跳到主要内容

数据截至 (上游 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直接注入宿主页面 = 把应用交给模型
反向映射回源码用户在预览里改了一行字,要写回哪个文件的哪一处只能「重新生成一遍」,改一个字等于赌一次

用起来什么样。 用户视角只有三步:

  1. 在对话框里说「做一份 6 页的融资 deck」。
  2. 聊天区出现一个正在长高的代码卡片,右侧同时出现预览页签。
  3. 预览里翻页、点某个标题直接改字、切换配色、点「导出 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 + sidecarapps/daemon/src/artifacts/manifest.ts
③ 预览拼 srcdoc、注入桥、跑在 sandbox="allow-scripts"apps/web/src/runtime/srcdoc.ts
④ 宿主浏览器做不到的事(原生弹窗、打印、截屏)委托给 Electronpackages/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:161createArtifactParser,四种事件 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_REmarkdown-context.ts:17)与 FENCE_CLOSE_RE:18)刻意不对称——开围栏可以带 info string(```html),闭围栏必须是光秃秃的三反引号,两者都不允许缩进。注释里写明这是在镜像 runtime/markdown.tsx 的渲染行为。
  • computeSkipRangesmarkdown-context.ts:61)返回两样东西:所有「代码区间」ranges,以及一个尚未闭合的围栏起点 unclosedFenceStart
  • isRealArtifactOpenAtmarkdown-context.ts:43)额外挡住 <artifactual 这类前缀共享的词——只有 <artifact 后面跟空白才算真标签。

findOpenTagparser.ts:58)在这之上再叠三条流式特有的回退规则,注释就写在函数头上:

未闭合围栏 ──────────────► 从围栏开头起全部 hold 住
尾行像围栏前缀("```ht") ─► 从该行行首起 hold
行内落单的反引号 ────────► 从它起 hold(下一个 chunk 可能配对成 inline code)

四个候选位置取最早的那个作为 hold-back 点(parser.ts:118-121note() 闭包)。这样文本刷新边界永远不会跨过任何「还可能变成标签/围栏/代码跨度」的字符。

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 副本。但它只在确认落盘成功时才替换——matchPersistedArtifactFilestrip.ts:211)先按 manifest identifier 匹配,匹配不上再按 <slug>(-N).<ext> 文件名匹配;两者都没中就原样保留,因为这时 transcript 里那份可能是产物正文唯一的幸存副本(strip.ts:241-245 注释写明了这个理由)。

4.6 兜底与旁路

模型不总是守协议,这一段还挂了几个补丁:

  • 散文当 HTMLvalidateHtmlArtifactvalidate.ts:57)要求去掉 BOM 后长度 ≥ 64、且首个非空白 token<!doctype html><htmlSTARTS_WITH_DOCUMENT_REvalidate.ts:40),并禁止引用 .live-artifacts / .od / .tmp 这些内部存储路径。注释直言这不是 HTML 校验器,只挡「明显不是文档的东西」。它的调用点在前端持久化之前(apps/web/src/components/ProjectView.tsx:3723),不在 daemon 的写入口上——所以它属于本节这条解析旁路,不算 §5.2 那三道闸之一。
  • 正文写在标签外recoverHtmlArtifactFromPrecedingDocumentrecover.ts:40)处理「模型在 <artifact> 前面先写了完整 <html>…</html>,标签里只放了一句总结」的情况,把前面那份捞回来。resolvePersistedArtifactHtmlrecover.ts:82)的注释特别强调:去重查找和落盘必须用同一份解析结果,否则会重复落盘(#4318)。
  • 指针型产物resolveHtmlPointerArtifactTargetpointer.ts:13)识别「见 xxx.html」这种只有一句话的产物,把它解析成指向已有文件的指针而不是新建文件。可见文本超过 100 字节(MAX_POINTER_TEXT_BYTESpointer.ts:7)就不当指针。

4.7 顺带一提:<question-form>

同目录下的 question-form.ts 走的是完全一样的「内联标签 + JSON 正文」套路,只是产出的不是设计稿而是一张表单。splitOnQuestionFormsquestion-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):

字段白名单位置
kindhtml / deck / react-component / markdown-document / svg / diagram / code-snippet / mini-app / design-systemmanifest.ts:22
rendererhtml / deck-html / react-component / markdown / svg / diagram / code / mini-app / design-systemmanifest.ts:34
exportshtml / pdf / zip / jsx / md / svg / txt,且非空数组manifest.ts:46
statusstreaming / complete / errormanifest.ts:47

尺寸上限也是硬编码常量(manifest.ts:3-10):

常量管什么
MANIFEST_VERSION1版本不等于 1 的持久化清单直接判废(manifest.ts:254
MAX_ENTRY_LENGTH / MAX_SUPPORTING_FILE_LENGTH260路径长度
MAX_SUPPORTING_FILES128支撑文件条数
MAX_TITLE_LENGTH200标题
MAX_METADATA_BYTES16 KiBmetadata 序列化后的字节数

entrysupportingFiles 里的每条路径都过 validateSupportingPathmanifest.ts:67):拒绝绝对路径、盘符前缀、\0、以及任何 .. 段。注意它是先把 \ 归一成 / 再检查,所以 Windows 风格的穿越也挡得住。

校验通过后 sanitizeManifestmanifest.ts:217重建对象而不是原样透传——只有白名单里的字段能活下来,version 强制盖成 1,status 缺省补 complete

遗留文件怎么办? inferLegacyManifestmanifest.ts:263)按扩展名猜:.html 且文件名里含 deck/slides/pitch 就推成 deck,否则 html.mdmarkdown-document.svgsvg;其余返回 null。返回 null 时 resolveCreateArtifactManifestcreate.ts:55)抛 ArtifactManifestRequiredError——宁可拒绝,也不给一个瞎猜的默认清单

这段推断逻辑在前后端各有一份(apps/web/src/artifacts/manifest.ts:151 也有 inferLegacyManifest),源码注释在 manifest.ts:266-268 明确标注了「必须手工保持同步,直到抽成共享模块」——是已知债,不是设计。

5.2 三道闸都装在同一个写入口

关键的收敛点是 apps/daemon/src/projects.ts:835writeProjectFile所有产物写盘都过它,所以闸门装在这里就覆盖了全部 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.XMReplace this panel withReplace role placeholdersYour form answer only said。文件头注释解释了为什么不做泛化:这五个是官方 pitch-deck 模板真实吐出的标记,扩到更通用的词会误伤真实文案。而且它只作用于 htmldeckPUBLICATION_GUARDED_ARTIFACT_KINDSpublication-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.2minPriorBytes: 4096stub-guard.ts:67),三者都能用 OD_ARTIFACT_STUB_GUARD* 环境变量覆盖(stub-guard.ts:227)。

这里有一处很见功力的细节。artifactIdentifiersMatchstub-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),页面必崩。normalizeArtifactRuntimeImportsruntime-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 文本流。createDsmlArtifactTextSuppressortext-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,拿不到宿主的 cookie、localStorage、DOM。宿主和 iframe 之间只能靠 postMessage 说话。

6.2 装配顺序就是 buildSrcdoc 的函数体

apps/web/src/runtime/srcdoc.ts:381buildSrcdoc 是一条顺序敏感的流水线,读它就等于读全部能力清单:

原始 html
│ 不是完整文档?包一层 doctype 壳
├─ sanitizeTitleInDoc srcdoc.ts:197 ← 必须最先做
├─ annotateMissingOdIds srcdoc.ts:925
├─ annotateManualEditSourcePaths (editBridge) srcdoc.ts:886
├─ injectBaseHref (baseHref)
├─ injectSandboxShim srcdoc.ts:1047
├─ injectPreviewFocusGuard (previewFocusGuard)
├─ injectDeckBridge (deck) srcdoc.ts:2025
├─ injectSelectionBridge (comment/inspect/selection) srcdoc.ts:1194
├─ injectPaletteBridge (palette) srcdoc.ts:691
├─ injectManualEditBridge (edit) srcdoc.ts:961
├─ injectTweaksBridge ← 永远注入 srcdoc.ts:2488
├─ injectSnapshotBridge srcdoc.ts:377
├─ injectExportCaptureBridge srcdoc.ts:603
└─ injectSrcdocTransportActivationBridge srcdoc.ts:364

两处顺序不是随便排的:

  • title 净化必须最先。因为用户按 Cmd+P 打印预览时,Chromium 拿 <title> 当默认文件名,任何后续注入都不该影响它(srcdoc.ts:397-400)。
  • tweaks 桥永远注入,注释解释了原因(srcdoc.ts:460-462):它只是个被动监听器,如果按开关决定注不注入,每次切换开关都要重建 srcdoc,用户会看到一次白闪。

6.3 六座桥都说什么话

宿主 → iframeiframe → 宿主用途
deckod:slideod:slide-state翻页、页码同步
commentod:comment-modeod:comment-active-targetod:comment-targetod:comment-targetsod:pod-stroke在元素上贴批注
inspectod:inspect-set/reset/extract/replayod:inspect-overrides实时改样式
selection(与 comment/inspect 共用一个注入)od:comment-hoverod:preview-scroll元素拾取的公共底座
editedit-mode/bridge.ts文本/样式提交消息手动编辑覆盖层
paletteod:palette整页换配色

另有三个不算「模式」的辅助通道:od:snapshot(元素截图)、od:export-capture(逐页导出)、od:srcdoc-transport-activate / od:srcdoc-transport-ready(懒加载壳)。

6.4 三个值得单独拿出来讲的设计

(a)沙箱垫片(injectSandboxShimsrcdoc.ts:1462)。 没有 allow-same-origin 的 iframe 里,第一次访问 localStorage 会抛 SecurityError。模型生成的 deck 常在 IIFE 顶部裸调 localStorage.getItem(...) 且不 try/catch——一抛整个脚本就死,deck 变成一张不能翻页的静态图。垫片在任何用户脚本之前装一个同源的内存版 store,让这类页面优雅降级(顶多是位置不跨刷新保存)。同一个 shim 还顺手接管了 <a href> 点击:空 href 和纯 hash 拦掉,页内 id 滚动到视图。

(b)deck 翻页的四级降级(gotoIndexsrcdoc.ts:3792)。 模型写的 deck 没有统一实现,可能是横向滚动、可能靠 .is-active 类、可能靠 transform 位移、也可能只监听键盘。所以翻页是逐级尝试、命中即停

gotoIndex(target)

├─① 是滚动式 deck? ──► scrollGo(target) srcdoc.ts:2308
├─② 能设 active 类? ─► setActive(target) srcdoc.ts:2265
├─③ 有 transform? ──► transformGo(target) srcdoc.ts:2244
└─④ 都不行 ─────────► 循环派发 Arrow 键事件 srcdoc.ts:2153
+ 320ms 后再 report()

连「哪些元素算 slide」都做了降级(slides()srcdoc.ts:3052):先用结构化选择器 .deck > .slide, .deck-stage > .slide, .deck-shell > .slide, body > .slide,一个都没匹配到才退回全量 .slide。这样非 deck 页面里当装饰用的 .slide 类名不会被误算成幻灯片。

(c)selection 桥的安全立场(srcdoc.ts:1793-1800)。 注释坦白得罕见:这个桥的 message 监听器不校验 ev.origin,理由是 web 应用跑在可配置端口和预览域名上,宿主 origin 不稳定。它转而依赖两件事——iframe sandbox 本身,以及属性白名单 + 取值净化。并且明确点出:od:inspect-overrides 回传的是结构化 override map,不含桥自己生成的 CSS 文本,因为产物 JS 可以伪造一条同源消息塞进恶意 css(比如 </style><script>…)。

6.5 顺便:为什么会有「懒加载壳」

buildLazySrcdocTransportsrcdoc.ts:492)生成一个几乎空的 shell 文档,宿主随后 postMessage 把真正的 HTML 灌进去(document.open/write/close)。壳一装好监听器就发 od:srcdoc-transport-ready

canActivateSrcDocTransportsrcdoc.ts:541)是抽出来的纯决策函数,五个条件全过才允许灌注。其中 shellReady 是 #2253 的修复:不 gate 的话,宿主可能在壳的脚本还没执行时就把 activate 发出去,消息被丢弃,壳永远停在 536 字节的空 body,而去重检查还会把后续 onLoad 触发的重试也压掉。

6.6 文件名净化:一个「跨软件兼容」的冷知识

sanitizePreviewTitlesrcdoc.ts:195)看着琐碎,但解释了一件真实痛点:Microsoft Teams 拒收含 : # % & * { } \ < > ? / + | " 的文件名,也拒收首尾空格和 ~$ 前缀。函数的实现细节有两处不显然:

  • 先 trim 再去 ~$,否则 " ~$Invoice" 会绕过锚定检查(srcdoc.ts:196-197)。
  • 循环去前缀直到稳定,因为 "~$~$Doc" 一次 replace 去不干净(srcdoc.ts:202-206)。

sanitizeTitleInDocsrcdoc.ts:340)负责把它套回文档里,且只改真正的 <title>——findRealTitleOffsetsrcdoc.ts:289)会跳过 HTML 注释、<script><style> 里的假 title。全程纯字符串操作、不用 DOMParser,所以 Node 测试环境和浏览器行为完全一致。


7. 第四段:手动编辑——把 DOM 元素映射回源码

7.1 问题

用户在预览里双击一个标题改成「新标题」。这个改动要落到磁盘上那份 HTML 的正确位置。iframe 里只有 DOM,没有源码行号——中间需要一个稳定的坐标系。

7.2 坐标系就是 data-od-source-path

MANUAL_EDIT_SOURCE_PATH_ATTR = 'data-od-source-path'edit-mode/bridge.ts:3)。值是元素在 DOM 树里的兄弟序号路径,形如 path-0-3-1

生成逻辑有两份,分别用在不同时机:

函数位置特点
sourcePathForElementsrcdoc.ts:1251构建 srcdoc 时批量打标,用 parent.children 原始序号
manualEditDomPathForElementedit-mode/bridge.ts:16运行期计算,先过滤掉宿主注入的节点

第二个的过滤是必需的——桥自己往页面里塞了 <script data-od-deck-bridge> 之类的节点(清单见 MANUAL_EDIT_HOST_NODE_SELECTORbridge.ts:4),如果把它们算进兄弟序号,同一个元素在「注入前」和「注入后」会得到两个不同的路径。

manualEditStableIdForElementbridge.ts:33)定了取 id 的优先级:显式 data-od-id > data-od-source-path > data-od-runtime-id > 现算 DOM path。

7.3 哪些元素能改、怎么改

manualEditKindForElementbridge.ts:78)把点击分成四类:text / link 落文字光标,image / container 只能调样式。判据不是标签名,而是 manualEditElementIsTextLeafbridge.ts:66)——有可见文本且没有元素子节点

所以裸 <div>标题</div><li><td><h4> 都能直接改字,和 <p> 待遇一样。反过来,含 <strong>/<a> 子元素的节点故意不算 text leaf,注释解释得很直白(bridge.ts:51-61):源码打补丁器 applyManualEditPatch 在目标有元素子节点时会拒绝 set-text 补丁,那么在这里给用户一个光标、让他打完字再保存失败,是更糟的体验。

7.4 导入的设计稿怎么办

外部导入的 HTML 没有任何 OD 标注,选不中任何元素。annotateMissingOdIdssrcdoc.ts:1275)在构建时补 data-od-id,选择器有讲究(srcdoc.ts:1282-1295):语义容器(section article header …)、标题、按钮、链接、任何带 [id] 的元素全都要,但 div 只取「语义容器/body 的直接子级且带 class 或 id」的那一层。深层嵌套的 flex/grid 包装 div 一律不标——注释说明理由是「给选择桥制造噪音,却不提供有意义的可选目标」。


8. 第五段:宿主桥 __od__——浏览器做不到的那些事

8.1 契约本身

packages/host/src/index.ts 是一个纯类型 + 纯校验的包,不含任何 Electron 代码。三个核心常量:

常量位置
OPEN_DESIGN_HOST_GLOBAL"__od__"index.ts:3
OPEN_DESIGN_HOST_VERSION2index.ts:4
OPEN_DESIGN_HOST_CLIENT_TYPES{ DESKTOP: "desktop" }index.ts:6

能力面(OpenDesignHostBridgeindex.ts:269)分成七组:shell(打开外链/项目目录)、browser(清数据)、capture(截屏)、project(选目录/导入)、pdf(打印)、petupdater

8.2 能力协商靠一个类型守卫

isOpenDesignHostBridgeindex.ts:325)是唯一的准入判定,且第一件事就是版本严格相等:

if (value.version !== OPEN_DESIGN_HOST_VERSION) return false;

版本不匹配整个桥直接判无效,退回纯 web 行为。之后逐组检查每个方法是不是函数。

注意 pickWorkingDir 是例外——它在类型上标为可选(index.ts:286-288 注释:「让旧 host 构建仍满足桥形状,调用方必须自己 feature-detect」),守卫里也不检查它。对应的调用包装 pickHostWorkingDirindex.ts:564)自己做了运行时探测:

if (typeof host.project.pickWorkingDir !== "function") {
return unavailable("host build does not support pickWorkingDir");
}

这是版本号做粗粒度 gate + 可选方法做细粒度 gate 的双层协商。

8.3 单次 HMAC token:一个绕开鉴权难题的巧招

问题:Home 页面想让用户在项目还不存在时就先选好工作目录。但 POST /api/projects/:id/working-dir 有 desktop-auth 门禁,渲染进程没有凭证。

OpenDesignHostPickWorkingDirSuccessindex.ts:68)的解法:主进程弹原生目录选择框,返回 { baseDir, token },其中 token 是主进程用 HMAC 对这个 baseDir 签的单次令牌。渲染进程把 token 一路带到项目创建之后,再花掉它。注释原话点明了目的(index.ts:71-75):让 Home 流程在项目存在前就能选目录,同时不把 daemon 的鉴权门禁暴露给渲染进程

8.4 三个结果归一化函数

normalizeOpenDesignHostProjectImportResultindex.ts:378)、…ReplaceWorkingDirResult:414)、…PickWorkingDirResult:441)负责把特权适配器的原始返回值收敛成渲染进程契约。共同模式:先分 canceled / failure / ok 三态,再逐字段验类型,缺一个就整体判失败。第一个函数的注释一句话说清了边界:适配器内部可以调 daemon API,但只有项目标识符能跨过宿主桥

8.5 落地在 Electron 里

apps/desktop/src/main/preload.cts:350-384 组装出 hostBridge 对象并 contextBridge.exposeInMainWorld(OPEN_DESIGN_HOST_GLOBAL, hostBridge),每个方法都是一层 ipcRenderer.invoke + try/catch 转 { ok: false, reason }satisfies OpenDesignHostBridge 让类型层直接卡住形状漂移。

真正干活的在主进程:apps/desktop/src/main/pdf-export.ts 里有 DECK_PAGE_SIZE(13.333×7.5 英寸,即 16:9)和一大段 DECK_PRINT_CSS——后者用 !important 强制每张 slide 变成 1920×1080、page-break-after: alwaysopacity: 1。最后那条注释说明了为什么(pdf-export.ts:57-58):deck 常用 opacity 切换显示,不强制的话非当前页会打印成空白页。


9. 另一条产物线:Live Artifact

9.1 和 Normal Artifact 的根本区别

维度Normal ArtifactLive Artifact
存储项目目录里的 entry 文件 + .artifact.json.live-artifacts/<id>/ 一整个目录
正文任意 HTML/JSX/MD,模型自由发挥template.html + data.json → 渲染出 index.html
脚本允许(跑在沙箱 iframe 里)完全禁止
更新让 agent 重新生成点「刷新」,重跑数据源
谁定的模型模型定模板,数据源定内容

9.2 三件套格式 html_template_v1

LiveArtifactDocumentlive-artifacts/schema.ts:27)把三个路径写死成字面量类型

format: 'html_template_v1';
templatePath: 'template.html';
generatedPreviewPath: 'index.html';
dataPath: 'data.json';

validateDocumentschema.ts:742)逐个比对,任何一个不等就报错。目录里实际还有五个文件,常量定义在 store.ts:16-25artifact.json(元数据)、provenance.json(来源)、refreshes.jsonl(刷新日志)、refresh.lock.json(锁)、refresh-state.json(状态)、以及 snapshots/ 目录。

9.3 渲染引擎:一个刻意残废的模板语言

renderHtmlTemplateV1live-artifacts/render.ts:265)只支持 {{ data.x.y }} 一种语法,且限制层层加码:

  1. validateHtmlTemplateV1Securityrender.ts:32)先扫六条禁令——<script><iframe>srcdoc=、任意 on*= 事件属性、javascript: URL、data-od-html 这类原始 HTML 注入指令(EXECUTABLE_TEMPLATE_PATTERNSrender.ts:23)。
  2. 拒绝任何 raw 插值({{{x}}} / {{&x}}RAW_TEMPLATE_INTERPOLATIONrender.ts:18)。
  3. 绑定路径必须以 data 开头且匹配 TEMPLATE_PATHrender.ts:22)。
  4. 解析结果必须是标量——数组或对象直接抛错(render.ts:77-80 scalarOrThrow)。
  5. 最后一律 escapeHtmlTemplateValuerender.ts:38)转义。

一句话:Live Artifact 的预览是纯静态、无脚本、只能填标量的。所以它不需要沙箱 iframe 那套桥,也不可能被数据源里的内容 XSS。

9.4 数据源与刷新权限

LiveArtifactSourceType 三选一(schema.ts:13):local_file / daemon_tool / connector_tool。刷新权限只有两档(LiveArtifactRefreshPermissionschema.ts:15):

  • none——不可刷新。
  • manual_refresh_granted_for_read_only——用户手动触发、且只读。

没有自动刷新档位。连 connector 的审批策略也只有 read_only_automanual_refresh_granted_for_read_only 两种(schema.ts:14)。

9.5 「宁可注册时拒绝,也不留个永远刷不动的产物」

这是这一块最值得学的一条设计原则。validateDaemonRefreshRequiredInputschema.ts:493)在创建时就模拟运行期的 selectJsonPath 逻辑:

// 按 path → file → name 的顺序取第一个「存在」的键(不是第一个非空的)
const selectorKey = (['path', 'file', 'name'] as const)
.find((key) => input[key] !== undefined);

注释解释了为什么必须是「第一个存在」而不是「第一个非空字符串」(schema.ts:501-506):运行期的 selectJsonPathrefresh.ts 里)用 optionalString(...) ?? ... 串联,遇到非字符串会直接抛错而不是往下 fallback。如果这里用「第一个非空」的宽松规则,{ path: 123, file: 'ok.json' } 就能注册成功、却每次刷新都失败——正好制造出这套校验想消灭的那类「已持久化但永远刷不动」的产物。

配套的 validateReadJsonSelectorschema.ts:343)也镜像了运行期的全部静态规则:禁绝对路径、禁 ..、禁 . 单点段、禁 .live-artifacts 保留段、且必须大小写敏感地以 .json 结尾(因为运行期用的是 endsWith('.json'),这里小写化会放进运行期会拒的 DATA.JSON)。

9.6 刷新的执行框架

refreshLiveArtifactrefresh-service.ts:108)是整个刷新流程的编排者,骨架是:

withLiveArtifactRefreshLock 拿目录级文件锁

├─ appendLog('refresh:start') 写 refreshes.jsonl
├─ markLiveArtifactRefreshRunning 状态置 running

├─ withLiveArtifactRefreshRun 挂总超时(默认 120s)
│ └─ withLiveArtifactRefreshSourceTimeout 单源超时(默认 30s)
│ └─ executeLocalDaemonRefreshSource refresh.ts:706

├─ buildLiveArtifactRefreshCandidate refresh.ts:492 合并进 dataJson
└─ commitLiveArtifactRefreshCandidate / markLiveArtifactRefreshFailed

两级超时常量在 refresh.ts:12-13。中断有专门的错误类型 LiveArtifactRefreshAbortErrorrefresh.ts:98),区分 cancelled / source_timeout / total_timeout 三种成因。崩溃留下的陈旧锁由 recoverStaleLiveArtifactRefreshesstore.ts:1123)扫目录回收。

预览的重新生成走 ensureLiveArtifactPreviewstore.ts:1198)——它比对 index.html 和三个依赖文件(artifact.json / template.html / data.json)的 mtime,预览更新才复用,否则重渲。


10. 第六段:导出

导出分成两族,判断依据是「要不要真的把页面渲染出来」。

族一:不需要渲染,daemon 直接读文件。

能力入口关键点
资源内联inlineRelativeAssetsinline-assets.ts:96只内联顶层相对 <link rel=stylesheet><script src>
文档预览buildDocumentPreviewdocument-preview.ts:32PDF/docx/pptx/xlsx 抽文本,不渲染
对话导出exportProjectTranscripttranscript-export.ts:161落成 .transcript.jsonl

inline-assets.ts 的头部注释(:1-34)罕见地把不做什么列全了:不改 <img>/<video>/<iframe> 的 src、不改 CSS url()@import、不改 ES module import、只认 rel=stylesheet。理由是这个原语的主要消费者是截图路径——无头浏览器渲染时会自己去拉资源,内联 CSS 和 JS 就够了。想要「完全离线的自包含导出」得另外做。

五个防御性上限(inline-assets.ts:74-78):owner HTML 2 MiB、单资源 5 MiB、候选标签 500 个、输出总量 50 MiB、并发读 8。AssetHandle:46)刻意把 sizeread() 拆开,注释说明了原因:早先的版本在全部读完之后才判上限,500 个 5 MiB 的资源能在报 413 之前先吃掉 2.5 GiB 内存;拆开后可以在 buffer 之前就短路。

transcript-export.ts 的头部注释(:1-53)是一份完整的格式说明书:JSONL、schemaVersion 2、text/thinking 连续块合并、tool_use/tool_result 原样保留、status/usage/raw 丢弃。附件只记引用不内联字节,并在 header 里显式写 attachmentsInlined: false,让下游合成器能分辨「完整记录」和「静默省略了输入的记录」。并发保护是 openSync(..., 'wx') 的文件锁,第二个并发导出抛 TranscriptExportLockedError:137)。

族二:必须渲染,走 iframe 截图或 Electron 打印。

用户点「导出 PDF」

├─ 路线 A:宿主打印(有 Electron)
│ buildDesktopPdfExportInput pdf-export.ts:24
│ └─ 读文件 + 算 baseHref + 定 defaultFilename
│ __od__.pdf.print(html, nonce, { deck })
│ └─ 主进程 printToPDF + DECK_PRINT_CSS

└─ 路线 B:逐页截图(deck / 图片格式)
injectExportCaptureBridge srcdoc.ts:603
│ 宿主 postMessage { type:'od:export-capture', mode:'image', deck }
│ 桥自己发 od:slide 翻页,逐张 dataURL 回传
▼ od:export-capture:slide × N → :done
buildScreenshotPptx / buildScreenshotPdf deck-export.ts:137 / :182

路线 B 的巧妙点在 srcdoc.ts:937-939 的注释:截图桥复用 deck 桥自己的导航能力window.__odDeckSlideState + 给自己 postMessage 一条 od:slide),所以任何 deck 桥能翻的页,导出就能截。不需要为导出再写一套翻页适配。

三处导出都要算的 baseHref 逻辑一模一样(pdf-export.ts:88rawBaseHrefdeck-export.ts:228 的同名函数):指向 daemon 的 /api/projects/:id/raw/<dir>/,好让离线渲染时相对路径的 CSS/JS/图片仍能解析。

导出格式常量:PPTX 幻灯片宽 13.333 英寸(PPTX_SLIDE_WIDTH_INdeck-export.ts:150),PDF 最长边 960pt(PDF_PAGE_LONGEST_PTdeck-export.ts:202)。


11. 巧妙之处(可以直接借鉴的六条)

① 让解析器和渲染器共用同一份「哪里是代码」的判定。 markdown-context.ts 的文件头注释直说它要「与 runtime/markdown.tsx 保持锁步」。凡是「协议标签内嵌在自然语言里」的系统都会踩这个坑——把判定抽成第三方模块,比在两边各写一份正则可靠得多。(apps/web/src/artifacts/markdown-context.ts:1-10

② 把闸门装在唯一的写入口上,而不是每个调用方。 writeProjectFileprojects.ts:758)是所有产物落盘的必经之路,三道闸都挂在这。stub-guard.ts:1-10 的注释点明了收益:因为判据是结构性的(比字节数),换任何 agent 后端都照样生效。

③ 结构性检测胜过文本匹配。 stub-guard 不匹配「见 xxx.html」这类措辞,只比大小。publication-guard 反过来只认五个字符串、绝不泛化。两者合起来是同一条原则的两面:能用结构判就别猜措辞,必须猜措辞时就把范围锁死到能列举

④ 注册期就模拟运行期。 validateDaemonRefreshRequiredInputschema.ts:493)逐条镜像 selectJsonPath 的取值优先级和类型规则,为的是不让「已持久化但永远刷不动」的产物存在。这比事后报错的用户体验好一个数量级。

⑤ 把「能不能干」的决策抽成纯函数。 canActivateSrcDocTransportsrcdoc.ts:541)和 classifyArtifactStubGuardstub-guard.ts:273)都是从异步流程里剥出来的纯判定——前者五个布尔输入,后者接收已抓好的兄弟列表。副作用留在外层,判定单测起来是纯函数级的成本。

⑥ 用不对称设计承认现实。 FENCE_OPEN_RE 允许 info string、FENCE_CLOSE_RE 不允许(markdown-context.ts:17-18),因为渲染器就是这么干的。selection 桥不校验 origin 并把理由写在注释里(srcdoc.ts:1793)。这类「明知不完美但明确写下边界」的注释,比假装严密的代码有用得多。


12. 边界与局限(诚实清单)

  • supportingFiles 目前是空头支票。 类型定义里写着「保留给未来的多文件产物打包;当前生成器只落一个 entry 文件,所以还没被填充」(apps/web/src/artifacts/types.ts:50-53)。多文件产物还没真正落地。
  • 清单推断逻辑双写。 daemon 和 web 各有一份 inferLegacyManifest,靠人工同步(apps/daemon/src/artifacts/manifest.ts:266-268)。
  • publication-guard 只挡五个字符串、只管 html/deck。 换一套模板就要么声明结构化的 od.inputs,要么往这个列表里加自己的标记并附上「证明它不可能出现在成品里」的 fixture(publication-guard.ts:8-16)。
  • stub-guard 默认只 warn 不 reject。 生产上要真拦得设 OD_ARTIFACT_STUB_GUARD=rejectstub-guard.ts:67)。
  • selection 桥不校验 postMessage origin。 依赖 sandbox + 属性白名单兜底(srcdoc.ts:1793-1800)。
  • Live Artifact 预览没有脚本。 需要交互图表就只能走 Normal Artifact 那条线(live-artifacts/render.ts:23-30)。
  • .transcript.lock 没有陈旧锁恢复。 崩溃后要人工 rmtranscript-export.ts:49-53)。相比之下 Live Artifact 的刷新锁有 recoverStaleLiveArtifactRefreshes
  • inline-assets 不是完整打包器。 图片、字体、CSS 里的 url() 和 ES module import 全都不管(inline-assets.ts:10-25)。
  • 只有 <question-form> 这一条追问通道。 没有 AskUserQuestion 工具,也没有 host 回答的返回路径。
  • localStorage 垫片是内存版。 产物里的持久化状态不跨刷新(srcdoc.ts:1455-1457)。

13. 代码地图(导航索引)

落盘契约(daemon)

主题文件符号
清单白名单与上限apps/daemon/src/artifacts/manifest.tsMANIFEST_VERSIONALLOWED_KINDSALLOWED_RENDERERSALLOWED_EXPORTS
清单校验/净化apps/daemon/src/artifacts/manifest.tsvalidateArtifactManifestInputsanitizeManifestvalidateSupportingPath
遗留推断apps/daemon/src/artifacts/manifest.tsinferLegacyManifestparsePersistedManifest
创建入口apps/daemon/src/artifacts/create.tsresolveCreateArtifactManifestArtifactManifestRequiredError
占位符闸apps/daemon/src/artifacts/publication-guard.tsUNRESOLVED_ARTIFACT_PLACEHOLDERSassertArtifactPublicationAllowed
骨架闸apps/daemon/src/artifacts/stub-guard.tsclassifyArtifactStubGuardfindPriorArtifactSiblingsartifactIdentifiersMatch
运行时修复apps/daemon/src/artifacts/runtime-compat.tsnormalizeArtifactRuntimeImports
标签抑制apps/daemon/src/artifacts/text-suppression.tscreateDsmlArtifactTextSuppressorcreateTaggedTextSuppressor
写入口(三闸汇合)apps/daemon/src/projects.tswriteProjectFile

流式解析(web)

主题文件符号
增量状态机apps/web/src/artifacts/parser.tscreateArtifactParserfindOpenTag
markdown 上下文apps/web/src/artifacts/markdown-context.tscomputeSkipRangesFENCE_OPEN_REisRealArtifactOpenAt
剥离/摘要/拆流apps/web/src/artifacts/strip.tsstripArtifactsummarizeArtifactsForTranscriptsplitStreamingArtifact
兜底恢复apps/web/src/artifacts/recover.tsrecoverHtmlArtifactFromPrecedingDocumentresolvePersistedArtifactHtml
结构嗅探apps/web/src/artifacts/validate.tsvalidateHtmlArtifactSTARTS_WITH_DOCUMENT_RE
指针产物apps/web/src/artifacts/pointer.tsresolveHtmlPointerArtifactTarget
渲染器路由apps/web/src/artifacts/renderer-registry.tsartifactRendererRegistryRendererRegistry
追问表单apps/web/src/artifacts/question-form.tssplitOnQuestionFormsparsePartialQuestionFormformatFormAnswers

沙箱预览与编辑(web)

主题文件符号
装配线apps/web/src/runtime/srcdoc.tsbuildSrcdoc
文件名净化apps/web/src/runtime/srcdoc.tssanitizePreviewTitlesanitizeTitleInDocfindRealTitleOffset
懒加载壳apps/web/src/runtime/srcdoc.tsbuildLazySrcdocTransportcanActivateSrcDocTransport
沙箱垫片apps/web/src/runtime/srcdoc.tsinjectSandboxShim
deck 协议apps/web/src/runtime/srcdoc.tsinjectDeckBridgegotoIndex__odDeckSlideState
选择/审查桥apps/web/src/runtime/srcdoc.tsinjectSelectionBridge
配色桥apps/web/src/runtime/srcdoc.tsinjectPaletteBridge
截图/导出桥apps/web/src/runtime/srcdoc.tsinjectSnapshotBridgeinjectExportCaptureBridge
自动标注apps/web/src/runtime/srcdoc.tsannotateMissingOdIdsannotateManualEditSourcePathssourcePathForElement
预览 iframe 挂载点apps/web/src/components/DesignFilesPanel.tsxsandbox="allow-scripts allow-downloads":1378
编辑覆盖层apps/web/src/edit-mode/bridge.tsMANUAL_EDIT_SOURCE_PATH_ATTRmanualEditDomPathForElementmanualEditKindForElementbuildManualEditBridge
源码补丁apps/web/src/edit-mode/source-patches.tsapplyManualEditPatch

宿主桥

主题文件符号
契约常量packages/host/src/index.tsOPEN_DESIGN_HOST_GLOBALOPEN_DESIGN_HOST_VERSION
能力面packages/host/src/index.tsOpenDesignHostBridgeisOpenDesignHostBridge
单次 tokenpackages/host/src/index.tsOpenDesignHostPickWorkingDirSuccesspickHostWorkingDir
结果归一化packages/host/src/index.tsnormalizeOpenDesignHostProjectImportResult
Electron 暴露apps/desktop/src/main/preload.ctscontextBridge.exposeInMainWorld
打印实现apps/desktop/src/main/pdf-export.tsDECK_PAGE_SIZEDECK_PRINT_CSS

Live Artifact 与导出(daemon)

主题文件符号
三件套与枚举apps/daemon/src/live-artifacts/schema.tsLiveArtifactDocumentLiveArtifactSourceTypeLiveArtifactRefreshPermission
注册期镜像运行期apps/daemon/src/live-artifacts/schema.tsvalidateDaemonRefreshRequiredInputvalidateReadJsonSelector
存储布局apps/daemon/src/live-artifacts/store.tsLIVE_ARTIFACTS_DIR_NAMEliveArtifactStorePathsensureLiveArtifactPreview
模板引擎apps/daemon/src/live-artifacts/render.tsrenderHtmlTemplateV1validateHtmlTemplateV1Security
刷新执行apps/daemon/src/live-artifacts/refresh.tsexecuteLocalDaemonRefreshSourcebuildLiveArtifactRefreshCandidateLiveArtifactRefreshAbortError
刷新编排apps/daemon/src/live-artifacts/refresh-service.tsrefreshLiveArtifact
PDF 导出输入apps/daemon/src/pdf-export.tsbuildDesktopPdfExportInput
deck 导出apps/daemon/src/deck-export.tsbuildDeckRenderInputbuildScreenshotPptxbuildScreenshotPdf
资源内联apps/daemon/src/inline-assets.tsinlineRelativeAssetsInlineAssetsLimitError
文档预览apps/daemon/src/document-preview.tsbuildDocumentPreview
对话导出apps/daemon/src/transcript-export.tsexportProjectTranscriptTranscriptExportLockedError

14. 接着读