Claude生成式UI的逆向与利用

Anthropic 在几个小时前宣布为 Claude 提供生成式 UI。交互式小部件 - 滑块、图表、动画 - 在 claude.ai 对话中内联渲染。不是图像。不是代码块。是在聊天中运行的 JavaScript 的实时 HTML 应用程序。

Claude生成式UI的逆向与利用
微信 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"]
}

可用模块:diagrammockupinteractivechartart

每个模块返回不同的设计指南 - 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.com
  • cdn.jsdelivr.net
  • unpkg.com
  • esm.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 的方式:

  1. LLM 开始生成 show_widget 工具调用
  2. widget_code 参数作为 JSON 字符串块逐令牌流式传输
  3. 客户端对部分内容进行增量 HTML 解析
  4. DOM 节点通过 innerHTML 或类似方式实时插入页面
  5. CSS 变量立即解析(同一文档)
  6. style 块和 HTML 结构在到达时渲染
  7. script 标记在流式传输完成后执行(这就是脚本放在最后的原因)
  8. CDN 库异步加载;图表/交互性在脚本运行后激活

这解释了设计指南中说的"构建代码以使有用内容尽早出现:style(短)→ 内容 HTML → script 最后"。内容渐进式渲染;脚本在最后激活它。

2、为 Pi 构建它

Pi 是一个基于终端的编码代理(如果你好奇的话,我比较了每个 CLI 编码代理)。终端渲染文本和(在现代终端中)内联图像。没有办法在终端内渲染带有 JavaScript 的交互式 HTML。当你需要 <canvas><input type="range"> 或 Chart.js 时,你需要一个浏览器引擎。

我的最初选择是:

  1. 终端图像协议(Sixel、Kitty 图形) - 将 HTML 渲染为截图,内联显示。没有交互性。
  2. 本地 Web 服务器 + 浏览器 - 在 localhost 上提供 HTML,自动打开浏览器标签页。完全交互性但退出终端。
  3. 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 扩展可以提供 renderCallrenderResult 函数用于自定义终端显示。而不是将原始 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 中。

我们编写了一个脚本来:

  1. 解析对话 JSON
  2. ## 标题边界处拆分每个 read_me 响应
  3. 去重共享部分
  4. 验证重新组合部分产生与原始内容字节相同的输出

结果: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.comcdn.jsdelivr.netunpkg.comesm.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-bluec-tealttsthboxnodearr
  • 通过 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 componentsColor 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

汇智网翻译整理,转载请标明出处