kimi-web Dynamic HTML 最终设计
在页面中嵌入可交互 HTML 组件 / iframe 页面 · 合并 docs/dynamic-html-research.html 与 reports/dynamic-html-design-research.html 两份调研的最终方案 · 2026-06-11
.html 文件或输出
```html 代码块,不新增协议内容类型、不新增 artifact 工具;前端把这两类内容识别为
artifact,在聊天卡片、右侧分屏和 files 面板里用统一的沙箱 iframe渲染——文件走 daemon
预览路由(服务端下发默认拒绝的 CSP),代码块走 srcdoc;两条投递路径共用同一个安全模型:
sandbox 不带 allow-same-origin。运行中的本地 dev server 作为第二类
artifact(URL 嵌入)。交互桥用对齐 MCP Apps 的 postMessage JSON-RPC,凭实例 token 校验。
1. 范围
| 做 | 不做 |
|---|---|
|
· 自包含 HTML(文件 / 代码块)的内联预览与分屏预览 · 同目录静态资源(CSS/JS/图片)的相对路径加载 · 本地 dev server 的 iframe 嵌入(URL artifact) · artifact → 对话的受控回传(postMessage 桥) · 新窗口打开 / 复制源码 / 下载 |
· 协议级 artifact 内容类型与独立 artifact store(文件即存储;将来需要版本管理时再评估) · WebContainers / Sandpack / 浏览器内打包 React 多文件项目(daemon 能跑真 dev server,由 URL artifact 覆盖) · artifact 发布/分享到外部(纯本地产品形态) |
2. 产品形态
2.1 三个呈现位置
- 聊天卡片(ArtifactBlock):write/edit 了
.html的工具卡片、超过 30 行的```html代码块,折叠为 artifact 卡片——标题(文件名或首个<title>)+ 大小 + 「预览 / 分屏打开 / 复制」。默认不自动渲染,点击才创建 iframe(防止一轮多个 artifact 同时吃资源)。 - 右侧分屏(artifact 舞台):与 files/diff 共享分栏布局。顶部固定外壳:标题、来源路径、「沙箱预览」标识(防伪造 UI 冒充原生界面)、预览/源码切换、刷新、新窗口、下载。
- files 面板:FilePreview 对
html类型增加预览 tab,复用同一个ArtifactFrame组件。 - 移动端:卡片点开后全屏 sheet 呈现(复用现有 bottom-sheet 模式),不做分屏。
2.2 入口矩阵
| artifact 来源 | 识别方式 | 投递方式 |
|---|---|---|
工作区 .html 文件(write/edit 工具、历史文件) | 工具参数路径后缀(toolMeta 已能提取);files 树 | daemon 预览路由(§4.1) |
消息中的 ```html 代码块 | messagesToTurns 拆块时按语言 + 行数阈值识别 | srcdoc(§4.2) |
| 本地 dev server(vite 等后台任务) | daemon 后台任务元数据中的端口/URL(解析任务输出或显式约定) | URL iframe(§4.3) |
3. 架构与数据流
为什么不上协议级 artifact 类型(reports 版方案 C 的取舍):kimi-code 的 agent 工作流本来就以文件为产物(TUI 同理), 「artifact = 文件」让 web/TUI 行为一致、历史会话立即可用、文件系统天然承担存储与版本(git); 新增 content type + store + 专用工具会改变模型行为并造成双轨。派生层(messagesToTurns)已有先例——TurnBlock 的 text/thinking/tool 同样是派生的有序块,artifact 作为第四种 kind 加入即可。
4. 渲染机制(统一安全模型,两种投递)
4.1 文件 artifact:daemon 预览路由
新增只读路由(薄包装现有 fs 读取,含路径穿越校验):
GET /api/v1/sessions/{session_id}/fs/{path}:preview
响应头(对 text/html):
Content-Type: text/html; charset=utf-8
Content-Security-Policy:
default-src 'none';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:;
font-src 'self' data:;
media-src 'self' data: blob:;
connect-src 'none'; ← 关键:禁止 fetch/XHR/WebSocket,daemon API 打不通
frame-src 'none';
base-uri 'none';
form-action 'none'
Referrer-Policy: no-referrer
X-Content-Type-Options: nosniff
'self'只为同目录相对资源服务:HTML 里<img src="./chart.png">、<script src="./app.js">会回到同一预览路由解析(非 HTML 文件按真实 MIME 返回,同样带 nosniff)。connect-src 'none'与「iframe sandbox 不带 allow-same-origin」双保险:前者挡住一切网络 API,后者让文档处于 opaque origin(即使 CSP 被绕过,跨域写操作也会被预检拦下)。- 外部 CDN 默认全禁,与仓库 HTML 生成规范的「默认自包含」一致;如确需放行(Chart.js 等),在 daemon 配置里加显式 allowlist 拼进
script-src,不做 UI 开关。 - 「新窗口打开」直接开这个 URL——头部策略在独立标签页同样生效。
4.2 代码块 artifact:srcdoc
内容尚不在磁盘上,直接内联,并在文档头注入 CSP meta(meta CSP 与文档自带 CSP 取交集,只紧不松):
<iframe
:srcdoc="injectCspMeta(block.html)"
sandbox="allow-scripts allow-forms allow-modals allow-popups"
referrerpolicy="no-referrer"
loading="lazy"
:key="contentHash" ← 内容变化即整体重建,不复用实例
/>
- 注入的 meta CSP 同 §4.1(去掉 'self':srcdoc 无相对资源概念),meta 不支持的指令(frame-ancestors 等)由 sandbox 兜底。
- 流式期间显示骨架占位 + 行数计数,代码块闭合后才渲染(避免每个 delta 重建 iframe)。
- 用户「保存为文件」后,卡片升级为文件 artifact(切到 §4.1 路径)。
4.3 URL artifact:本地 dev server
<iframe src="http://localhost:5173/" loading="lazy"> ← 不加 sandbox
- 这是用户自己的项目进程,信任级别 = 用户代码;不同端口已是跨 origin,拿不到 kimi-web 与 daemon 的任何状态。
- 目标页面带
X-Frame-Options/frame-ancestors拒绝被嵌时,外壳降级为「在新标签页打开」按钮 + 截图占位(iframe onload 探测失败即降级)。 - 入口:daemon 后台任务(background.task.started)携带端口元数据时,任务卡片出现「预览」。
allow-same-origin,且不提供任何「放开同源」的设置开关(Open WebUI 的反面教训);
② 不可信 HTML 永远不直接进宿主 DOM(不进 Markdown.vue、不用 v-html / dangerouslySetInnerHTML)。
5. postMessage 交互桥
协议形态对齐 MCP Apps(JSON-RPC over postMessage),方法集从最小白名单开始。宿主在 iframe
load 后用 postMessage 下发一次性 实例 token(opaque origin 下
event.origin 恒为 null,不能用 origin 鉴别,必须 token + event.source === iframe.contentWindow 双校验)。
| 方法(artifact → 宿主) | 语义 | 宿主行为 |
|---|---|---|
ui/ready | 初始化完成 | 撤掉骨架占位 |
ui/height | 内容高度变化 | 调整内联卡片 iframe 高度(上限 70vh,超出内部滚动) |
ui/error | 运行时错误(artifact 内 window.onerror 转发) | 外壳显示错误条 +「让模型修复」按钮 |
ui/open-link | 请求打开外部链接 | 宿主确认弹层后 window.open,绝不在 iframe 内导航 |
ui/follow-up | 请求向对话追加一条消息(如「基于这个原型改 X」) | 填入 Composer 草稿,由用户按发送——iframe 永远不能直接驱动 agent |
桥以一段宿主注入的 <script>(srcdoc 注入 / 预览路由响应改写 <head>)暴露
window.kimi.{notifyHeight, reportError, openLink, followUp},模型的 HTML 生成规范同步增加这几个可选 API 的说明。
将来支持 MCP Apps(ui:// 资源)时,这条桥直接换成完整 MCP JSON-RPC 方言,外壳与安全模型不变。
6. 安全模型
#token 携带,前端对 API 请求加 header,daemon 校验并对预览路由豁免(CSP 已默认拒绝、且其内容本身来自工作区)。
这同时关闭了与本功能无关的存量攻击面(任何本机网页可直接调用无鉴权的 127.0.0.1:7878)。| 威胁 | 后果 | 对策 |
|---|---|---|
| 提示注入控制的 HTML 调用 daemon API | 读写任意文件 / 开会话执行命令(等价 RCE) | sandbox 无 allow-same-origin(opaque origin)+ connect-src 'none' + daemon token 三层 |
| HTML 外联泄漏对话/工作区内容 | 数据经 img/fetch/表单发往外部域名 | CSP default-src 'none'(img 仅 self/data/blob、form-action 'none'、connect 'none');CDN 走显式 allowlist |
| 伪造宿主 UI(假登录框/假确认弹窗) | 用户误信 artifact 内容为原生界面 | 外壳常显「沙箱预览」标识与来源路径;禁全屏 API;外链必经宿主确认 |
| postMessage 伪造 | 其他 frame / 页面冒充 artifact 触发宿主动作 | 实例 token + event.source 校验 + zod 校验消息 schema + follow-up 永远停在草稿 |
| 资源滥用(巨型 HTML、死循环、一轮 N 个 artifact) | 页面卡死、内存膨胀 | 大小上限(如 2 MB,超出仅源码模式)、点击才实例化、同屏最多 1 个活动 iframe(分屏内)、lazy + 离屏卸载 |
| 预览路由路径穿越 | 读出工作区之外的文件 | 复用 fs 路由既有的根目录约束与规范化(实现时验证用例:../、symlink、绝对路径) |
7. 模块改动清单
| 位置 | 改动 |
|---|---|
packages/daemon/src/routes/fs.ts | 新增 :preview GET 路由(CSP/安全头 + 注入桥脚本);daemon 启动 token 中间件(独立小改动) |
apps/kimi-web/src/types.ts | TurnBlock 增加 { kind: 'artifact'; artifact: ArtifactView };ArtifactView = { source: 'file' | 'inline' | 'url'; title; path?; html?; url?; size } |
apps/kimi-web/src/composables/messagesToTurns.ts | 派生 artifact 块:html 写盘工具 → file artifact;≥30 行 ```html 代码块 → inline artifact(保持原始顺序,不 hoist) |
apps/kimi-web/src/components/ArtifactFrame.vue(新) | 统一沙箱 iframe:三种投递、骨架/错误态、postMessage 桥、高度管理 |
ArtifactBlock.vue(新)/ ChatPane.vue | 聊天卡片外壳;ChatPane 渲染第四种 block |
ConversationPane.vue | 右侧分屏 artifact 舞台(与 files 分栏布局共享);移动端全屏 sheet |
FilePreview.vue | html 类型:预览 | 源码 tab,复用 ArtifactFrame |
TasksPane.vue / 任务卡片 | 带端口的后台任务显示「预览」入口(URL artifact) |
| HTML 生成规范(用户侧 CLAUDE.md / 系统提示) | 补充沙箱约束说明(无 localStorage、无外联、可选 window.kimi 桥 API) |
8. 关键边界与降级
- 流式:代码块未闭合 / 文件正在连续 edit 时显示骨架,settle 后渲染;刷新按钮手动重载。
- 大文件:>2 MB 仅提供源码 + 新窗口打开(新窗口走预览路由不受限)。
- opaque origin 的 API 限制:localStorage/sessionStorage 抛异常、无 cookie——写进生成规范,让模型用内存状态。
- 历史会话:派生逻辑对历史消息同样生效,旧会话里的 HTML 工具调用自动获得预览入口。
- 主题:iframe 内容不继承应用暗色主题;外壳提示由内容自管(自包含 HTML 规范已要求移动端可读,同理可建议支持 prefers-color-scheme)。
- 嵌入被拒(URL artifact):onload 探测失败 → 新标签页按钮降级。
9. 调研依据(两份输入报告的来源汇总)
- Claude Artifacts 沙箱与 CSP 行为:simonwillison.net/tags/claude-artifacts;support.claude.com(artifacts 帮助页)
- Open WebUI:iframe srcdoc + sandbox="allow-scripts" + IFRAME_CSP(commit 3bba1c2);其「同源放行」开关为反面教训
- LibreChat:Sandpack 预览 + 自托管 bundler + static-browser-server(每预览独立 origin)——多文件浏览器内打包路线的成本参照
- bolt.new / StackBlitz WebContainers:浏览器内 Node 运行时;本地 daemon 架构下由真实 dev server 替代
- v0 Sandbox:每会话隔离 VM 的 full app preview 形态参照
- MCP Apps(官方扩展)/ mcp-ui:sandboxed iframe + JSON-RPC over postMessage;本设计交互桥的协议对齐目标
- OpenAI Apps SDK:model-visible 内容与组件私有数据(_meta)分层的设计参考
- MDN:iframe sandbox、CSP、postMessage、srcdoc 语义