OpenUI 逆向工程 ChatGPT Intelligent UI 的实现机制
We reverse engineered ChatGPT Intelligent UI
OpenUI 逆向工程了 ChatGPT 的 Intelligent UI,指出该能力随 GPT-6 引入,模型用 DIL(Markdown 加 JSX 式标签与 JavaScript)写出界面,经服务端编译为 JavaScript 程序和 JSON 数据,再由沙箱 iframe 中的 Web Worker 执行并渲染为 ChatGPT 原生组件。
我们逆向工程了 ChatGPT 中 Intelligent UI 的工作原理。一种推理格式、服务端编译、客户端运行时和渲染,与设计系统和目录协同工作,让生成式 UI 成为现实。
Intelligent UI 是随 GPT-6 引入的能力,它让 ChatGPT 能够以在对话中内联渲染的交互式界面进行响应(OpenAI 公告)。单次响应可以将文字与滑块、表单、表格、图表、地图和产品卡片等组件组合在一起。这些组件无需再次调用模型即可响应输入,并且它们使用 ChatGPT 自己的设计系统绘制,而不是作为网页嵌入。

本文中的观察是在 2026 年 10 月的 ChatGPT 网页版上进行的,使用的是 GPT-6 和 GPT-6 Thinking。
ChatGPT 的实现将工作分配给模型、后端服务器和客户端:
- 推理格式:模型用 DIL 编写界面,DIL 将 Markdown 与类 JSX 标签和 JavaScript 结合在一起。
- 服务端编译:服务器将每个部分响应转换为一个 JavaScript 程序和一个包含文本与数据的 JSON 文档。
- 客户端运行时:一个沙盒化运行时执行该程序并生成 UI 操作。
- 渲染:ChatGPT 将这些操作应用到自己的原生组件上。
- 设计系统和目录:模型可用的组件、属性和设计令牌。
模型
推理格式
## Team plan <slider … /> DIL.useState(8)
DIL:Markdown · JSX · JS
后端服务器
服务端编译
- 修复部分输出
- 验证属性
- 将文本移至常量
JS 程序JSON 数据
用户客户端 ChatGPT 网页版
客户端运行时沙盒化 iframe + Web Workerseats = 8
- 运行程序
- 保持状态,对树进行差异比较
渲染ChatGPT 的原生组件团队方案估算$232/月
设计系统和目录组件、属性和设计令牌模型从中选择编译器据此验证渲染器用它绘制
这就是模型所写的内容。在 ChatGPT 中,这是一种 OpenAI 称为 DIL 的语言:用于文字段落的 Markdown、用于组件的类 JSX 标签,以及用于状态和逻辑的 JavaScript。我们将跟随一个小型响应穿过每一层:
## Team plan estimate
Drag the slider to see the **monthly price** for your team.
{@body const [seats,setSeats] = DIL.useState(8)}
{@body const price = seats*29}
<box border padding={3} gap={2}>
<slider min={1} max={50} value={seats} onChange={setSeats}/>
<title size="xl">${price}/mo</title>
</box>标题和段落是普通的 Markdown。这些标签是来自 ChatGPT 目录的组件。两行 {@body …} 是 JavaScript:第一行声明一个状态,seats,第二行从中派生 price。滑块绑定到 seats,因此移动它会更新价格。完整的词汇表是:
- Markdown
- 组件标签
{@body …}语句{expression}插值{#if}/{:else if}/{:else}条件语句{#each list as item, i}循环- 事件处理器
GenUI操作,例如issueNewTurn和copy
之所以需要一种专用格式,是因为模型是逐 token 编写界面的。
- 它必须易于可靠地编写,因此它由模型已经非常熟悉的记法构建而成。
- 它必须在写到一半时仍然可用。 语句各自独占一行,任何未闭合的元素都可以自动闭合。这样服务器就能在最后一个完整结构处截断部分响应,并且仍然能编译它。
客户端从不按原样执行模型的输出。后端服务器会把它编译成一个 JavaScript 程序和一个 JSON 文档,并与消息一起存储(作为 model_dil_v2)。响应会编译成如下内容(为便于阅读已格式化):
function __dilSafe(evaluate, failureValue) {
try { return evaluate(); } catch { return failureValue; }
}
DIL.render(__dil.jsx(() => {
const __dilConstants = DIL.useConstants();
const __dilModelDataBindings = DIL.useAppData((appData) => appData.opGenui?.modelDataBindings ?? {});
const [seats, setSeats] = DIL.useState(8, { key: "seats" });
const price = __dilSafe(() => seats * 29, undefined);
return __dil.jsx(__dil.Fragment, null,
__dil.jsx("title", { size: "lg" }, __dilConstants["0"]),
__dil.jsx("text", null, __dilConstants["1"], __dil.jsx("bold", null, __dilConstants["2"]), __dilConstants["3"]),
__dil.jsx("box", { border: true, padding: 3, gap: 2 },
__dilSafe(() => __dil.jsx("slider", { min: 1, max: 50, value: seats, onChange: setSeats }), null),
__dil.jsx("title", { size: "xl" }, __dilConstants["4"], __dilSafe(() => price, null), __dilConstants["5"])));
}, { key: "body:2" }));{
"constants": {
"0": "Team plan estimate",
"1": "Drag the slider to see the ",
"2": "monthly price",
"3": " for your team.",
"4": "$",
"5": "/mo"
},
"appData": { "opGenui": { "componentResults": {}, "modelDataBindings": {} } }
}Markdown 被编译进与组件相同的树中。标题变成一个 title,段落变成一个内含 text 的 bold,它们的文字则移入常量表。
编译完成了每个客户端原本都必须重复进行的工作:
- 普通函数调用。 标记变成对
__dil.jsx的调用,因此 JavaScript 运行时无需 DIL 解析器就能求值该程序。 - 错误隔离。 表达式被包裹在
__dilSafe中,因此抛出异常的表达式只会移除一个元素,而不会中止整个渲染。 - 文本放在单独的表中。 静态文本移入常量表,因此当响应流式传输时,不断增长的文本改变的是数据而不是程序。
- 稳定的状态键。 每一份状态都会获得一个键(
{ key: "seats" }),因此它的值在每次重新编译后都能保留。 - 修复与验证。 不完整的语句和标签会被丢弃,未闭合的元素会被闭合,未通过目录验证的属性会被移除并记录为诊断信息。
JSON 文档保存文本常量以及服务器为该响应解析出的任何数据,例如图片搜索结果(见 Data)。
客户端接收编译后的程序和 JSON 文档。它的工作分为两部分:运行时负责执行程序,渲染器负责绘制结果。
该程序是模型编写的代码,因此它不在 ChatGPT 页面中运行。ChatGPT 加载一个隐藏的 iframe(runner.html),以 allow-scripts 和内容安全策略 default-src 'none' 进行沙箱隔离,并启动一个 Web Worker。
- 锁定。 在求值程序之前,worker 会从其全局作用域中移除网络访问、定时器、消息传递和动态代码求值,并冻结剩余的全局对象。
- 求值。 然后它用
new Function对程序求值。运行时对象(DIL、__dil、GenUI)以及目录中的复合组件作为参数传入。 - 看门狗。 未在超时时间内响应的程序会被隔离,并重启 worker。
运行时是一个 React 风格的小型协调器。它渲染组件,并将 hook 状态保存在带键的槽位中。然后它将生成的树与前一棵树进行比较,并将差异编码为操作列表。它不绘制任何内容。
以下示例展示了首次渲染产生的操作,每个节点一行。列出每个元素属性名的条目已省略:
CREATE #1 title SET size = "lg" PLACE under root at 0
CREATE #2 text "Team plan estimate" PLACE under #1 at 0
CREATE #3 text PLACE under root at 1
CREATE #4 text "Drag the slider to see the " PLACE under #3 at 0
CREATE #5 bold PLACE under #3 at 1
CREATE #6 text "monthly price" PLACE under #5 at 0
CREATE #7 text " for your team." PLACE under #3 at 2
CREATE #8 box SET border = true, padding = 3, gap = 2 PLACE under root at 2
CREATE #9 slider SET min = 1, max = 50, value = 8, onChange = fn#1 PLACE under #8 at 0
CREATE #10 title SET size = "xl" PLACE under #8 at 1
CREATE #11 text "$" PLACE under #10 at 0
CREATE #12 text "232" PLACE under #10 at 1
CREATE #13 text "/mo" PLACE under #10 at 2函数从不离开 worker;滑块的处理器仅作为标识符(fn#1)发送。在传输中,操作被编码为整数的二进制序列,字符串则保存在单独的表中。
ChatGPT 页面将这些操作应用到它自己的组件树上。每个 CREATE 都会从 ChatGPT 的设计系统中实例化一个原生组件,页面会在变更到达时为其添加动画。页面只接受已知组件类型的操作,因此模型输出无法引入任意的标记或样式。例外情况是某些属性接受的原始 CSS 值(参见 设计系统与目录)以及 AppBlock 应用,它们在 iframe 中运行(参见 AppBlock 逃生舱)。
交互则朝相反方向运行。当用户将滑块拖到 9 时,页面会把处理函数的标识符和参数发送给 worker。worker 调用 setSeats(9),重新渲染,并返回更新操作。
不涉及模型调用。
ChatGPT 页面原生渲染器
团队方案估算$261/月
沙箱化 worker运行程序
状态setSeats(9)seats 8 → 9price 9 × $29 = $261
然后重新渲染组件将新树与前一棵树进行比较将差异作为更新操作返回
交互 0模型调用 0
操作协议不依赖于平台。worker 的沙箱还允许使用 Hermes(React Native 使用的 JavaScript 引擎)的全局对象。这表明 ChatGPT 的移动应用运行相同的运行时,并使用它们自己的原生渲染器来应用这些操作;我们尚未直接验证这一点。
目录定义了模型可以请求的内容。之所以需要它,是因为模型并不是从原始布局和样式规则来构建界面。它从 ChatGPT 已经知道如何绘制的组件中进行选择,并使用诸如 padding={3} 这样的设计令牌来设置样式。某些属性接受原始 CSS 值,例如像素宽度和十六进制颜色,但更推荐使用设计令牌。因此:
- 生成的界面在每个平台上看起来都与 ChatGPT 的其他部分一致。
- 编译器有一个模式来检查输出。 组件上不存在的属性,或类型错误的字面量,会在编译期间被移除并记录为诊断信息。
在我们捕获的一次响应中,编译器移除了两个属性:一个 icon 上的 fill(一个 unknown_prop 诊断)和一个 box 上的 gap="1"(一个 invalid_literal 诊断)。
目录包含三个部分:
- 原生组件。 ChatGPT 客户端代码的组件注册表中定义了大约 70 个组件;在我们捕获的响应中出现了其中 39 个。
- 设计令牌,用于间距、圆角、颜色和尺寸。
- 复合组件,由 OpenAI 用 DIL 编写并预先构建后发送到沙箱,例如图像和产品组件。在我们的捕获中,模型使用了这些组件,但从未定义自己的组件。
响应中的每一部分都映射到一个目录条目:
| 在响应中 | 目录条目 | 解析后的值 |
|---|---|---|
## Team plan estimate | title | lg 尺寸令牌 |
**monthly price** | text 内的 bold | 行内强调 |
box border padding={3} gap={2} | 布局容器 | 边框、12 px 内边距、8 px 间距(4 px 间距刻度) |
slider min max value onChange | 输入 | ChatGPT 的滑块 |
title size="xl" | 标题文本 | xl 尺寸令牌 |
流式文本很简单:每个新令牌都会追加到屏幕上已有的内容之后。流式界面则更难,原因有三:
- 输出通常还无法运行。 在大多数时刻,它都是一个不完整的程序,仍有标签或表达式处于打开状态,无法按原样执行。
- 界面必须在增长的同时保持可用。用户已经操作过的组件必须保持其状态。
- 有些内容是单独到达的。图像等数据来自服务器,而不是来自文本。
单纯追加 token 的流无法表达这一点。ChatGPT 改为对流式传输结构化消息的补丁,该消息将原始文本、编译后的程序及其数据并排保存。
响应通过服务器发送事件流(POST /backend-api/f/conversation)到达浏览器。每个事件都是对正在构建的消息的一次 JSON-Patch 风格更新。单个事件通常会同时更新原始 DIL 文本及其编译形式。以下是从一次捕获的响应中截取并缩短的一次更新:
{"o": "patch", "v": [
{"p": "/message/content/parts/0", "o": "append", "v": " Sunday lamb roast with friends — generous food, …"},
{"p": "/message/metadata/model_dil_v2/code", "o": "replace", "v": "DIL.render(__dil.jsx(()=>{…"},
{"p": "/message/metadata/model_dil_v2/constants", "o": "append", "v": {"0": "Here's a plan for a proper Sunday lamb roast with friends — …"}},
{"p": "/message/metadata/model_dil_v2/constants", "o": "append", "v": {"1": "Since you're"}},
{"p": "/message/metadata/model_dil_v2/fallbackMarkdown", "o": "append", "v": " Sunday lamb roast with friends — …"}
]}服务器不会增量编译。每隔几百毫秒,很可能是在模型每输出一个新块时,它都会重新编译模型到目前为止写下的所有内容并发送结果。编译从第一个 token 开始,此时任何标签都尚未出现。
编译写了一半的响应
在任何时刻,模型都可能处于某个标签或表达式的中间。编译器会在最后一个完整结构处截断源代码:
- 未完成的
{@body}语句或标签会被丢弃; - 已有内容的开放元素会被自动闭合,而没有内容的则被丢弃;
- 部分文本按原样保留。
编译器会在 recoveryDiagnostics(unterminated_tag、unclosed_block、unterminated_braced_value)中记录每次截断。一旦源代码再次完整,该记录就会消失。在界面中,文本逐词流式显示,而每个组件只有在其标签完成后才会出现。
三种更新
我们捕获了一次 6,341 个字符的响应。其文本在 18.7 秒内流式传输,共 84 次更新。其中 76 次携带了新文本,它们分为三组。其余更新创建了消息(1 次)、传递了图像结果(5 次)并标记完成(2 次)。
| 更新 | 次数 | 发送内容 |
|---|---|---|
| 结构发生变化 | 52 | 追加文本以及整个编译后的程序 |
| 仅文本增长 | 14 | 追加文本以及一个更新后的常量;程序未变 |
| 处于未完成的标签内 | 10 | 仅追加文本;界面不变 |
编译后的程序是一个单一的嵌套表达式,其闭合括号在每次重新编译时都会变化,因此无法追加,只能整体替换。重新发送的代码占该响应约 275 KB 补丁的 83%。更新到达的中位间隔为 230 毫秒,完整重新编译每 290 毫秒一次。在另外两次捕获中,更新大约每 410 毫秒到达一次。
52结构发生变化
14仅文本增长
10处于未完成的标签内
5图像结果
设置推理原始文本编译后的 UI图像完成
1.4 秒1.7 秒1.6 秒1.4 秒1.5 秒
最后一个 token已闭合
完整重新编译:整个程序被重新发送仅常量补丁无 UI 变化图像搜索,标签闭合为结果
在客户端
页面将每个新程序传递给沙箱化的 worker。worker 对其进行求值,用现有状态重新渲染,并将更新操作发送回页面。由于在编译期间添加的键,状态在重新编译之间保持其值。如果新程序求值或渲染失败,worker 会保留上一个可用的程序。
然后页面为每个变化添加动画:
- 文本在 0.7 秒内淡入;
- 新行和网格项在 0.42 秒内滑入;
- 图表在 1.8 秒内绘制;
- 容器高度过渡变化而不是跳跃。
工具结果包含用户可能操作的值:零售商的定价、地点的地址或来自页面的图像。将这些值通过模型传递有复制错误或编造细节的风险。因此 ChatGPT 将其中一些数据与模型输出分开提供:模型编写请求或引用,服务器在编译程序旁边填充这些值,位于 appData 中。我们看到了两种机制,如下所述。
并非所有工具数据都走这条路线。例如,来自网络搜索的天气数据是由模型自己写入响应的。
服务器定义的组件
有些组件由服务器解析。要显示图像,模型描述它而不是链接到它:
<AsyncImage query="slow roasted rosemary garlic lamb shoulder browned golden roast in baking tray" aspectRatio="5:4" maxWidth="152px"/>标签完成后,服务器运行图像搜索,检查生成的 URL,并将结果修补到响应中,通常在几秒钟后:
"a588d0a9-…": {
"componentName": "AsyncImage",
"state": { "is_loading": false, "images": [{ "content_url": "https://images.openai.com/…", "url": "https://anecraefficientia.pt/…" }] }
}在我们捕获的图像搜索响应中,图像 URL 是由服务器提供的,而不是由模型编写的。其他一些组件也以相同方式解析:
AsyncImageGroup,用于图像轮播;Entity,用于产品和地点芯片;Cite,用于源链接。
模型标签完成
<AsyncImage query="slow roasted rosemary garlic lamb shoulder browned golden roast in baking tray" aspectRatio="5:4" maxWidth="152px" />
后端服务器一两秒
- 标签完成
- 图像搜索
- URL 已检查
- 结果已修补
ChatGPT已渲染

菜单慢烤羊肉迷迭香、大蒜和柠檬
4ChatGPT 绘制图像
绑定工具结果
来自模型工具调用的结果,例如网络搜索,被赋予 ID。模型通过 ID 引用其字段,而不是重新输入值:
<title size="lg">{turn448791product0.displayed_price}</title>
<caption>{turn448791product0.merchant}</caption>服务器随响应提供引用的字段:
"turn448791product0": {
"title": "Sony WH-1000XM6 Wireless Noise-Canceling Headphones",
"displayed_price": "₹35,989",
…
"merchant": "Amazon.in",
…
}屏幕上的价格来自搜索结果本身,而不是模型的副本。
大多数交互从不离开响应。拖动滑块或勾选复选框会改变 worker 内部的状态,页面只接收结果操作(参见渲染)。动作是到达响应外部的交互。程序通过宿主提供的一个小型 GenUI 对象访问它们,具有如下函数:
issueNewTurn(text)copy(text)openUrl(url)openEntityDetail(ref)
继续对话
界面与模型通信的唯一方式是 issueNewTurn。它发送新的用户消息,程序根据其状态构建该消息的文本:
<button block onClick={()=>GenUI.issueNewTurn("Develop a detailed dog vest design concept based on my moodboard. Style: "+style+". Main color: "+color+". Features: "+features.join(", ")+". Show visual design inspiration and practical construction ideas.")}>Develop this direction <icon name="arrow-right" inline/></button>用户选择样式和颜色后,按钮生成了这条消息:
Develop a detailed dog vest design concept based on my moodboard. Style: cozy. Main color: sand. Features: Reflective details, Adjustable straps. Show visual design inspiration and practical construction ideas.该消息的存储方式与键入的消息完全相同,没有任何标记表明它来自界面。表单的工作方式相同:<form onSubmit={…}> 与 <button submit> 一起调用 issueNewTurn 并传入表单的值。
有些请求需要原生组件未设计的功能,例如使用 Web Audio 合成声音的鼓机。对于这些,模型可以编写一个 AppBlock:一个自包含的 Web 应用,使用 HTML、CSS 和 JavaScript,嵌入在响应中。这是一个的开头,已缩短:
<AppBlock title="Drum Lab" icon="app-chatgpt" variant="inline" app_block_id="drum-lab-01">
<div id="dl" class="w-full min-w-0 space-y-4 text-base">
<style>
#dl{color:var(--viz-text)}#dl button{touch-action:manipulation}#dl .panel{background:var(--viz-panel);border:1px solid var(--viz-border);border-radius:15px}…
</style>
…
<button id="dl-play" class="btn" style="background:var(--viz-text);color:var(--viz-card);min-width:100px">▶ Play</button>
…
</div>
<script>
(function(){
const root=document.getElementById('dl');if(root.dataset.init)return;root.dataset.init="yes";
…
function audioInit(){if(!audio){const C=window.AudioContext||window.webkitAudioContext; if(!C)return false;audio=new C();…
…
})();
</script>
</AppBlock>AppBlock 的处理方式与响应中的其他部分不同。编译器不会翻译它。编译后的程序只包含一个占位符(__chatgptClientDefinedWidget),客户端从原始响应文本中读取应用的源代码。
ChatGPT 在独立域名的可见 iframe 中渲染应用。它使用与处理应用相同的宿主("Skybridge"),并将模型的 HTML 写入内部框架:
<iframe sandbox="allow-scripts allow-same-origin" referrerpolicy="no-referrer"
src="https://codex-inline-visualization-<hash>.web-sandbox.oaiusercontent.com/?app=skybridge&deviceType=desktop&…">
<iframe id="root" src="about:blank"
sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms">
<!-- the app's HTML and script are written here -->
</iframe>
</iframe>iframe 中的应用位于 ChatGPT 的设计系统之外。若不加处理,它看起来就像对话中间的一个外来网页。宿主通过三种方式防止这种情况:
- 共享基础样式。它注入 Tailwind 的基础样式,因此应用与周围页面从相同的排版和间距默认值开始。
- 共享颜色。它提供主题变量(
--viz-text、--viz-panel、--viz-accent等),供模型的 CSS 引用。应用使用这些变量而非硬编码颜色,因此能跟随 ChatGPT 的浅色和深色主题。 - 尺寸调整。它根据应用内容调整 iframe 尺寸,使应用读起来像是响应的一部分,而不是其中的一个滚动框。
与原生响应不同,应用绘制自己的界面。其状态存在于自己的 JavaScript 变量中,在我们的捕获中,它没有回调 ChatGPT。
响应只存储一次,但可以从无法运行它的客户端打开,最可能是较旧的应用版本。对于这些客户端,服务器会为每个编译程序同时生成一个纯 Markdown 版本 fallbackMarkdown。它保留正文。图像、产品、引用和地图以 ChatGPT 客户端已能渲染的内联标记写入。交互部分被简化为静态文本。
- 重新加载时状态重置。用户更改的值在页面重新加载时丢失,即使客户端将它们发送到视图状态端点。
- 模型看不到界面状态。它只接收随
issueNewTurn发送的消息文本。当被询问时,它无法报告用户更改过的值。 - 后续消息会重新生成界面。每次后续消息都会产生一个带有新程序的新响应;现有界面从不被修改。
- 流的大部分是重发的代码。在一个响应中,重发的程序占 275 KB 补丁的 83%,而文本只有 6.3 KB。
- 交互会重发未更改的属性。在定价组件中单击一次产生了 158 个操作,其中只有 4 个改变了任何内容。
- 逻辑错误是静默的。抛出错误的表达式不渲染任何内容,错误的逻辑也不会被检测到。在一个仪表板中,切换图表的指标将收入数字从 $6,930 改为 $256,410。
- 对无效属性没有明显反馈。编译器移除无效属性并记录诊断信息,但模型在后续响应中重复了相同的错误。这表明这些诊断信息可能没有到达模型。
- 回退版本省略了计算内容。从状态和
{#each}内部内容派生的值不会出现在 Markdown 版本中。 - 图像搜索可能失败。模型编写查询但从未看到结果。一次针对车把变速器特写的查询返回了一张来自电动自行车商品列表的图片。
- 布局是固定的。响应使用固定的列数和像素宽度,并且捕获的响应中没有一个使用了可用的断点钩子。
AppBlock应用是隔离的。它们的状态保存在各自的 JavaScript 变量中,刷新后即丢失,并且它们没有向 ChatGPT 发起任何回调。
所有观察均来自我们自己的 ChatGPT 账户、ChatGPT 网页应用产生的流量,以及 chatgpt.com 公开提供的 JavaScript。这些观察是在 2026 年 10 月使用 GPT-6 和 GPT-6 Thinking 进行的。
- 对话导出。我们将对话导出为网页应用加载对话时所用的 JSON。对于每个带有界面的响应,导出内容包含模型的原始 DIL、编译后的程序、常量和数据、Markdown 回退内容,以及任何编译器诊断信息。
- 流捕获。我们通过在浏览器中包装页面的流读取器,记录了几个响应的服务器发送事件流。随后我们重放这些补丁,以在每次更新后重建消息。
- 浏览器检查。我们使用浏览器的开发者工具检查页面结构,包括用于
AppBlock的 iframe 以及 worker 所执行的脚本。 - 客户端代码。我们阅读了沙箱运行器和 worker(协议版本 14),以及 chatgpt.com 中包含组件注册表、渲染器和 widget 宿主的打包文件。随后我们在本地测试页面中运行了未经修改的运行器,以记录它发出的确切操作。
- 编译器重新实现。我们根据 DIL 源代码和编译输出的配对,重新实现了服务器编译器。其输出与 OpenAI 的逐字节一致,无论是在流式响应的中间版本上还是在完成的响应上。
- 探针。我们编写了旨在测试特定功能的提示词:表单、可编辑列表、图表、地图、计时器,以及会发回消息的按钮。
未对移动端实现进行检查。
来源:Hacker News · openui.com
