用 Joplo 讲清楚 Intelligent UI:AI 怎样直接回答出一个界面
本文改写自作者的长文《Intelligent UI 实现详解》。原文约一万字,有抓包分析和复刻代码(intelligent-ui-demo)。这里只保留主干,并把关键流程做成了一个可以播放的 Joplo 演示。
问 AI “帮我算一下房贷”,它可以回一段文字,也可以直接回一个计算器:拖动滑块,月供马上变化。后一种回答就是本文要讲的东西。看起来只是“AI 输出了一个组件”,背后其实要回答三个问题:
- 用什么语言描述界面?
- 怎么边写边传?
- 怎么安全地显示出来?
先看演示
演示围绕同一个房贷计算器展开:正常流程、AI 写错时怎样容错、沙箱的安全边界,以及“写数据”和“写程序”两条路线的对比。下面的文字和演示一一对应,可以边看边读。
两条路线:写数据,还是写程序
让 AI 直接输出可交互的界面,有两种做法。
写数据:AI 输出一份组件清单,浏览器用已有的组件把它拼出来。代表是 A2UI。
- 天然安全:清单里没有可执行代码,组件只能来自目录里的白名单。
- 可以用在原生 App 里。
- 代价是计算要回到 AI:用户每改一次参数,都要再问一次模型。
写程序:AI 输出一段小程序,浏览器在本地运行它。代表是 ChatGPT 的 Intelligent UI。
- 计算在本地即时完成,可以做复杂的交互。
- 代价是必须隔离;程序写错了,可能整个界面都跑不起来;也很难用在原生 App 里。
这几个名字常被混在一起,其实是三层东西:A2UI 是界面格式(写数据);AG-UI 是传输协议,事件包括 RUN_STARTED、TEXT_MESSAGE_CONTENT、TOOL_CALL_ARGS、STATE_DELTA(JSON Patch)等,它可以承载 A2UI、Open-JSON-UI、MCP-UI 这些格式;Intelligent UI 则是“写程序”路线的一整条工程链路。
Intelligent UI 的链路:一次“帮我算一下房贷”
1. AI 逐字写出 DIL 源码
模型输出的是 Markdown 正文,中间夹着 @body 指令和类似 JSX 的标签:
{@body const [amount, setAmount] = DIL.useState(100)}
{@body const monthly = amount * 10000 * r * (1+r)**n / ((1+r)**n - 1)}
<slider label="贷款金额(万元)" value={amount} onChange={setAmount} min={10} max={500}/>
2. 服务端编译器:每次都从头编译,而且不报错退出
模型是一个字一个字写出来的,所以编译器拿到的输入几乎总是不完整的。它每隔一小段时间,就把到目前为止的全部内容重新编译一遍,并且:
- 给每个
useState加上 key,例如DIL.useState(100, {key: "amount"}),让状态在刷新后还在; - 给带表达式的元素套一层“保护罩”,算错了只影响这一个元素:
function __dilSafe(evaluate, fallback) {
try {
return evaluate();
} catch {
return fallback;
}
}
- 遇到问题记一条诊断,而不是失败退出,例如
{"code":"unclosed_block","action":"recovered_parse","line":66}。
原文的一次抓包里,有 338 条诊断、134 次程序更新、56 处保护罩。
3. 用 SSE 推送补丁
服务端只发一种消息:delta 补丁。正文文本和程序代码是两条同时进行的线:
{"p": "/message/content/parts/0", "o": "append", "v": "…"}
{"p": "/message/metadata/model_dil_v2/code", "o": "replace", "v": "…"}
连接断了可以 resume 续传。一次回答的连接大约 2.5 MB、持续几十秒。
4. 两层沙箱
浏览器把程序交给沙箱运行:
- 外层是一个跨域 iframe,CSP 从
default-src 'none'起步,禁止联网; - 内层是一个 Web Worker,在独立线程里运行程序,只交出一棵界面数据树,例如
{"t":"box","p":{"gap":3},"c":[…]}。死循环了,直接终止这个 Worker 就行。
事件处理器在树里只是一个编号({"__dilFn":"fn1"})。程序需要“复制”“打开链接”“发起新一轮提问”“打开实体详情”“调用插件”这 5 种能力时,拿到的也只是能力编号:调用时发一条消息,由主页面校验参数后再执行。
界面用的是宿主提供的组件:30 个常驻、283 个按需加载。AI 只能按名字引用;还没确认的组件,先显示一个占位框。
5. 交互不经过服务器
用户拖动滑块,页面告诉沙箱“fn1 被触发,新值 300”,沙箱重新计算,交回一棵新树;页面比对新旧两棵树,只更新变化的部分。整个过程不经过服务器。
6. 状态回到下一轮对话
带名字的状态会上报给服务端(POST …/dil/view_state)。用户接着问“按现在的参数,等额本金每月多少”,下一轮请求会带上这些状态快照,AI 直接按 300 万、20 年来回答。
同一个计算器,用 A2UI 写
换成“写数据”路线:AI 先发 createSurface,再用 updateComponents 发一组平铺的组件(必须有 root),然后用 updateDataModel 发初始数据。
用户拖动滑块时,本地的 /amount 变了,但月供不会变,因为清单里没有计算逻辑。用户点“重新计算”,一个带上下文的 action 发回 AI,AI 再发一次 updateDataModel。每算一次,就是一次往返。
这就是两条路线最直观的差别:A2UI 每次重算一次往返,Intelligent UI 在本地即时完成。
AI 写错的时候
“写程序”路线最怕的是语法错误:整个程序跑不起来,而 AI 每多写几个字就再失败一次,用户看到的是持续的空白。作者用 DeepSeek 复刻时遇到的常见错误,以及对应的处理:
code、pre里出现花括号:这两个标签里的内容一律当纯文字;- 指令漏了结尾的花括号:在这一行结尾截断;
- 用了没声明的变量(
value={usage}):看到成对的usage/setUsage时自动补上声明; - 整篇包在 html 代码围栏里:去掉围栏,记一条诊断。
加上两道保险之后,12 份真实输出、30,184 个“写到一半”的版本全部能运行,没有一个退回纯文字。另一个坑是思考模式:首屏要多等 20–30 秒,一定要显示进度。
作者总结的做法
- 编译器的容错要做重,把出错的影响控制在一个元素(保护罩);
- 给 AI 的说明里放完整、可运行的示例,并把它们纳入测试;
- 沙箱两层隔离,CSP 禁止联网;能力用替身,在主页面校验参数,防止原型污染;
- 流式传输用“快照 + 补丁”,并支持 resume;
- 更新界面时做新旧树比对,避免输入框丢内容、丢光标。
为什么用 Joplo 来讲
这篇文章最难讲清楚的,不是某个概念,而是“一件事接着一件事发生”:模型在写、编译器在追、补丁在传、沙箱在算、状态又回到对话。截图只能定格其中一刻,录屏又改不了。
Joplo 正好适合这类技术讲解:
- 按步骤播放:每一步配一句字幕,观众可以暂停、回退、全屏;
- 方案对比:同一个计算器写成“写数据”和“写程序”两个方案,在看板里来回切换;
- 情况:正常流程、AI 写错、越过安全边界,各是一种情况,不容易漏掉;
- 源码就是 TSX:讲解和代码放在一起,交给 Agent 修改也只改那一处;
- 一条命令分享:写完运行
npx joplo share,就得到上面那个链接。
想自己做一个,从这里开始:
npx joplo@latest init my-demo
cd my-demo
npm install
npx joplo dev --open
原文与延伸阅读
- Intelligent UI 实现详解(原文,含抓包与示意图)
- intelligent-ui-demo(作者的复刻实现)
- Joplo(开源,Apache-2.0)