用 Joplo 讲清楚 Intelligent UI:AI 怎样直接回答出一个界面

本文改写自作者的长文《Intelligent UI 实现详解》。原文约一万字,有抓包分析和复刻代码(intelligent-ui-demo)。这里只保留主干,并把关键流程做成了一个可以播放的 Joplo 演示。

问 AI “帮我算一下房贷”,它可以回一段文字,也可以直接回一个计算器:拖动滑块,月供马上变化。后一种回答就是本文要讲的东西。看起来只是“AI 输出了一个组件”,背后其实要回答三个问题:

  1. 用什么语言描述界面?
  2. 怎么边写边传?
  3. 怎么安全地显示出来?

先看演示

用舞台下方的播放控件逐步播放,也可以全屏观看。在新窗口打开演示

演示围绕同一个房贷计算器展开:正常流程、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

原文与延伸阅读