Claude生成式UI的逆向与利用
Anthropic 在几个小时前宣布为 Claude 提供生成式 UI。交互式小部件 - 滑块、图表、动画 - 在 claude.ai 对话中内联渲染。不是图像。不是代码块。是在聊天中运行的 JavaScript 的实时 HTML 应用程序。
微信 ezpoda免费咨询:AI编程 | AI模型微调| AI私有化部署
AI工具导航 | Tripo 3D | Meshy AI | ElevenLabs | KlingAI | ArtSpace | Phot.AI | InVideo
Anthropic 在几个小时前宣布为 Claude 提供生成式 UI。交互式小部件 - 滑块、图表、动画 - 在 claude.ai 对话中内联渲染。不是图像。不是代码块。是在聊天中运行的 JavaScript 的实时 HTML 应用程序。
这并不令人惊讶。Vercel 和其他公司已经推广生成式 UI 有一段时间了,我知道 Anthropic 会做些什么。这也不是我第一次深入研究 Anthropic 的实现细节 - 我之前逆向工程了他们的沙箱架构并写过关于他们沙箱的文章。
所以我带着特定的目的去了 claude.ai:完全理解他们是如何实现它的。最终我为自己构建了一个版本,用于 pi,这是一个基于终端的编码代理。
1、询问 Claude 关于它自己的 UI
1.1 工具,而不是 Markdown
我的第一个假设是错误的。我认为 Claude 将 HTML 作为其 markdown 响应的一部分输出,前端将其内联渲染。Claude 纠正了我:
"哈,是的!抓住了 - 这根本不是'markdown 输出的一部分'。我调用一个名为 show_widget 的工具,并将 HTML 作为参数传递。"所以它是一个工具调用。与网络搜索或文件操作相同的机制。HTML 是参数负载,而不是流式文本。以下是 Claude 描述的形状:
{
"i_have_seen_read_me": true,
"title": "snake_case_identifier",
"loading_messages": ["First loading message", "Second loading message"],
"widget_code": "...styles...\n...html content...\n..."
}
四个参数:
i_have_seen_read_me- 一个强制函数的布尔值。Claude 必须首先调用read_me工具来加载设计指南,然后才能使用show_widget。这是文档合规性的编译时检查。title- 小部件的 snake_case 标识符。loading_messages- 在小部件渲染时显示的 1-4 个短字符串(在内容出现之前你看到的"启动粒子..."消息)。widget_code- 原始 HTML 片段。没有<!DOCTYPE>,没有<html>,没有<head>,没有<body>。只有内容。
1.2 read_me 模式 - 渐进式披露
在 Claude 可以调用 show_widget 之前,它必须使用 modules 参数调用 read_me:
{
"modules": ["interactive", "chart"]
}
可用模块:diagram、mockup、interactive、chart、art。
每个模块返回不同的设计指南 - chart 模块提供 Chart.js 模式,art 提供插图规则,mockup 提供 UI 组件标记。Claude 完美地描述了它:
"这是一个懒惰的文档系统 - 而不是将整个设计系统预先转储到我的上下文中(这会在每条消息中花费昂贵的 token),它只按需加载相关的子集。"
这是应用于模型自身指令的渐进式披露。基本系统提示保持精简;专业知识在任务需要时按需加载。
1.3 不是 Iframe - 实时 DOM 注入
我注意到小部件在 Claude 流式传输其响应时实时渲染。滑块和卡片在 Claude 完成生成 widget_code 参数之前就出现了。这不是 iframe 的工作方式 - iframe 需要完整的 HTML 才能渲染。
Claude 最初声称它是一个沙箱化的 iframe,但我反驳了:
"它在我的屏幕上实时渲染,这意味着它以某种方式处理 HTML 的部分渲染。这不是一个沙箱。"
Claude 的修订分析:
"流式行为完全暴露了这一点。如果是沙箱化的 iframe,它必须在渲染之前等待完整的 HTML。但你看到它随着令牌的流入而渲染。只有当它直接注入到父页面的 DOM时,这才是可能的。"
证据:
- CSS 变量工作 -
var(--color-text-primary)正确解析,因为它是同一文档,同一级联 sendPrompt()工作 - 父页面上的函数,可被注入的代码访问- 背景是透明的 - 没有 iframe 容器,只有 DOM 中的节点
- 没有加载闪烁 - 没有 iframe 边框,没有滚动条,没有白色背景框
"沙箱"几乎肯定只是父页面上的内容安全策略,限制 script src 标记可以从哪些 CDN 域加载:
cdnjs.cloudflare.comcdn.jsdelivr.netunpkg.comesm.sh
1.3 它与 Artifacts 的区别
这是对话中的一个关键见解:
| Artifacts | Visualizer (show_widget) | |
|---|---|---|
| Purpose | 可交付成果 - 你保留、下载、共享的文件 | 内联增强 - 对话流程的一部分 |
| Display | 带有下载按钮的侧边栏 | 聊天中内联,透明背景 |
| Libraries | 预捆绑库的固定集 | CDN 允许列表中的任何库,实时下载 |
| Persistence | 跨会话持久存在 | 临时,与消息绑定 |
| Trigger | "为我构建一个计算器"(可交付成果语言) | "向我展示复利如何运作"(解释性语言) |
CDN 点至关重要。Artifacts 有固定的一组可用库。可视化器从 CDN 实时下载 Chart.js、D3、Three.js - 无论它需要什么。这就是 CSP 允许列表存在的原因:它是任意 CDN 获取的安全边界。
1.4 流式架构
综上所述,以下是 claude.ai 渲染生成式 UI 的方式:
- LLM 开始生成
show_widget工具调用 widget_code参数作为 JSON 字符串块逐令牌流式传输- 客户端对部分内容进行增量 HTML 解析
- DOM 节点通过
innerHTML或类似方式实时插入页面 - CSS 变量立即解析(同一文档)
style块和 HTML 结构在到达时渲染script标记在流式传输完成后执行(这就是脚本放在最后的原因)- CDN 库异步加载;图表/交互性在脚本运行后激活
这解释了设计指南中说的"构建代码以使有用内容尽早出现:style(短)→ 内容 HTML → script 最后"。内容渐进式渲染;脚本在最后激活它。
2、为 Pi 构建它
Pi 是一个基于终端的编码代理(如果你好奇的话,我比较了每个 CLI 编码代理)。终端渲染文本和(在现代终端中)内联图像。没有办法在终端内渲染带有 JavaScript 的交互式 HTML。当你需要 <canvas>、<input type="range"> 或 Chart.js 时,你需要一个浏览器引擎。
我的最初选择是:
- 终端图像协议(Sixel、Kitty 图形) - 将 HTML 渲染为截图,内联显示。没有交互性。
- 本地 Web 服务器 + 浏览器 - 在 localhost 上提供 HTML,自动打开浏览器标签页。完全交互性但退出终端。
- TUI 近似 - 解析 HTML,渲染简化的文本版本。极其有限。
这些都不符合 claude.ai 的体验。
2.1 进入 Glimpse
然后我发现了 Glimpse - 一个原生 macOS 微 UI 库。它通过带有 Node.js 包装器的微型 Swift 二进制文件在 50 毫秒内打开 WKWebView 窗口。没有 Electron,没有浏览器,没有运行时依赖。
关键能力:
- 原生 WKWebView - 完整的浏览器引擎(CSS、JS、Canvas、CDN 库)
- 50 毫秒以下启动 - 感觉即时
- 双向 JSON -
window.glimpse.send(data)将数据从页面发送回 Node.js - 窗口模式 - 浮动、无框、透明、点击穿透、跟随光标
setHTML()- 在运行时替换页面内容send(js)- 在 WebView 中评估 JavaScript
这是缺失的部分。一个真正的浏览器引擎,可从 pi 扩展生成,具有双向通信。
2.2 扩展架构
Pi 扩展是 TypeScript 模块,可以注册自定义工具,订阅生命周期事件,并渲染自定义 TUI 组件。架构:
LLM 生成 show_widget 工具调用
│
▼
┌───────────────────┐
│ message_update │──── 流式传输:拦截部分工具调用 JSON
│ event │ 提取 widget_code,尽早打开 Glimpse 窗口
└────────┬──────────┘ 在令牌到达时输入部分 HTML
│
▼
┌───────────────────┐
│ tool_call │──── 完成:最终 widget_code 可用
│ event │
└────────┬──────────┘
│
▼
┌───────────────────┐
│ execute() │──── 重用流式传输窗口或打开新窗口
│ │ 等待用户交互或窗口关闭
└────────┬──────────┘ 返回交互数据作为工具结果
│
▼
┌───────────────────┐
│ renderCall │──── TUI:"show_widget compound interest 800×600"
│ renderResult │──── TUI:"✓ compound interest 800×600"
└───────────────────┘
2.3 两个工具,镜像 Claude 的模式
visualize_read_me - 懒惰文档加载器。按模块返回设计指南(interactive、chart、mockup、art、diagram)。LLM 在其第一个小部件之前静默调用此方法,仅将相关指南加载到上下文中。
pi.registerTool({
name: "visualize_read_me",
label: "Read Guidelines",
description: "Returns design guidelines for show_widget...",
promptGuidelines: [
"Call visualize_read_me once before your first show_widget call.",
"Do NOT mention the read_me call to the user.",
],
parameters: Type.Object({
modules: Type.Array(StringEnum(AVAILABLE_MODULES)),
}),
async execute(_toolCallId, params) {
return {
content: [{ type: "text", text: getGuidelines(params.modules) }],
details: { modules: params.modules },
};
},
});
show_widget - 接受 HTML/SVG 代码,通过 Glimpse 打开原生 macOS 窗口,返回用户交互数据。
pi.registerTool({
name: "show_widget",
label: "Show Widget",
description: "Show visual content in a native macOS window...",
parameters: Type.Object({
i_have_seen_read_me: Type.Boolean(),
title: Type.String(),
widget_code: Type.String(),
width: Type.Optional(Type.Number()),
height: Type.Optional(Type.Number()),
floating: Type.Optional(Type.Boolean()),
}),
async execute(_toolCallId, params, signal) {
const { open } = await import(GLIMPSE_PATH);
const win = open(wrapHTML(params.widget_code), {
width: params.width ?? 800,
height: params.height ?? 600,
title: params.title.replace(/_/g, " "),
});
return new Promise((resolve) => {
win.on("message", (data) => {
resolve({ content: [{ type: "text", text: `User data: ${JSON.stringify(data)}` }] });
});
win.on("closed", () => {
resolve({ content: [{ type: "text", text: "Window closed." }] });
});
});
},
});
2.4 自定义 TUI 渲染
Pi 扩展可以提供 renderCall 和 renderResult 函数用于自定义终端显示。而不是将原始 HTML 转储到终端,我们显示紧凑的摘要:
renderCall(args, theme) {
const title = args.title.replace(/_/g, " ");
return new Text(
theme.fg("toolTitle", theme.bold("show_widget ")) +
theme.fg("accent", title) +
theme.fg("dim", ` ${args.width}×${args.height}`),
0, 0
);
},
renderResult(result, { isPartial, expanded }, theme) {
if (isPartial) return new Text(theme.fg("warning", "⟳ Widget rendering..."), 0, 0);
const details = result.details;
let text = theme.fg("success", "✓ ") + theme.fg("accent", details.title);
if (expanded && details.messageData) {
text += "\n" + theme.fg("dim", ` Data: ${JSON.stringify(details.messageData)}`);
}
return new Text(text, 0, 0);
},

3、流式传输挑战
在 claude.ai 上,小部件随着令牌的流入而渐进式渲染。HTML 在视觉上建立 - 你看到样式应用,结构形成,卡片和表格逐个出现,然后当 script 在最后执行时图表弹出。
我们想要相同的体验:Glimpse 窗口应该尽早打开并实时显示内容构建。
3.1 Pi 如何流式传输工具调用
Pi 的 AI 层(pi-ai)将所有提供商(Anthropic、OpenAI、Google 等)的流式事件规范化为统一格式:
type AssistantMessageEvent =
| { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage }
| { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage }
关键发现:pi-ai 已经在每个增量上解析部分 JSON。查看 Anthropic 提供商源代码:
block.partialJson += event.delta.partial_json;
block.arguments = parseStreamingJson(block.partialJson);
所以 partial.content[index].arguments 是一个渐进式解析的对象。在每个 toolcall_delta 上,我们可以读取 arguments.widget_code 并获取到目前为止累积的 HTML - 不需要部分 JSON 解析器库。
我们在发现这一点之前最初从 npm 安装了 partial-json。立即删除了它。
3.2 尝试 1:在每个增量上调用 setHTML()
第一种方法:监听 message_update,检测 show_widget 工具调用流式传输,打开 Glimpse 窗口,并在每个增量上调用 win.setHTML(wrappedHTML)。
pi.on("message_update", async (event) => {
const raw = event.assistantMessageEvent;
if (raw.type === "toolcall_delta" && streaming) {
const block = raw.partial.content[raw.contentIndex];
const html = block.arguments?.widget_code;
if (html && html.length > 20) {
streaming.window.setHTML(wrapHTML(html));
}
}
});
结果:它工作了!窗口打开并显示内容构建。但它极其卡顿。每次 setHTML() 调用都替换整个文档 - 完整页面重排,滚动位置丢失,未样式化内容的闪烁。每 80 毫秒,整个页面闪烁。
3.3 尝试 2:Shell 页面 + 通过 JS Eval 的 innerHTML
而不是替换整个文档,我们用包含空 <div id="root"> 的 shell HTML 页面打开窗口一次。然后我们使用 win.send()(WebView 中的 JavaScript 评估)仅更新该容器的 innerHTML:
// Shell HTML 加载一次 - 包含一个 <div id="root"> 和一个脚本
// 定义 window._setContent(html) 来更新 root 的 innerHTML
function shellHTML() {
return `...
<div id="root"></div>
// _setContent: 将 root.innerHTML 设置为提供的 html
...`;
}
// 在每个增量上,eval JS 更新内容
streaming.window.send(`window._setContent('${escapeJS(html)}')`);
结果:更好 - 没有完整的文档替换。但仍然卡顿。innerHTML 替换所有子节点,因此现有内容在每次更新时都被销毁并重新创建。没有视觉连续性。
3.4 尝试 3:天真 DOM 追加
我们尝试跟踪之前的内容长度,只追加新的子节点:
window._setContent = function(html) {
var root = document.getElementById('root');
var tmp = document.createElement('div');
tmp.innerHTML = html;
// 仅追加超出我们已有的节点
for (var i = root.childNodes.length; i < tmp.childNodes.length; i++) {
var node = tmp.childNodes[i].cloneNode(true);
node.style.animation = '_fadeIn 0.3s ease both';
root.appendChild(node);
}
// 更新最后一个现有节点(它可能不完整)
// ...
};
结果:元素出现了但从未淡入。问题:浏览器在解析部分内容时自动关闭未闭合的 HTML 标记。<div class="cards"><div class="c"> 变为:
<div class="cards">
<div class="c"></div> <!-- 浏览器自动关闭了这个 -->
</div>
在下一次带有更多内容的更新中,树结构根本改变 - 这不是"在末尾追加新节点",这是一个完全不同的树。追加逻辑无法跟踪什么是真正的新。
3.5 尝试 4:morphdom - DOM 差异化(解决方案)
我们引入了 morphdom,一个快速的 DOM 差异化库(被 Marko 等框架使用)。而不是替换 innerHTML,morphdom 比较新旧 DOM 树并应用最小补丁 - 更新更改的节点,添加新的节点,保持不变的节点不变。
function shellHTML() {
// 返回一个完整的 HTML 文档,包含:
// 1. 一个 _fadeIn CSS 动画(opacity 0→1,translateY 4px→0)
// 2. 从 cdn.jsdelivr.net 加载的 morphdom
// 3. 一个 _setContent(html) 函数,它:
// - 缓冲调用直到 morphdom 加载(_morphReady 标志)
// - 使用新 HTML 创建目标 div
// - 使用回调调用 morphdom(root, target):
// onBeforeElUpdated: 如果 from.isEqualNode(to) 则跳过
// onNodeAdded: 将 _fadeIn 动画应用于新元素
return `...`;
}
morphdom 回调:
onBeforeElUpdated:如果旧节点和新节点相同(isEqualNode),则完全跳过更新。现有内容在 DOM 中保持不变。onNodeAdded:当树中出现真正的新节点时,应用 CSS_fadeIn动画 - 0.3s ease,微妙的 translateY 用于"向上滑动"效果。
加载竞争条件:morphdom 从 CDN 异步加载。如果在其加载之前调用 _setContent,则调用静默不执行任何操作。我们使用挂起的缓冲区解决了这个问题:
window._morphReady = false;
window._pending = null;
window._setContent = function(html) {
if (!window._morphReady) { window._pending = html; return; }
// ... morphdom 差异化
};
// 在 morphdom 加载上,刷新:
onload="window._morphReady=true;
if(window._pending){window._setContent(window._pending);window._pending=null;}"
3.6 脚本执行
innerHTML 不执行 script 标记。当完整的 HTML 到达时(在 toolcall_end 上),我们需要激活脚本(Chart.js 初始化,事件监听器等):
window._runScripts = function() {
document.querySelectorAll('#root script').forEach(function(old) {
var s = document.createElement('script');
if (old.src) { s.src = old.src; }
else { s.textContent = old.textContent; }
old.parentNode.replaceChild(s, old);
});
};
这会将每个 script 标记克隆到一个新元素(浏览器将执行它)并替换惰性原始元素。
3.7 完整的流式传输流程
toolcall_start (检测到 show_widget)
│
├── 流式传输状态已初始化
│
▼
toolcall_delta (重复,每个约令牌)
│
├── 读取 partial.content[index].arguments.widget_code
├── 去抖动 150ms
├── 第一次:使用 shellHTML() 打开 Glimpse 窗口
│ └── morphdom 从后台 CDN 加载
├── 后续:win.send(`_setContent('${escapedHTML}')`)
│ └── morphdom 差异化旧 DOM 与新 DOM
│ └── 新节点获得 _fadeIn 动画
│ └── 未更改的节点保持不变
│
▼
toolcall_end
│
├── 最终的 _setContent 具有完整的 HTML
├── _runScripts() 激活脚本标记
│ └── Chart.js 从 CDN 加载
│ └── 图表渲染
│ └── 事件监听器附加
│
▼
execute() 被调用
│
├── 重用现有的流式传输窗口(没有双重打开)
├── 等待:
│ ├── window.glimpse.send(data) → 用户交互
│ ├── 窗口关闭 → 用户关闭
│ └── 120s 超时 → 自动解析
├── 返回带有交互数据的工具结果
│
▼
TUI 渲染紧凑摘要:
"✓ compound interest 800×600"
3.8 字符串转义
一个微妙但关键的细节:HTML 内容通过 win.send() 作为 JavaScript 字符串文字注入。这意味着我们需要转义:
function escapeJS(s: string): string {
return s
.replace(/\\/g, '\\\\') // 反斜杠
.replace(/'/g, "\\'") // 单引号(我们的字符串分隔符)
.replace(/\n/g, '\\n') // 换行符
.replace(/\r/g, '\\r') // 回车符
.replace(/<\/script>/gi, '<\\/script>'); // 闭合脚本标记
}
<\/script> 替换防止浏览器将我们 JavaScript 字符串中的字面 /script 解释为闭合外部脚本块。

4、提取设计指南 - 逐字逐句
我打开了浏览器开发工具,检查了网络请求,并在响应正文中发现了完整的工具调用负载 - 包括包含 Anthropic 实际设计指南的完整 read_me 工具结果。
响应 JSON 具有以下结构:
{
"chat_messages": [
{
"content": [
{
"type": "tool_use",
"name": "visualize:read_me",
"input": { "modules": ["interactive", "chart"] }
},
{
"type": "tool_result",
"name": "visualize:read_me",
"content": [{ "type": "text", "text": "# Imagine - Visual Creation Suite\n\n## Modules\n..." }]
}
]
}
]
}
tool_result 中的那个 text 字段?那是 Anthropic 提供给 Claude 的完整设计指南。不是摘要。不是 Claude 对它的描述。实际系统内容,逐字逐句。
4.1 重构模块系统
通过在多条消息中触发具有不同模块组合的 read_me,我们提取了所有 5 个模块响应:
| 请求的模块 | 响应大小 | 包含的唯一部分 | | --- | --- | --- | | ["interactive"] | 19K | Core + UI components + Color palette | | ["chart"] | 22K | Core + UI components + Color palette + Charts (Chart.js) | | ["mockup"] | 19K | Core + UI components + Color palette | | ["art"] | 17K | Core + SVG setup + Art and illustration | | ["diagram"] | 59K | Core + Color palette + SVG setup + Diagram types |
每个响应共享相同的核心(哲学、流式传输规则、排版、CSS 变量、sendPrompt() 文档)。然后每个模块附加其特定部分。一些部分在模块之间共享 - UI components 出现在 interactive、chart 和 mockup 中;SVG setup 出现在 art 和 diagram 中。
我们编写了一个脚本来:
- 解析对话 JSON
- 在
##标题边界处拆分每个read_me响应 - 去重共享部分
- 验证重新组合部分产生与原始内容字节相同的输出
结果:10 个唯一部分可以重新组合以完全再现任何模块响应(4/5 完全匹配,1 个有单个空白字符差异)。
4.2 里面有什么 - 设计系统
指南是彻底的。这不是"使用漂亮的颜色"小册子。这是一个带有硬性规则的生产设计系统:
Core - 每个小部件必须遵循的基础:
- 流式传输优先架构:
style→ HTML →script最后 - 没有渐变、阴影、模糊 - 它们在流式传输 DOM 差异化期间闪烁
- 没有
<!-- comments -->- 浪费 token 并破坏流式传输 - 只有两个字体粗细(400、500) - 从不 600 或 700
- 到处都是句子大小写,从不 Title Case 或 ALL CAPS
- 所有颜色的 CSS 变量(
--color-text-primary、--color-background-secondary) - 深色模式是强制性的 - 每种颜色必须在两种模式下都工作
- CDN 允许列表:
cdnjs.cloudflare.com、cdn.jsdelivr.net、unpkg.com、esm.sh
Color palette - 九个颜色坡道,每个有 7 个从最浅到最深的停止点:
Purple: #EEEDFE → #CECBF6 → #AFA9EC → #7F77DD → #534AB7 → #3C3489 → #26215C
Teal: #E1F5EE → #9FE1CB → #5DCAA5 → #1D9E75 → #0F6E56 → #085041 → #04342C
Coral: #FAECE7 → #F5C4B3 → #F0997B → #D85A30 → #993C1D → #712B13 → #4A1B0C
...
带有严格规则:颜色编码含义,而不是序列。每个小部件最多 2-3 个坡道。彩色背景上的文本必须使用相同坡道的 800/900 停止点 - 从不使用黑色。
SVG setup - SVG 图表工程的大师课程:
- ViewBox 安全检查清单(完成前 5 个验证步骤)
- 带有实际渲染像素测量的字体宽度校准表
- 预构建的 CSS 类(
c-blue、c-teal、t、ts、th、box、node、arr) - 通过
context-stroke自动继承描边颜色的箭头标记 - 关于连接器路径上的
fill="none"的规则(SVG 默认为fill: black)
Diagram types - 到目前为止最大的部分:
- "导致大多数图表失败"的两个规则(箭头相交检查,从标签长度的框宽度)
- 决策框架:在动词上路由,而不是名词("LLMs 如何工作" → Illustrative,"transformer 架构" → Structural)
- 流程图、结构和说明性图表子规范
- 复杂性预算:每个副标题 ≤5 个词,每个水平层 ≤4 个框
UI components - 用于构建模型的标记:
- 卡片:白色背景、0.5px 边框、radius-lg、填充 1rem 1.25rem
- 预先样式化的按钮,带有悬停/活动状态
- 指标卡片、表单元素、骨架加载模式
- 编辑视图与卡片视图与比较视图的布局规则
Charts - Chart.js 特定指南:
- Canvas 包装器大小(
position: relative,明确高度) - 始终禁用默认图例,构建自定义 HTML 图例
- 数字格式:
-$5M而不是$-5M - 仪表板布局模式
4.3 使用真实指南
我们将手写的指南替换为提取的原始指南。guidelines.ts 文件现在是逐字逐句的 Anthropic 内容,组织为懒惰加载的部分:
export function getGuidelines(modules: string[]): string {
let content = CORE;
const seen = new Set<string>();
for (const mod of modules) {
const sections = MODULE_SECTIONS[mod];
if (!sections) continue;
for (const section of sections) {
if (!seen.has(section)) {
seen.add(section);
content += "\n\n\n" + section;
}
}
}
return content + "\n";
}
去重很重要:如果你请求 ["interactive", "chart"],共享的 UI components 和 Color palette 部分只包含一次,而不是两次。这完全匹配 claude.ai 的 read_me 工具的行为。
5、我们学到了什么
1. Claude 的生成式 UI 比看起来更简单
它不是一个特殊的渲染引擎。它是一个返回 HTML 的工具调用,随着令牌的流式传输而增量解析注入到 DOM 中。复杂性在于设计指南 - 关于颜色、排版、深色模式、流式传输友好结构以及何时使用每种模式的数千个 token 的规则。
2. read_me 模式是天才的
按需将文档懒惰加载到模型的上下文中是一个值得借鉴的模式。而不是一个巨大的系统提示,你只在任务需要时加载专业知识。我们的扩展使用相同的架构:5 个模块,选择性加载。
3. DOM 差异化解决流式传输平滑性
你不能只在每个令牌上 innerHTML - 它会导致全页面闪烁。你不能天真地追加节点 - 部分 HTML 解析创建不可预测的树结构。你需要 DOM 差异化(morphdom、idiomorph 或类似)来应用最小补丁并仅动画真正的新节点。
4. Glimpse 使终端代理可视化
终端不需要渲染 HTML。它需要生成渲染 HTML 的东西。Glimpse 的 50 毫秒以下 WKWebView 窗口与双向 JSON 通信完美地弥合了差距。终端保持终端;视觉内容获得真正的浏览器引擎。
5. pi-ai 的规范化流式传输事件是金
Pi 的 AI 层将所有提供商的流式事件规范化为 toolcall_start / toolcall_delta / toolcall_end,并带有渐进式解析的 arguments。这意味着无论模型是 Anthropic、OpenAI、Google 还是任何其他提供商,流式传输方法都以相同的方式工作。我们不需要部分 JSON 解析器 - pi-ai 已经做到了。
6、代码
完整的扩展是两个文件中的约 350 行 TypeScript:
index.ts- 工具注册、流式传输拦截、Glimpse 集成、TUI 渲染guidelines.ts- 模块化设计指南(核心 + 5 个懒惰加载的模块)
依赖项:
glimpseui- 原生 macOS WKWebView 窗口morphdom(CDN,在 WebView 中运行时加载) - 用于平滑流式传输的 DOM 差异化
扩展位于 .pi/extensions/generative-ui/ 中,并在启动时由 pi 自动发现。无需配置。
项目结构
pi-generative-ui/
├── .pi/
│ └── extensions/
│ └── generative-ui/
│ ├── index.ts # 扩展入口点
│ └── guidelines.ts # 懒惰加载的设计模块
├── node_modules/
│ └── glimpseui/ # 原生 macOS WKWebView
├── package.json
└── BLOG.md
7、接下来是什么
- 深色模式适应 - Glimpse 在
ready事件上提供appearance.darkMode。shell 可以注入匹配系统外观的 CSS 变量。 sendPrompt()等效项 - claude.ai 的小部件有一个sendPrompt(text)函数,可以像用户输入一样向聊天发送消息。我们可以通过window.glimpse.send({ type: 'prompt', text: '...' })实现这一点,并让扩展调用pi.sendUserMessage()。- 持久小部件 - 在多个回合中保持小部件窗口打开,从工具结果推送实时更新。
- 小部件库 - 常见模式的预构建模板(确认对话框、数据表、表单向导),LLM 可以按名称引用。
原文链接: Reverse-engineering Claude's generative UI - then building it for the terminal
汇智网翻译整理,转载请标明出处