前天,Anthropic 在 Claude 中上线了基于生成式 UI 的新交互。这种能力可以让 AI 在聊天信息流中,以可视化的方式介绍概念和信息,其直观程度远超纯文本。
我之前一直在关注类似的方案,Claude 的发布无疑是一剂强心针,让我觉得必须加快开发步伐了。同时,这正好也是一个逆向参考其实现方案的绝佳机会。
经过两天对 Codex 和 Claude 的“疯狂 PUA”,还真让我搞出来了!

这个功能的核心在于,AI 可以直接在聊天中绘制交互式图表,并且支持流式输出——边生成边渲染。回想以前让 AI 写网页,必须等待整个页面代码完全生成后才能看到效果,等待过程漫长而枯燥。
现在完全不同了。你能亲眼目睹图表一笔一划在画布上成型,SVG 节点一个接一个地冒出来。这种生成过程本身极具视觉冲击力,而生成完成后,图表立刻就能进行交互,体验非常流畅。
你可以在我的 Agent 产品 CodePilot 中直接体验:https://github.com/op7418/CodePilot
接下来,我将介绍这个功能的具体玩法、实现原理以及开发过程中遇到的那些“坑”。
有哪些好玩的用法
数据分析:让数字一目了然
例如,让 AI 绘制一张“美国和伊朗冲突每天成本估算”的图表。过去,AI 只会输出一大段文字,其中的数字关系让人眼花缭乱。现在,它可以直接生成图表,每部分的金额占比清晰直观,文字解释与图表混合输出,相辅相成。

小工具:创建可交互的计算器
让它制作一个复利计算器。通过拖拽滑块调整初始金额、投资年限,下方的图表和计算结果会实时变化。这不是一张静态图片,而是一个真正可交互的小部件。同理,贷款计算、单位换算等工具都能轻松实现。

架构图:程序员的最爱
你可以让它绘制某个项目的技术架构图,或是某个实现方案的可视化流程图。例如,我让它画出从 API 请求到 JWT 身份验证的完整流程。特性对比、流程图、层级结构全部图形化呈现,比阅读冗长的文字描述理解起来快得多。

分析线上数据
还有一种玩法是直接丢给它一个 GitHub 仓库链接,它会自动抓取数据并进行可视化分析。比如,我将自己的 CodePilot 项目地址发过去,它便分析了星标数、Fork 数、技术栈、架构设计和核心模块,并将这一切绘制成图表。项目全貌一目了然,效率远超阅读大段文字。

支持交互与深度解释
最强大的一点在于,生成的示意图与模型结合得非常紧密,并非一次性输出就结束。你可以与生成的示意图交互,要求它进行更详细的解释。例如,我让它解释季风和洋流的关系。

如果我们想了解更详细的机制,可以点击图中的“洋流机制”按钮。这会自动向模型发送一条后续指令,让它继续生成关于洋流机制的详细示意图。

当然,我们还可以进行更复杂的交互,比如常见的物理、数学公式可视化。这对学生群体尤其有用,每个参数都可以通过滑块或输入框控制,相应的动画会立刻发生变化。

国产模型支持
在 CodePilot 中实现该功能后,并非只有 Claude 模型可以使用。经过测试,Kimi K2.5、Minimax M2.5、以及 Anthropic 的原生模型都能良好运行。个人认为,K2.5 绘制的图形甚至比 Claude Sonnet 3.5/4.6 还要美观一些,在架构分析上也相当详细。如果想体验这个功能,我推荐首选 K2.5 试试。
好了,关于模型侧的玩法就展示到这里。如果你不关心背后的实现原理,现在就可以去安装 CodePilot 愉快地玩耍了。
如何实现的

Claude 官方方案
Claude.ai 官方使用的是 tool_use 机制。模型调用一个专用的 tool 来输出结构化的 widget 内容,前端则解析 tool 调用的 input 参数进行渲染。
这个方案在 Claude.ai 自身的架构下没有问题,但直接搬到 CodePilot 就行不通,主要原因有三点:
- SDK 限制。CodePilot 使用 Claude Agent SDK 的
preset: 'claude_code' 模式,无法注册自定义 tool。SDK 暴露的是文本增量(text delta)流,在 tool 层面无法扩展。
- 流式体验。
tool_use 的结果需要等待 input_json_delta 拼接完整后才能渲染,不支持 HTML 的增量渲染。而在代码围栏方案下,HTML 随文本流式到达,可以边生成边预览。
- 渲染隔离。Claude.ai 使用 Shadow DOM 进行隔离。我们选择了 sandbox iframe。iframe 的隔离更彻底——它提供完全独立的 JavaScript 执行环境,可以通过 CSP 精确控制资源加载,不存在样式泄漏和脚本逃逸的风险。
CodePilot 的实现方案
触发机制:代码围栏
模型通过输出一段特殊的 Markdown 代码围栏来触发渲染:
show-widget
{"title":"training_flow","widget_code":"<svg width=\"100%\" viewBox=\"0 0 680 400\">..."}
这种格式复用了 CodePilot 已有的代码围栏模式(如 image-gen-request、batch-plan 等),前端的解析器链路天然支持。

渲染机制:Sandbox Iframe
每个 widget 都渲染在一个 sandbox="allow-scripts" 的 iframe 中。iframe 的 srcdoc 是一个精心构建的 receiver 页面。
CSP(内容安全策略)严格限制,只允许来自 4 个特定 CDN 域名的外部脚本,并设置 connect-src 'none' 以禁止所有网络请求。
通过 postMessage 进行通信。流式预览阶段发送 widget:update 消息,此时不执行脚本;最终渲染时发送 widget:finalize 消息,执行所有脚本。
ResizeObserver 监听 iframe 内容的高度变化,并通过 postMessage 报告给父页面进行调整。所有 <a> 标签的点击事件被拦截,并由父页面在新窗口中打开,保证了安全性和一致性。

CSS 变量桥接
这是让 widget 与应用视觉完美融合的关键。CodePilot 使用基于 OKLCH 色彩空间的 CSS 变量。而 Anthropic 的 widget 设计指南使用的是 --color-background-primary 这类标准变量名。
桥接层在 iframe 初始化时,将 CodePilot 的变量值计算并注入到 iframe 的 :root 中。这样,模型按照指南编写的 CSS 就能自动适配当前应用的主题色彩。
当应用在深色/浅色模式间切换时,父页面检测到 html 的 class 变化,会重新计算变量值并推送给 iframe,实现主题的实时同步。

流式渲染流程
这是整个实现中最复杂的部分。模型逐 token 生成代码,这意味着在任意时刻,接收到的 widget 代码都可能是不完整的 JSON、不完整的 HTML 或不完整的 <script> 标签。
处理流程如下:
- 正则匹配
```show-widget,区分“未闭合”和“已闭合”状态。
- 手动提取。定位
"widget_code":" 后面的内容,逐字符进行反转义处理。不能使用 JSON.parse,因为此时 JSON 很可能还不完整。
- Script 检测。检测到未闭合的
<script> 标签时,在 <script 之前进行截断,避免 JavaScript 代码被当作纯文本显示出来。
- 净化 (Sanitize)。对流式内容剥离所有脚本和事件处理器,因为预览阶段不需要交互能力。
- 防抖 (Debounce)。使用 120ms 的防抖间隔,防止 iframe 更新过于频繁影响性能。

体验打磨:那些不该被注意到的细节
从代码或方案角度看或许并不复杂,真正的复杂性在于用户体验的打磨。有太多细节会影响最终感受,目标就是让用户完全注意不到生成过程中的这些“毛刺”。

这就需要在每个环节采用不同的处理方式:
-
文字消失
- 现象:模型先输出介绍文字(如“我来为你可视化解释...”),然后开始输出 widget 围栏。围栏一出现,前面的文字突然消失,等 widget 渲染完才回来。
- 原因:解析函数
parseAllShowWidgets() 对纯文本返回空数组。当围栏刚出现但尚未闭合时,围栏前的文字被传入此函数,结果被丢弃。
- 修复:检测到围栏前的文本不包含任何已完成的 widget 围栏时,直接将其渲染为普通消息,绕过解析函数。
-
高度跳变
- 现象:widget 渲染完成的瞬间,整个聊天区域会抖动一下。
- 原因:iframe 初始高度为 0px。内容第一次报告实际高度(可能是 400px+)时,CSS transition 会让这个变化在 300ms 内完成,形成明显的动画跳变。
- 修复:在首次报告高度时,临时禁用 CSS transition,让高度瞬间到位。后续的高度微调再使用平滑过渡。
-
Finalize 闪烁
- 现象:widget 从流式预览切换到最终渲染时,内容会闪烁一下。
- 原因:receiver iframe 在
finalize 时执行 root.innerHTML = html 整体替换 DOM。即使新旧内容完全一样(例如纯 SVG),浏览器也会触发一帧重绘。
- 修复:
finalize 时,先将新 HTML 解析到临时容器中,分离出 script 元素。比较去掉 script 后的“视觉 HTML”与当前 DOM——如果相同,则跳过 innerHTML 替换,直接追加并执行脚本。实现了纯 SVG widget 的“零重绘” finalize。
-
滚动回跳
- 现象:聊天正在自动滚动到底部时,突然跳回几百像素前的位置,然后再跳回来。
- 原因:流式(streaming)结束时,
StreamingMessage 组件卸载,MessageItem 组件挂载。这是两个不同的 React 组件,其内部的 WidgetRenderer 被销毁并重建。新实例的 iframe 高度从 0 开始,导致内容区高度骤降,自动滚底逻辑检测到高度变化从而触发滚动调整。
- 修复:实现模块级的高度缓存。每当 widget 报告高度时,以
widgetCode 的前200个字符为 key 存入缓存。新的 WidgetRenderer 实例在 useState 初始化时从缓存读取高度,使 iframe 以正确的高度开始渲染,避免了从 0 到实际高度的过渡。
-
Script 代码泄露
- 现象:使用 Chart.js 等库的 widget 加载时,聊天底部会显示一大段 JavaScript 代码。
- 原因:模型输出的
<script> 标签在流式传输中逐字符到达。<script> 开始标签到了但 </script> 结束标签还没到时,流式净化函数剥离了开始标签,导致标签内的 JavaScript 代码变成了裸露的文本节点并被浏览器渲染。
- 修复:在提取部分代码后,检测最后一个
<script 是否有匹配的 </script>。如果没有,就在 <script 位置截断。根据指南,script 应放在最后,截断不影响视觉内容。截断期间展示加载遮罩,并提示“正在为可视化添加交互动画”。
-
iframe Ready 竞态
- 现象:极少数情况下,widget 完全不渲染,一直停留在 0px 高度。
- 原因:
WidgetRenderer 通过 useEffect 注册 message 事件监听。但 iframe 内的 receiver 脚本加载完成后会立刻发送 widget:ready 消息。如果 iframe 加载速度快于 React effect 执行,消息会在监听器注册前发出,导致 iframeReady 状态永远无法变为 true。
- 修复:在 iframe 元素上添加
onLoad 回调作为兜底方案。onLoad 触发时,receiver 脚本必然已执行完毕,这是一个可靠的“就绪”信号。
-
React 组件树稳定性
- 现象:widget 在代码围栏闭合的瞬间会闪烁一次。
- 原因:两个问题叠加:1) 流式阶段的 partial widget 没有稳定的 React key,闭合后获得新 key 导致重新挂载;2) 加载遮罩(shimmer)使用外部
<div> 包裹,改变了组件树结构,也引发了重挂载。
- 修复:为 partial widget 计算一个稳定的 key(基于其在最终分段数组中的预期位置),与闭合后的 key 保持一致。将 shimmer 遮罩移入
WidgetRenderer 内部,通过 prop 控制显示。确保组件树结构(<WidgetRenderer key="w-N">)始终保持不变。
写在最后
整个生成式 UI 系统,难点并不在于“让一段 HTML 在 iframe 里跑起来”——那很简单。真正的复杂性在于:如何让这个 iframe 在流式传输、React 组件生命周期切换、应用主题变化等一系列状态转换中,始终保持视觉上的稳定与流畅。
每一个“闪一下”、“跳一下”、“消失一下”的细微问题,都需要深入理解 React 的协调(Reconciliation)机制、浏览器的渲染管线以及 postMessage 的通信时序,才能找到根源并加以解决。
最终追求的效果是:用户看到模型的回复中,自然地穿插着精美的图表和示意图,仿佛它们本就该在那里一样,整个交互过程浑然天成,让技术服务于无缝的体验。欢迎在 云栈社区 的前沿板块与其他开发者继续探讨这类 React 与前端的深度实现话题。