开发者设计了Markdown→生成式UI协议系统,让前端开发者可用简洁语法动态生成和控制界面。
"用户界面在很大程度上会消亡," Eric Schmidt 预测道。Agent 将在需要时动态生成你所需的任何 UI。我构建了一个原型来探索这个前提。
这是一个 Agent 化 AI 助手,从零开始生成 React UI,数据在客户端、服务器和 LLM 之间流动。该原型基于三个想法:
Markdown 作为协议 —— 一条流携带文本、可执行代码和数据。LLM 已经知道如何编写它。
流式执行 —— Agent 编写并执行代码。每个语句完成后立即执行 —— 无需等待完整响应。
mount() 原语 —— 一个函数,让 Agent 创建响应式 UI,具有客户端-服务器-LLM 通信的数据流模式。
查看这里的仓库。
如何将代码执行与文本和数据相结合?全部流式传输并以任意顺序交错?在一个协议中?
我一直回到 markdown。LLM 对 markdown 了如指掌 —— 格式化、代码围栏,所有这些。为什么要教它们新东西?
所以我确定了三种块类型:
这可能看起来像这样:
Hey! I am the assistant. This text is streamed to the user token by token.
But I can also run code...
```tsx agent.run
const messages = await fetchMessages()
I can mount UIs
const fakeMovieData = new StreamedData("fake-movies");
const form = mount({
streamedData: fakeMovieData,
ui: ({ streamedData }) => <Movies movies={streamedData} />
})
I can stream data into these UIs [data appears one by one...]
[
{ "name": "Blade Runner", "rating": 4.5 },
{ "name": "Dune", "rating": 4.2 }
]
All within the same response...
文本、代码和数据——交错,以任意顺序,任意次数。解析器在令牌到达时逐步处理它。
语法自然可扩展。需要新的块类型?只需添加新的围栏头。`tsx agent.run` 和 `json agent.data` 只是前两个。
## 反馈循环
反馈循环很简单:`console.log` 是 Agent 与自己沟通的方式。它的工作方式如下:
- LLM 生成带有代码块的 markdown
- 文本流式传输给用户,代码在服务器上逐步执行
- `console.*` 输出和异常作为新的转折反馈给 LLM
- 如果没有输出或异常 —— 完成,等待新的用户查询
这意味着 Agent 可以对自己的执行做出反应:
How many messages did I get?
```tsx agent.run
const messages = await fetchMessages();
console.log('messagesCount:', messages.length);
[runtime transcript]
messagesCount: 4
You have four new messages.
或者它可以暂停并等待用户输入:
const form = mount({ /* ... */ });
const answer = await form.result; // Blocks until user submits
console.log("user:responded", answer);
我希望语句在 LLM 生成它们时执行,无需等待代码围栏关闭。结果将是更响应的用户体验——API 调用启动、UI 渲染、错误浮出,所有这些都在 LLM 仍在发送令牌时发生。
问题:流式执行还不是标准原语。没有运行时让你输入令牌并在语句完成时执行它们,具有共享上下文和顶级 await。
我最终构建了 bun-streaming-exec 来处理这个问题,使用 vm.Script 和一些"创意性"的包装。如果你想深入了解,我写了一篇关于这个方法的专门文章。
它很奇怪吗?是的。有效吗?大部分时间。
将文本、代码和数据放在一个流中,你拥有 Agent 化 UI 的大部分构建块。缺失的部分是一种将代码转变为实时界面的方式。对于 UI,React 是显而易见的选择。LLM 已经看到了数百万个 React 组件。它们知道 JSX。
核心原语是 mount():
mount({
ui: () => <Card>Hello from the agent!</Card>
});
LLM 生成代码,服务器执行它。mount() 序列化 React 组件并将其发送到客户端。客户端在聊天中呈现它。
真正的力量来自数据流。
构建这个,我最终得到了四种不同的模式来在服务器、客户端和 LLM 之间移动数据:
Agent 可以等待用户输入:
const form = mount({
outputSchema: z.object({ name: z.string().min(1) }),
ui: ({ output }) => (
<Box>
<TextField {...output.name} label="Your name" />
<Button type="submit" {...output}>Submit</Button>
</Box>
)
});
const { name } = await form.result; // Blocks until submit
console.log("user:responded", name);
{...output.name} 连接字段。await form.result 暂停执行,直到用户提交。结果通过 console.log 反馈给 LLM。
服务器端变化透明地更新 UI:
const data = new Data({ progress: 0 });
mount({
data,
ui: ({ data }) => <LinearProgress value={data.progress} />
});
data.progress = 40; // UI updates immediately
在幕后,Data 对象是代理。突变被检测、序列化为补丁、通过 WebSocket 发送并在客户端应用。
LLM 可以将 JSON 直接流式传输到 UI:
const movies = new StreamedData("movies-list");
mount({
streamedData: movies,
ui: ({ streamedData }) => (
<Card>
{streamedData?.map((movie, i) => (
<Recommendation key={i} rating={movie.rating}>{movie.name}</Recommendation>
)) ?? <Loading />}
</Card>
)
});
[
{ "name": "Blade Runner", "rating": 4.5 },
{ "name": "Dune", "rating": 4.2 }
]
JSON 逐令牌流式传输。客户端使用 jsonriver 逐步解析,随着数据到达而更新 UI。完成后,服务器也可以通过 StreamedData 对象访问它。
对于 UI 内的实时交互:
const data = new Data({ messages, loading: false });
const onRefresh = async () => {
data.loading = true;
data.messages = await loadMessages();
data.loading = false;
};
mount({
data,
callbacks: { onRefresh },
ui: ({ data, callbacks }) => (
<Card>
<MessageList messages={data.messages} />
<RefreshButton loading={data.loading} onClick={callbacks.onRefresh} />
</Card>
)
});
单击按钮调用服务器端函数。回调获取新数据、更新状态,UI 反映它 —— 全部在代码中,不会触发新的 LLM 轮次。
随着 UI 变得更加复杂,用户必须等待更长时间才能让 LLM 生成代码。对于更复杂的 UI,有一个插槽机制:Agent 可以先挂载骨架界面,然后稍后注入更重的部分。
结合流式执行,骨架在其 mount() 语句完成的瞬间出现。每个 mountSlot() 调用在 LLM 完成生成它时填充一个部分:
const shell = mount({
data, callbacks: { onResolve },
ui: () => (
<Card>
<Slot name="stats" fallback={<Skeleton variant="rectangular" height={120} />} />
<Slot name="blockers" fallback={<Skeleton variant="rectangular" height={80} />} />
</Card>
),
});
shell.mountSlot("stats", ({ data }) => <StatsRow data={data} />);
shell.mountSlot("blockers", ({ callbacks }) => <BlockerList onResolve={callbacks.onResolve} />);
插槽与其父元素共享相同的上下文:数据、回调、流式数据。这意味着插槽相互间保持反应性。一个插槽中的回调可以改变共享数据,而每个读取它的其他插槽都会自动更新。
Claude Code 和 ChatGPT 的 Code Interpreter 已经大规模执行 LLM 生成的代码 —— 沙箱、基于能力的权限和静态分析正在整个行业积极开发中。困难的未解决问题是 prompt injection,这对所有 Agent 架构同样成立 —— 工具调用、MCP 和代码执行一样。该项目没有处理任何这些。它探索上面的层:一旦假设安全得到合理解决,你可以构建什么。我们还没有完全到达那里。
我构建这个原型是为了看看 markdown 是否真的可以作为 Agent 化 UI 的协议而不需要任何微调。当我第一次让它运行时,我很惊讶。模型立即接受了它。虽然不完美,但核心想法就是有效的。
那是因为这里的每个设计选择都优化了一件事:LLM 人体工学。
Markdown 与代码围栏一起使用,因为 LLM 已在数十亿文档上训练。TypeScript 因为它在 GitHub 上最常用的语言中连接服务器和客户端。React 因为它是它们最熟悉的 UI 框架。mount() 因为它的构建块 —— 可等待的结果、回调、Zod 架构 —— 是模型已经看到数百万次的模式。
该系统不会教模型任何新东西。它将模型已知的模式排列成一个真正运行的系统。
你可以从头开始为 Agent 化 UI 设计新协议。或者你可以将运行时与模型的训练数据相匹配:markdown。
查看这里的仓库。