> ## Content Index
> Fetch the complete content index at: https://www.hubwiz.com/blog/llms.txt
> Use this file to discover other available public pages before exploring further.

# Claude生成式UI的逆向与利用
- URL: https://www.hubwiz.com/blog/reverse-engineering-and-replicate-claude-generative-ui/
- Published: 2026-03-14T14:15:01.000Z
- Updated: 2026-03-14T14:15:01.000Z
- Description: Anthropic 在几个小时前宣布为 Claude 提供生成式 UI。交互式小部件 - 滑块、图表、动画 - 在 claude.ai 对话中内联渲染。不是图像。不是代码块。是在聊天中运行的 JavaScript 的实时 HTML 应用程序。
- Author: admin
- Tags: APPLICATION, CLAUDE, #Import 2026-10-14 14:41

Anthropic 在几个小时前[宣布为 Claude 提供生成式 UI](https://x.com/claudeai/status/2032124273587077133?ref=hubwiz.com)。交互式小部件 - 滑块、图表、动画 - 在 claude.ai 对话中内联渲染。不是图像。不是代码块。是在聊天中运行的 JavaScript 的实时 HTML 应用程序。

这并不令人惊讶。Vercel 和其他公司已经推广生成式 UI 有一段时间了，我知道 Anthropic 会做些什么。这也不是我第一次深入研究 Anthropic 的实现细节 - 我之前[逆向工程了他们的沙箱架构](https://www.hubwiz.com/blog/sandboxed-execution-environment)并写过关于他们[沙箱](https://www.hubwiz.com/blog/sandbox-comparison-2026)的文章。

所以我带着特定的目的去了 claude.ai：完全理解他们是如何实现它的。最终我为自己构建了一个版本，用于 [pi](https://github.com/badlogic/pi?ref=hubwiz.com)，这是一个基于终端的编码代理。

## 1、询问 Claude 关于它自己的 UI

### 1.1 工具，而不是 Markdown

我的第一个假设是错误的。我认为 Claude 将 HTML 作为其 markdown 响应的一部分输出，前端将其内联渲染。Claude 纠正了我：

> "哈，是的！抓住了 - 这根本不是'markdown 输出的一部分'。我调用一个名为 `show_widget` 的工具，并将 HTML 作为参数传递。"

所以它是一个**工具调用**。与网络搜索或文件操作相同的机制。HTML 是参数负载，而不是流式文本。以下是 Claude 描述的形状：

```json
{
  "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`：

```json
{
  "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.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](https://github.com/badlogic/pi?ref=hubwiz.com) 是一个基于终端的编码代理（如果你好奇的话，我[比较了每个 CLI 编码代理](https://www.hubwiz.com/blog/cli-coding-agents-compared)）。终端渲染文本和（在现代终端中）内联图像。**没有办法在终端内渲染带有 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](https://github.com/hazat/glimpse?ref=hubwiz.com) \- 一个原生 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 在其第一个小部件之前静默调用此方法，仅将相关指南加载到上下文中。

```typescript
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 窗口，返回用户交互数据。

```typescript
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 转储到终端，我们显示紧凑的摘要：

```typescript
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);
},

```

![带有行星选择的抛射运动模拟器](https://michaellivs.com/images/generative-ui/simulator.gif)

## 3、流式传输挑战

在 claude.ai 上，小部件随着令牌的流入而渐进式渲染。HTML 在视觉上建立 - 你看到样式应用，结构形成，卡片和表格逐个出现，然后当 `script` 在最后执行时图表弹出。

我们想要相同的体验：Glimpse 窗口应该尽早打开并实时显示内容构建。

### 3.1 Pi 如何流式传输工具调用

Pi 的 AI 层（pi-ai）将所有提供商（Anthropic、OpenAI、Google 等）的流式事件规范化为统一格式：

```typescript
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 提供商源代码：

```typescript
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)`。

```typescript
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：

```typescript
// 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 追加

我们尝试跟踪之前的内容长度，只追加新的子节点：

```typescript
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">` 变为：

```html
<div class="cards">
  <div class="c"></div>  <!-- 浏览器自动关闭了这个 -->
</div>

```

在下一次带有更多内容的更新中，树结构根本改变 - 这不是"在末尾追加新节点"，这是一个完全不同的树。追加逻辑无法跟踪什么是真正的新。

### 3.5 尝试 4：morphdom - DOM 差异化（解决方案）

我们引入了 [morphdom](https://github.com/patrick-steele-idem/morphdom?ref=hubwiz.com)，一个快速的 DOM 差异化库（被 Marko 等框架使用）。而不是替换 innerHTML，morphdom 比较新旧 DOM 树并应用**最小补丁** \- 更新更改的节点，添加新的节点，保持不变的节点不变。

```typescript
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`，则调用静默不执行任何操作。我们使用挂起的缓冲区解决了这个问题：

```typescript
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 初始化，事件监听器等）：

```typescript
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 字符串文字注入。这意味着我们需要转义：

```typescript
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` 解释为闭合外部脚本块。

![实时流式传输的架构图](https://michaellivs.com/images/generative-ui/diagram.gif)

## 4、提取设计指南 - 逐字逐句

我打开了浏览器开发工具，检查了网络请求，并在响应正文中发现了完整的工具调用负载 - 包括包含 Anthropic 实际设计指南的完整 `read_me` 工具结果。

响应 JSON 具有以下结构：

```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**](https://github.com/Michaelliv/pi-generative-ui/blob/main/.pi/extensions/generative-ui/claude-guidelines/sections/core%5Fdesign%5Fsystem.md?ref=hubwiz.com) \- 每个小部件必须遵循的基础：

- 流式传输优先架构：`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**](https://github.com/Michaelliv/pi-generative-ui/blob/main/.pi/extensions/generative-ui/claude-guidelines/sections/color%5Fpalette.md?ref=hubwiz.com) \- 九个颜色坡道，每个有 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**](https://github.com/Michaelliv/pi-generative-ui/blob/main/.pi/extensions/generative-ui/claude-guidelines/sections/svg%5Fsetup.md?ref=hubwiz.com) \- SVG 图表工程的大师课程：

- ViewBox 安全检查清单（完成前 5 个验证步骤）
- 带有实际渲染像素测量的字体宽度校准表
- 预构建的 CSS 类（`c-blue`、`c-teal`、`t`、`ts`、`th`、`box`、`node`、`arr`）
- 通过 `context-stroke` 自动继承描边颜色的箭头标记
- 关于连接器路径上的 `fill="none"` 的规则（SVG 默认为 `fill: black`）

[**Diagram types**](https://github.com/Michaelliv/pi-generative-ui/blob/main/.pi/extensions/generative-ui/claude-guidelines/sections/diagram%5Ftypes.md?ref=hubwiz.com) \- 到目前为止最大的部分：

- "导致大多数图表失败"的两个规则（箭头相交检查，从标签长度的框宽度）
- 决策框架：在动词上路由，而不是名词（"LLMs 如何工作" → Illustrative，"transformer 架构" → Structural）
- 流程图、结构和说明性图表子规范
- 复杂性预算：每个副标题 ≤5 个词，每个水平层 ≤4 个框

[**UI components**](https://github.com/Michaelliv/pi-generative-ui/blob/main/.pi/extensions/generative-ui/claude-guidelines/sections/ui%5Fcomponents.md?ref=hubwiz.com) \- 用于构建模型的标记：

- 卡片：白色背景、0.5px 边框、radius-lg、填充 1rem 1.25rem
- 预先样式化的按钮，带有悬停/活动状态
- 指标卡片、表单元素、骨架加载模式
- 编辑视图与卡片视图与比较视图的布局规则

[**Charts**](https://github.com/Michaelliv/pi-generative-ui/blob/main/.pi/extensions/generative-ui/claude-guidelines/sections/charts%5Fchart%5Fjs.md?ref=hubwiz.com) \- Chart.js 特定指南：

- Canvas 包装器大小（`position: relative`，明确高度）
- 始终禁用默认图例，构建自定义 HTML 图例
- 数字格式：`-$5M` 而不是 `$-5M`
- 仪表板布局模式

### 4.3 使用真实指南

我们将手写的指南替换为提取的原始指南。`guidelines.ts` 文件现在是逐字逐句的 Anthropic 内容，组织为懒惰加载的部分：

```typescript
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](https://michaellivs.com/blog/reverse-engineering-claude-generative-ui/?ref=hubwiz.com)

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