数据截至 (上游 commit b084ab075ba2)
产物与沙箱预览:从流式文本到 iframe 里能点的页面
30 秒导读: 模型给你的从来不是「一个网页」,而是一串正在往外吐的字符。这一章讲 Open Design 怎么把这串字符切出来、验真、落成磁盘上的
.html、塞进沙箱 iframe 里跑起来,再让你在上面点选、改字、换配色、导出 PDF。
本章只讲「产物这条链」。一次 run 的整体生命周期见 01-run-lifecycle,产物好不好看谁来评审见 06-quality-loop。
1. 这是什么(零基础也能懂)
一句话定义: 一套把「模型输出的文本」变成「用户手里的可交互设 计稿」的工程管线。
它解决什么问题。 你让 AI「做个落地页」,模型能力上没问题——它会写出一整份 HTML。但从这份 HTML 到「用户在应用里看到一个能滚动、能点按钮、能改标题、能导出 PDF 的页面」,中间有一堆没人替你做的脏活:
| 脏活 | 白话 | 不做会怎样 |
|---|---|---|
| 从流里切出产物 | 边收边判断「这段是聊天话,那段是产物正文」 | 原始 <artifact> 标签和半截 HTML 直接漏进聊天气泡 |
| 区分真标签和代码示例 | 模型在讲解里也会写 ```html <artifact …> ``` | 讲解被当成产物吃掉,回复中途断掉 |
| 验真再落盘 | 模型可能吐出「我已更新页面」这种散文当 HTML | 项目里多出一堆幽灵 .html 空标签页 |
| 安全地跑起来 | 生成的页面里有任意 JS | 直接注入宿主页面 = 把应用交给模型 |
| 反向映射回源码 | 用户在预览里改了一行字,要写回哪个文件的哪一处 | 只能「重新生成一遍」,改一个字等于赌一次 |
用起来什么样。 用户视角只有三步:
- 在对话框里说「做一份 6 页的融资 deck」。
- 聊天区出现一个正在长高的代码卡片,右侧同时出现预览页签。
- 预览里翻页、点某个标题直接改字、切换配色、点「导出 PDF」。
一句话直觉: 把这套东西当成浏览器的 view-source 反过来跑——普通浏览器是「文件 → 渲染」,Open Design 是「模型的话 → 文件 → 渲染 → 再改回文件」,而且中间那道「文件」是真的落在你磁盘上的项目目录里。
2. 术语先对齐(这仓库自己定义的四个词)
仓库根目录的 CONTEXT.md 明确规定了领域词汇,代码和文档都按它走。四个词的关系必须先分清,否则后面每一节都会混:
| 术语 | 定义 | 在磁盘上是什么 |
|---|---|---|
| Normal Artifact | 一份项目设计产出,由一个入口文件 + 一份清单构成 | 概念,不是单个文件 |
| Artifact Entry File | 打开/渲染这份产物的主文件 | landing-page.html |
| Artifact Manifest | 认定「这个文件是产物」并记录 kind/renderer/exports/entry 的伴生元数据 | landing-page.html.artifact.json |
| Live Artifact | 可刷新的产出,带数据源和预览状态,独立存储 | .live-artifacts/<id>/ 一整个目录 |
关系约束写死在 CONTEXT.md:85-88:一个 Project 含零到多个 Normal Artifact;一个 Normal Artifact 有且只有一个 Entry File 和有且只有一个 Manifest;Live Artifact 属于 Project,但不是 Normal Artifact。
这条「不是」很重要——第 9 节会看到两者走完全不同的存储和渲染路径。
3. 顶层全景(这条链怎么转)
怎么读这张图:从左往右是数据流向,上半条是 Normal Artifact 的主线,方框里第二行是负责的文件。
模型流式输出
│
▼
┌───────────────┐ 剥掉标签、防止把代码围栏
│ ① 前端增量解析 │ 里的示例当成真产物
│ parser.ts │ markdown-context.ts
└───────┬───────┘
│ 完整产物正文
▼
┌───────────────┐ 白名单校验 kind/renderer/exports
│ ② 落盘契约 │ + 三道闸:运行时修复 / 占位符 / 骨架
│ manifest.ts │ projects.ts::writeProjectFile
└───────┬───────┘
│ 磁盘上的 entry + .artifact.json
▼
┌───────────────┐ 包进 iframe srcdoc,注入
│ ③ 沙箱装配线 │ 6 座 postMessage 桥
│ srcdoc.ts │ edit-mode/bridge.ts
└───────┬───────┘
│
┌────┴─────┬──────────────┐
▼ ▼ ▼
④ 宿主桥 ⑤ 手动编辑 ⑥ 导出
__od__ 改 DOM→写源码 PDF/PPTX/ZIP
packages/ source-patches pdf-export.ts
host .ts deck-export.ts
各段一句话职责:
| 段 | 干什么 | 主文件 |
|---|---|---|
| ① 解析 | 把 <artifact> 从聊天流里切出来,边收边发事件 | apps/web/src/artifacts/parser.ts |
| ② 落盘 | 校验清单、拦截垃圾产物、写入 entry + sidecar | apps/daemon/src/artifacts/manifest.ts |
| ③ 预览 | 拼 srcdoc、注入桥、跑在 sandbox="allow-scripts" 里 | apps/web/src/runtime/srcdoc.ts |
| ④ 宿主 | 浏览器做不到的事(原生弹窗、打印、截屏)委托给 Electron | packages/host/src/index.ts |
| ⑤ 改 | 点中元素 → 映射回源码位置 → 打补丁 | apps/web/src/edit-mode/ |
| ⑥ 导 | 截图拼 PDF/PPTX、内联资源、导出对话记录 | apps/daemon/src/*-export.ts |
还有一条旁路:Live Artifact 有自己的目录、模板引擎和刷新调度,第 9 节单讲。
4. 第一段:流式解析——边收边切
4.1 它要解决的小问题
模型是一个 token 一个 token 吐的。你收到的可能是 :
好的,我来做一版。<artif
下一个 chunk 才补上 act identifier="hero" type="text/html">。解析器必须能在半截标签上停住,既不把 <artif 当正文显示出去,也不能一直卡着不刷新(否则用户看到聊天卡住)。
4.2 原理演示
// 示意,非源码:增量状态机的骨架
let buffer = '';
let inside = false;
function feed(delta) {
buffer += delta;
if (!inside) {
const open = findOpenTag(buffer); // 可能返回 complete / partial / none
if (open.kind === 'none') return flushAll(); // 全是正文,安全吐出
if (open.kind === 'partial') return flushBefore(open.start); // 只吐前半段
inside = true; // 命中完整开标签
}
// 找不到 </artifact> 时,尾部预留 len-1 字节不吐,防止切断闭标签
}
重点看两处:partial 分支的回退保留(hold-back),以及闭标签的尾部预留。
4.3 真实实现
状态机在 apps/web/src/artifacts/parser.ts:161 的 createArtifactParser,四种事件 text / artifact:start / artifact:chunk / artifact:end 定义在 parser.ts:11。
闭标签的尾部预留只有一行,但很关键(parser.ts:211):
const flushUpTo = state.buffer.length - (CLOSE_TAG.length - 1);
</artifact> 长 11 字符,所以永远留住最后 10 个字节不发出去——否则一个 chunk 边界正好切在 </artif / act> 之间,闭标签就永远匹配不上了。
4.4 真正难的地方:别把代码围栏里的示例当真产物
模型经常这样回复:
下面这段就是产物协议的写法:
```html
<artifact identifier="demo" type="text/html">
<!doctype html>…
</artifact>
```
你可以照着改。
上面这段里的 <artifact> 是讲解,不是产物。如果解析器当真,用户看到的回复会在「下面这段就是产物协议的写法:」之后戛然而止。
解决办法是让解析器跟聊天 markdown 渲染器共用一套「哪些区间是代码」的判定。这套判定住在 apps/web/src/artifacts/markdown-context.ts:
FENCE_OPEN_RE(markdown-context.ts:17)与FENCE_CLOSE_RE(:18)刻意不对称——开围栏可以带 info string(```html),闭围栏必须是光秃秃的三反引号,两者都不允许缩进。注释里写明这是在镜像runtime/markdown.tsx的渲染行为。computeSkipRanges(markdown-context.ts:61)返回两样东西:所有「代码区间」ranges,以及一个尚未闭合的围栏起点unclosedFenceStart。isRealArtifactOpenAt(markdown-context.ts:43)额外挡住<artifactual这类前缀共享的词——只有<artifact后面跟空白才算真标签。
findOpenTag(parser.ts:58)在这之上再叠三条流式特有的回退规则,注释就写在函数头上:
未闭合围栏 ──────────────► 从围栏开头起全部 hold 住
尾行像围栏前缀("```ht") ─► 从该行行首起 hold
行内落单的反引号 ────────► 从它起 hold(下一个 chunk 可能配对成 inline code)
四个候选位置取最早的那个作为 hold-back 点(parser.ts:118-121 的 note() 闭包)。这样文本刷新边界永远不会跨过任何「还可能变成标签/围栏/代码跨度」的字符。
4.5 同一套判定的另外三个消费者
markdown-context.ts 被复用在三处,都在 apps/web/src/artifacts/strip.ts:
| 消费者 | 干什么 | 位置 |
|---|---|---|
stripArtifact | 从已完成的回复里删掉产物块,只留聊天话 | strip.ts:60 |
splitStreamingArtifact | 把「还在流的产物」拆出来渲染成实时代码卡片 | strip.ts:338 |
summarizeArtifactsForTranscript | 把已落盘的产物替换成一行摘要再喂给下一轮 | strip.ts:253 |
第三个是省 token 的关键:产物已经在磁盘上了,agent 下一轮应该 grep 文件而不是重读 30K token 的 HTML 副本。但它只在确认落盘成功时才替换——matchPersistedArtifactFile(strip.ts:211)先按 manifest identifier 匹配,匹配不上再按 <slug>(-N).<ext> 文件名匹配;两者都没中就原样保留,因为这时 transcript 里那份可能是产物正文唯一的幸存副本(strip.ts:241-245 注释写明了这个理由)。
4.6 兜底与旁路
模型不总是守协议,这一段还挂了几个补丁:
- 散文当 HTML:
validateHtmlArtifact(validate.ts:57)要求去掉 BOM 后长度 ≥ 64、且首个非空白 token 是<!doctype html>或<html(STARTS_WITH_DOCUMENT_RE,validate.ts:40),并禁止引用.live-artifacts/.od/.tmp这些内部存储路径。注释直言这不是 HTML 校验器,只挡「明显不是文档的东西」。它的调用点在前端持久化之前(apps/web/src/components/ProjectView.tsx:3723),不在 daemon 的写入口上——所以它属于本节这条解析旁路,不算 §5.2 那三道闸之一。 - 正文写在标签外:
recoverHtmlArtifactFromPrecedingDocument(recover.ts:40)处理「模型在<artifact>前面先写 了完整<html>…</html>,标签里只放了一句总结」的情况,把前面那份捞回来。resolvePersistedArtifactHtml(recover.ts:82)的注释特别强调:去重查找和落盘必须用同一份解析结果,否则会重复落盘(#4318)。 - 指针型产物:
resolveHtmlPointerArtifactTarget(pointer.ts:13)识别「见 xxx.html」这种只有一句话的产物,把它解析成指向已有文件的指针而不是新建文件。可见文本超过 100 字节(MAX_POINTER_TEXT_BYTES,pointer.ts:7)就不当指针。
4.7 顺带一提:<question-form>
同目录下的 question-form.ts 走的是完全一样的「内联标签 + JSON 正文」套路,只是产出的不是设计稿而是一张表单。splitOnQuestionForms(question-form.ts:139)把回复切成 prose/form 交替的段,parsePartialQuestionForm(:288)支持半截 JSON 边流边渲染,用户填完后 formatFormAnswers(:614)把答案格式化成下一条 user message。仓库里这是唯一的追问机制——没有 AskUserQuestion 工具,没有 tool_result 回注。
5. 第二段:落盘契约——清单 + 三道闸
5.1 清单为什么必须存在
一个 .html 文件躺在项目里,前端怎么知道它该用「网页渲染器」还是「幻灯片渲染器」?能导出哪些格式?标题是什么?答案是伴生的 xxx.html.artifact.json。
清单的合法值是封闭白名单,不是自由字符串(apps/daemon/src/artifacts/manifest.ts):
| 字段 | 白名单 | 位置 |
|---|---|---|
kind | html / deck / react-component / markdown-document / svg / diagram / code-snippet / mini-app / design-system | manifest.ts:22 |
renderer | html / deck-html / react-component / markdown / svg / diagram / code / mini-app / design-system | manifest.ts:34 |
exports | html / pdf / zip / jsx / md / svg / txt,且非空数组 | manifest.ts:46 |
status | streaming / complete / error | manifest.ts:47 |
尺寸上限也是硬编码常量(manifest.ts:3-10):
| 常量 | 值 | 管什么 |
|---|---|---|
MANIFEST_VERSION | 1 | 版本不等于 1 的持久化清单直接判废(manifest.ts:254) |
MAX_ENTRY_LENGTH / MAX_SUPPORTING_FILE_LENGTH | 260 | 路径长度 |
MAX_SUPPORTING_FILES | 128 | 支撑文件 条数 |
MAX_TITLE_LENGTH | 200 | 标题 |
MAX_METADATA_BYTES | 16 KiB | metadata 序列化后的字节数 |
entry 和 supportingFiles 里的每条路径都过 validateSupportingPath(manifest.ts:67):拒绝绝对路径、盘符前缀、\0、以及任何 .. 段。注意它是先把 \ 归一成 / 再检查,所以 Windows 风格的穿越也挡得住。
校验通过后 sanitizeManifest(manifest.ts:217)重建对象而不是原样透传——只有白名单里的字段能活下来,version 强制盖成 1,status 缺省补 complete。
遗留文件怎么办? inferLegacyManifest(manifest.ts:263)按扩展名猜:.html 且文件名里含 deck/slides/pitch 就推成 deck,否则 html;.md → markdown-document;.svg → svg;其余返回 null。返回 null 时 resolveCreateArtifactManifest(create.ts:55)抛 ArtifactManifestRequiredError——宁可拒绝,也不给一个瞎猜的默认清单。
这段推断逻辑在前后端各有一份(
apps/web/src/artifacts/manifest.ts:151也有inferLegacyManifest),源码注释在manifest.ts:266-268明确标注了「必须手工保持同步,直到抽成共享模块」——是已知债,不是设计。
5.2 三道闸都装在同一个写入口
关键的收敛点是 apps/daemon/src/projects.ts:835 的 writeProjectFile。所有产物写盘都过它,所以闸门装在这里就覆盖了全部 agent、全部路径:
writeProjectFile(name, body, { artifactManifest })
│
├─ 闸0 normalizeArtifactRuntimeImports projects.ts:769
│ 修 Motion CDN 引错 bundle 的老问题
│
├─ validateArtifactManifestInput 白名单校验
│
├─ 闸1 assertArtifactPublicationAllowed projects.ts:794
│ 仅 html/deck:正文还留着模板占位符 → 抛 422
│
├─ 闸2 evaluateArtifactStubGuard projects.ts:806
│ 仅 html/deck:新正文比历史同名产物小太多 → warn/reject
│
├─ writeFile(entry)
└─ writeFile(entry + '.artifact.json')
闸 1(publication-guard.ts) 挡的是「模板占位符没填就发布」。它刻意只认五个字符串(publication-guard.ts:37):Name to confirm、$X.XM、Replace this panel with、Replace role placeholders、Your form answer only said。文件头注释解释了为什么不做泛化:这五个是官方 pitch-deck 模板真实吐出的标记,扩到更通用的词会误伤真实文案。而且它只作用于 html 和 deck(PUBLICATION_GUARDED_ARTIFACT_KINDS,publication-guard.ts:32)——markdown 草稿里出现这些词是合法的。
闸 2(stub-guard.ts) 挡的是「模型第二次生成时偷懒,写了个『详见 xxx.html』的空壳覆盖掉原来 40KB 的页面」。判据是结构性的,不是文本匹配:拿新正文的字节数,跟磁盘上同 identifier 的最大历史兄弟比。
新产物 identifier="landing"
│
├─ findPriorArtifactSiblings 扫目录 stub-guard.ts:172
│ 正则 ^(landing|slug|artifact)(-\d+)?\.html?$
│ 每个候选再读 xxx.html.artifact.json 确认真实 identifier
│
└─ classifyArtifactStubGuard stub-guard.ts:273
最大兄弟 < 4096 字节 ──► pass(基准本身太小,不比)
新尺寸 ≥ 最大兄弟×0.2 ──► pass
否则 ──► mode=reject 抛错 / mode=warn 记录
默认 mode: 'warn'、minRetainedRatio: 0.2、minPriorBytes: 4096(stub-guard.ts:67),三者都能用 OD_ARTIFACT_STUB_GUARD* 环境变量覆盖(stub-guard.ts:227)。
这里有一处很见功力的细节。artifactIdentifiersMatch(stub-guard.ts:112)判断两个 identifier 是否同源:
if (a === b) return true;
const slugA = slugifyArtifactIdentifier(a);
if (slugA.length === 0) return false;
const slugB = slugifyArtifactIdentifier(b);
if (slugA !== slugB) return false;
return a === slugA || b === slugB; // ← 必须有一边就是 slug 本身
最后那行是防误判的:slug 化会在 60 字符处截断,两个只在第 61 个字符之后才分岔的长 identifier 会 slug 成同一个串。要求「其中一边就是 slug 形式」才算匹配,既保住了 "Landing Page" ↔ "landing-page" 这条真桥,又挡掉了截断碰撞。同理,全非 ASCII 的 identifier(测试 / 首页)都 slug 成空串,被 slugA.length === 0 直接判不匹配。
闸 0(runtime-compat.ts) 是个纯修复:模型经常给 React 动画页面引 motion@x/dist/motion.js(vanilla DOM bundle,没有 useScroll 这些 hook),页面必崩。normalizeArtifactRuntimeImports(runtime-compat.ts:30)在检测到确实用了 React hook 的前提下(MOTION_REACT_HOOK_USAGE 门槛,runtime-compat.ts:4),把 script src 改写成 framer-motion.js 并删掉失配的 integrity 属性。边界写得很清楚(runtime-compat.ts:6-10):只修 unpkg/jsdelivr 的 UMD <script src> 形态,不碰 ESM import、importmap、esm.sh。
5.3 还有一道闸在更上游:daemon 的文本抑制
apps/daemon/src/artifacts/text-suppression.ts 干的是另一件事:某些 CLI(走 ACP 协议那批)会把 <DSML artifact> / <tool_call> 这类内部标签直接混进 assistant 文本流。createDsmlArtifactTextSuppressor(text-suppression.ts:31)在 daemon 侧就把它们吃掉,不让它们到达前端。
实现和前端解析器是同一个思路的镜像:createTaggedTextSuppressor(:49)维持一个 candidate 尾巴缓冲,possibleTagStart(:151)从后往前找 <、最多回溯 MAX_CANDIDATE_LENGTH = 512 字节。判定函数(如 isPossibleDsmlArtifactOpen,:163)把候选串抹掉 < | , 空白 后跟规范名做双向前缀匹配——所以 <|DSML, artifact> 和 < artifact > 都能认出来。
6. 第三段:沙箱预览——srcdoc 装配线
6.1 为什么是 srcdoc 而不是直接渲染
产物里的 JS 是模型写的,等同于不可信代码。所以它跑在 <iframe sandbox="allow-scripts allow-downloads"> 里(挂载点见 apps/web/src/components/DesignFilesPanel.tsx:2098)——没有 allow-same-origin