kimi-code/docs/dynamic-html-design-final.html
qer bb4396065c feat(kimi-web): polish chat UI components
- ThinkingBlock: foldable last-paragraph teaser, hover-only indicator,
  auto-collapse when streaming ends, line-height aligned with content
- ChatPane: static turn-summary at end of assistant turns
- Composer: perm-pill background matches toggle-pill
- SessionRow: kebab button moved before timestamp
- ToolCall: replace text chevron with SVG
2026-06-11 02:29:51 +08:00

285 lines
22 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>kimi-web Dynamic HTML 最终设计</title>
<style>
:root {
--ink: #1a1a1a; --muted: #57606a; --faint: #8b949e;
--line: #d8dee4; --soft: #f6f8fa; --panel: #ffffff;
--blue: #0969da; --bluebg: #ddf4ff; --green: #1a7f37; --greenbg: #dafbe1;
--red: #cf222e; --redbg: #ffebe9; --amber: #9a6700; --amberbg: #fff8c5;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 0 16px 80px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", sans-serif;
color: var(--ink); background: #fafbfc; line-height: 1.7; font-size: 15px;
}
main { max-width: 900px; margin: 0 auto; }
h1 { font-size: 24px; margin: 36px 0 6px; }
h2 { font-size: 19px; margin: 44px 0 12px; padding-bottom: 8px; border-bottom: 2px solid var(--line); }
h3 { font-size: 16px; margin: 24px 0 8px; }
p { margin: 8px 0; }
ul { margin: 8px 0; }
.sub { color: var(--muted); font-size: 13px; margin-bottom: 28px; }
code { font-family: var(--mono); font-size: 13px; background: var(--soft); border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; }
pre { background: #0d1117; color: #e6edf3; border-radius: 8px; padding: 14px 16px; overflow-x: auto; font-size: 13px; line-height: 1.6; }
pre code { background: none; border: none; color: inherit; padding: 0; }
table { width: 100%; border-collapse: collapse; margin: 14px 0; font-size: 13.5px; background: var(--panel); }
th, td { border: 1px solid var(--line); padding: 8px 10px; text-align: left; vertical-align: top; }
th { background: var(--soft); }
.tablewrap { overflow-x: auto; }
.verdict { background: var(--bluebg); border: 1px solid #54aeff66; border-radius: 10px; padding: 14px 18px; margin: 18px 0; }
.verdict b { color: var(--blue); }
.risk { background: var(--redbg); border-left: 4px solid var(--red); border-radius: 6px; padding: 10px 14px; margin: 12px 0; }
.note { background: var(--amberbg); border-left: 4px solid var(--amber); border-radius: 6px; padding: 10px 14px; margin: 12px 0; }
.ok { background: var(--greenbg); border-left: 4px solid var(--green); border-radius: 6px; padding: 10px 14px; margin: 12px 0; }
.tag { display: inline-block; font-size: 11.5px; font-weight: 600; border-radius: 999px; padding: 1px 9px; margin-right: 4px; white-space: nowrap; }
.t-green { background: var(--greenbg); color: var(--green); }
.t-red { background: var(--redbg); color: var(--red); }
.t-blue { background: var(--bluebg); color: var(--blue); }
.flow { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 14px 0; font-size: 13px; }
.flow .step { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 8px 12px; }
.flow .arrow { color: var(--faint); font-weight: 700; }
.wire {
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
padding: 14px; margin: 12px 0; font-family: var(--mono); font-size: 12px;
line-height: 1.5; overflow-x: auto; white-space: pre; color: var(--muted);
}
#tocbtn {
position: fixed; right: 18px; bottom: 18px; z-index: 20;
background: var(--ink); color: #fff; border: none; border-radius: 999px;
padding: 10px 16px; font-size: 13px; cursor: pointer; box-shadow: 0 4px 14px rgba(0,0,0,.2);
}
#toc {
position: fixed; right: 18px; bottom: 64px; z-index: 20; display: none;
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
padding: 10px 16px; box-shadow: 0 8px 24px rgba(0,0,0,.14); max-width: 300px;
}
#toc a { display: block; color: var(--ink); text-decoration: none; padding: 4px 0; font-size: 13px; }
#toc a:hover { color: var(--blue); }
@media (max-width: 640px) { body { font-size: 14.5px; } th, td { padding: 6px 8px; } }
</style>
</head>
<body>
<main>
<h1>kimi-web Dynamic HTML 最终设计</h1>
<p class="sub">在页面中嵌入可交互 HTML 组件 / iframe 页面 · 合并 docs/dynamic-html-research.html 与 reports/dynamic-html-design-research.html 两份调研的最终方案 · 2026-06-11</p>
<div class="verdict">
<b>设计一句话:</b>「artifact = 工作区里的 HTML 内容」。模型照常写 <code>.html</code> 文件或输出
<code>```html</code> 代码块,<b>不新增协议内容类型、不新增 artifact 工具</b>;前端把这两类内容识别为
artifact在聊天卡片、右侧分屏和 files 面板里用<b>统一的沙箱 iframe</b>渲染——文件走 daemon
预览路由(服务端下发默认拒绝的 CSP代码块走 srcdoc两条投递路径共用同一个安全模型
<code>sandbox</code> 不带 <code>allow-same-origin</code>。运行中的本地 dev server 作为第二类
artifactURL 嵌入)。交互桥用对齐 MCP Apps 的 postMessage JSON-RPC凭实例 token 校验。
</div>
<h2 id="s1">1. 范围</h2>
<div class="tablewrap"><table>
<tr><th></th><th>不做</th></tr>
<tr><td>
· 自包含 HTML文件 / 代码块)的内联预览与分屏预览<br>
· 同目录静态资源CSS/JS/图片)的相对路径加载<br>
· 本地 dev server 的 iframe 嵌入URL artifact<br>
· artifact → 对话的受控回传postMessage 桥)<br>
· 新窗口打开 / 复制源码 / 下载
</td><td>
· 协议级 artifact 内容类型与独立 artifact store文件即存储将来需要版本管理时再评估<br>
· WebContainers / Sandpack / 浏览器内打包 React 多文件项目daemon 能跑真 dev server由 URL artifact 覆盖)<br>
· artifact 发布/分享到外部(纯本地产品形态)
</td></tr>
</table></div>
<h2 id="s2">2. 产品形态</h2>
<h3>2.1 三个呈现位置</h3>
<div class="wire">┌────────────────────────────────────────────┬───────────────────────────┐
│ 聊天流 │ 右侧分屏artifact 舞台) │
│ │ ┌───────────────────────┐ │
│ kimi &gt; 我做了一个对比页…… │ │ ⛶ 方案对比 · 沙箱预览 │ │
│ ┌────────────────────────────────────┐ │ │ ┌───────────────────┐ │ │
│ │ ▤ docs/compare.html [预览] │ ──→ │ │ │ (iframe 渲染) │ │ │
│ │ HTML artifact · 24 KB │ │ │ │ │ │ │
│ └────────────────────────────────────┘ │ │ └───────────────────┘ │ │
│ (工具卡片 / 代码块卡片是同一种外壳) │ │ 预览 | 源码 ⟳ ↗ ⤓ │ │
│ │ └───────────────────────┘ │
└────────────────────────────────────────────┴───────────────────────────┘
files 标签页FilePreview 对 .html 提供「预览 | 源码」切换(同一渲染组件)</div>
<ul>
<li><b>聊天卡片ArtifactBlock</b>write/edit 了 <code>.html</code> 的工具卡片、超过 30 行的 <code>```html</code> 代码块,折叠为 artifact 卡片——标题(文件名或首个 <code>&lt;title&gt;</code>+ 大小 + 「预览 / 分屏打开 / 复制」。默认<b>不自动渲染</b>,点击才创建 iframe防止一轮多个 artifact 同时吃资源)。</li>
<li><b>右侧分屏artifact 舞台)</b>:与 files/diff 共享分栏布局。顶部固定外壳:标题、来源路径、「沙箱预览」标识(防伪造 UI 冒充原生界面)、预览/源码切换、刷新、新窗口、下载。</li>
<li><b>files 面板</b>FilePreview 对 <code>html</code> 类型增加预览 tab复用同一个 <code>ArtifactFrame</code> 组件。</li>
<li><b>移动端</b>:卡片点开后全屏 sheet 呈现(复用现有 bottom-sheet 模式),不做分屏。</li>
</ul>
<h3>2.2 入口矩阵</h3>
<div class="tablewrap"><table>
<tr><th>artifact 来源</th><th>识别方式</th><th>投递方式</th></tr>
<tr><td>工作区 <code>.html</code> 文件write/edit 工具、历史文件)</td><td>工具参数路径后缀toolMeta 已能提取files 树</td><td>daemon 预览路由§4.1</td></tr>
<tr><td>消息中的 <code>```html</code> 代码块</td><td>messagesToTurns 拆块时按语言 + 行数阈值识别</td><td>srcdoc§4.2</td></tr>
<tr><td>本地 dev servervite 等后台任务)</td><td>daemon 后台任务元数据中的端口/URL解析任务输出或显式约定</td><td>URL iframe§4.3</td></tr>
</table></div>
<h2 id="s3">3. 架构与数据流</h2>
<div class="flow">
<span class="step">模型 write a.html / 输出 ```html</span><span class="arrow"></span>
<span class="step">消息流(现有协议,零改动)</span><span class="arrow"></span>
<span class="step">messagesToTurns 派生 artifact 块</span><span class="arrow"></span>
<span class="step">ArtifactBlock 卡片</span><span class="arrow"></span>
<span class="step">ArtifactFrame 沙箱渲染</span><span class="arrow"></span>
<span class="step">postMessage 桥token 校验)</span>
</div>
<p><b>为什么不上协议级 artifact 类型</b>reports 版方案 C 的取舍kimi-code 的 agent 工作流本来就以文件为产物TUI 同理),
「artifact = 文件」让 web/TUI 行为一致、历史会话立即可用、文件系统天然承担存储与版本git
新增 content type + store + 专用工具会改变模型行为并造成双轨。派生层messagesToTurns已有先例——TurnBlock 的
text/thinking/tool 同样是派生的有序块artifact 作为第四种 kind 加入即可。</p>
<h2 id="s4">4. 渲染机制(统一安全模型,两种投递)</h2>
<h3>4.1 文件 artifactdaemon 预览路由</h3>
<p>新增只读路由(薄包装现有 fs 读取,含路径穿越校验):</p>
<pre><code>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/WebSocketdaemon API 打不通
frame-src 'none';
base-uri 'none';
form-action 'none'
Referrer-Policy: no-referrer
X-Content-Type-Options: nosniff</code></pre>
<ul>
<li><code>'self'</code> 只为同目录相对资源服务HTML 里 <code>&lt;img src="./chart.png"&gt;</code><code>&lt;script src="./app.js"&gt;</code> 会回到同一预览路由解析(非 HTML 文件按真实 MIME 返回,同样带 nosniff</li>
<li><code>connect-src 'none'</code> 与「iframe sandbox 不带 allow-same-origin」双保险前者挡住一切网络 API后者让文档处于 opaque origin即使 CSP 被绕过,跨域写操作也会被预检拦下)。</li>
<li>外部 CDN 默认全禁,与仓库 HTML 生成规范的「默认自包含」一致如确需放行Chart.js 等),在 daemon 配置里加显式 allowlist 拼进 <code>script-src</code>,不做 UI 开关。</li>
<li>「新窗口打开」直接开这个 URL——头部策略在独立标签页同样生效。</li>
</ul>
<h3>4.2 代码块 artifactsrcdoc</h3>
<p>内容尚不在磁盘上,直接内联,并在文档头注入 CSP metameta CSP 与文档自带 CSP 取交集,只紧不松):</p>
<pre><code>&lt;iframe
:srcdoc="injectCspMeta(block.html)"
sandbox="allow-scripts allow-forms allow-modals allow-popups"
referrerpolicy="no-referrer"
loading="lazy"
:key="contentHash" ← 内容变化即整体重建,不复用实例
/&gt;</code></pre>
<ul>
<li>注入的 meta CSP 同 §4.1(去掉 'self'srcdoc 无相对资源概念meta 不支持的指令frame-ancestors 等)由 sandbox 兜底。</li>
<li>流式期间显示骨架占位 + 行数计数,<b>代码块闭合后才渲染</b>(避免每个 delta 重建 iframe</li>
<li>用户「保存为文件」后,卡片升级为文件 artifact切到 §4.1 路径)。</li>
</ul>
<h3>4.3 URL artifact本地 dev server</h3>
<pre><code>&lt;iframe src="http://localhost:5173/" loading="lazy"&gt; ← 不加 sandbox</code></pre>
<ul>
<li>这是用户自己的项目进程,信任级别 = 用户代码;不同端口已是跨 origin拿不到 kimi-web 与 daemon 的任何状态。</li>
<li>目标页面带 <code>X-Frame-Options</code> / <code>frame-ancestors</code> 拒绝被嵌时,外壳降级为「在新标签页打开」按钮 + 截图占位iframe onload 探测失败即降级)。</li>
<li>入口daemon 后台任务background.task.started携带端口元数据时任务卡片出现「预览」。</li>
</ul>
<div class="risk">
<b>不可妥协的两条红线:</b>① 渲染模型生成的 HTML 时sandbox 永远不带
<code>allow-same-origin</code>且不提供任何「放开同源」的设置开关Open WebUI 的反面教训);
② 不可信 HTML 永远不直接进宿主 DOM不进 Markdown.vue、不用 v-html / dangerouslySetInnerHTML
</div>
<h2 id="s5">5. postMessage 交互桥</h2>
<p>协议形态对齐 MCP AppsJSON-RPC over postMessage方法集从最小白名单开始。宿主在 iframe
<code>load</code> 后用 <code>postMessage</code> 下发一次性 <b>实例 token</b>opaque origin 下
<code>event.origin</code> 恒为 <code>null</code>,不能用 origin 鉴别,必须 token + <code>event.source === iframe.contentWindow</code> 双校验)。</p>
<div class="tablewrap"><table>
<tr><th>方法artifact → 宿主)</th><th>语义</th><th>宿主行为</th></tr>
<tr><td><code>ui/ready</code></td><td>初始化完成</td><td>撤掉骨架占位</td></tr>
<tr><td><code>ui/height</code></td><td>内容高度变化</td><td>调整内联卡片 iframe 高度(上限 70vh超出内部滚动</td></tr>
<tr><td><code>ui/error</code></td><td>运行时错误artifact 内 window.onerror 转发)</td><td>外壳显示错误条 +「让模型修复」按钮</td></tr>
<tr><td><code>ui/open-link</code></td><td>请求打开外部链接</td><td>宿主确认弹层后 window.open绝不在 iframe 内导航</td></tr>
<tr><td><code>ui/follow-up</code></td><td>请求向对话追加一条消息(如「基于这个原型改 X」</td><td>填入 Composer 草稿,<b>由用户按发送</b>——iframe 永远不能直接驱动 agent</td></tr>
</table></div>
<p>桥以一段宿主注入的 &lt;script&gt;srcdoc 注入 / 预览路由响应改写 &lt;head&gt;)暴露
<code>window.kimi.{notifyHeight, reportError, openLink, followUp}</code>,模型的 HTML 生成规范同步增加这几个可选 API 的说明。
将来支持 MCP Apps<code>ui://</code> 资源)时,这条桥直接换成完整 MCP JSON-RPC 方言,外壳与安全模型不变。</p>
<h2 id="s6">6. 安全模型</h2>
<div class="note"><b>前置依赖daemon 启动 token。</b>daemon 生成随机 tokenweb URL 以
<code>#token</code> 携带,前端对 API 请求加 headerdaemon 校验并对预览路由豁免CSP 已默认拒绝、且其内容本身来自工作区)。
这同时关闭了与本功能无关的存量攻击面(任何本机网页可直接调用无鉴权的 127.0.0.1:7878</div>
<div class="tablewrap"><table>
<tr><th>威胁</th><th>后果</th><th>对策</th></tr>
<tr><td>提示注入控制的 HTML 调用 daemon API</td><td>读写任意文件 / 开会话执行命令(等价 RCE</td><td>sandbox 无 allow-same-originopaque origin+ <code>connect-src 'none'</code> + daemon token 三层</td></tr>
<tr><td>HTML 外联泄漏对话/工作区内容</td><td>数据经 img/fetch/表单发往外部域名</td><td>CSP default-src 'none'img 仅 self/data/blob、form-action 'none'、connect 'none'CDN 走显式 allowlist</td></tr>
<tr><td>伪造宿主 UI假登录框/假确认弹窗)</td><td>用户误信 artifact 内容为原生界面</td><td>外壳常显「沙箱预览」标识与来源路径;禁全屏 API外链必经宿主确认</td></tr>
<tr><td>postMessage 伪造</td><td>其他 frame / 页面冒充 artifact 触发宿主动作</td><td>实例 token + event.source 校验 + zod 校验消息 schema + follow-up 永远停在草稿</td></tr>
<tr><td>资源滥用(巨型 HTML、死循环、一轮 N 个 artifact</td><td>页面卡死、内存膨胀</td><td>大小上限(如 2 MB超出仅源码模式、点击才实例化、同屏最多 1 个活动 iframe分屏内、lazy + 离屏卸载</td></tr>
<tr><td>预览路由路径穿越</td><td>读出工作区之外的文件</td><td>复用 fs 路由既有的根目录约束与规范化(实现时验证用例:<code>../</code>、symlink、绝对路径</td></tr>
</table></div>
<h2 id="s7">7. 模块改动清单</h2>
<div class="tablewrap"><table>
<tr><th>位置</th><th>改动</th></tr>
<tr><td><code>packages/daemon/src/routes/fs.ts</code></td><td>新增 <code>:preview</code> GET 路由CSP/安全头 + 注入桥脚本daemon 启动 token 中间件(独立小改动)</td></tr>
<tr><td><code>apps/kimi-web/src/types.ts</code></td><td><code>TurnBlock</code> 增加 <code>{ kind: 'artifact'; artifact: ArtifactView }</code><code>ArtifactView = { source: 'file' | 'inline' | 'url'; title; path?; html?; url?; size }</code></td></tr>
<tr><td><code>apps/kimi-web/src/composables/messagesToTurns.ts</code></td><td>派生 artifact 块html 写盘工具 → file artifact≥30 行 ```html 代码块 → inline artifact保持原始顺序不 hoist</td></tr>
<tr><td><code>apps/kimi-web/src/components/ArtifactFrame.vue</code>(新)</td><td>统一沙箱 iframe三种投递、骨架/错误态、postMessage 桥、高度管理</td></tr>
<tr><td><code>ArtifactBlock.vue</code>(新)/ <code>ChatPane.vue</code></td><td>聊天卡片外壳ChatPane 渲染第四种 block</td></tr>
<tr><td><code>ConversationPane.vue</code></td><td>右侧分屏 artifact 舞台(与 files 分栏布局共享);移动端全屏 sheet</td></tr>
<tr><td><code>FilePreview.vue</code></td><td><code>html</code> 类型:预览 | 源码 tab复用 ArtifactFrame</td></tr>
<tr><td><code>TasksPane.vue</code> / 任务卡片</td><td>带端口的后台任务显示「预览」入口URL artifact</td></tr>
<tr><td>HTML 生成规范(用户侧 CLAUDE.md / 系统提示)</td><td>补充沙箱约束说明(无 localStorage、无外联、可选 window.kimi 桥 API</td></tr>
</table></div>
<h2 id="s8">8. 关键边界与降级</h2>
<ul>
<li><b>流式</b>:代码块未闭合 / 文件正在连续 edit 时显示骨架settle 后渲染;刷新按钮手动重载。</li>
<li><b>大文件</b>&gt;2 MB 仅提供源码 + 新窗口打开(新窗口走预览路由不受限)。</li>
<li><b>opaque origin 的 API 限制</b>localStorage/sessionStorage 抛异常、无 cookie——写进生成规范让模型用内存状态。</li>
<li><b>历史会话</b>:派生逻辑对历史消息同样生效,旧会话里的 HTML 工具调用自动获得预览入口。</li>
<li><b>主题</b>iframe 内容不继承应用暗色主题;外壳提示由内容自管(自包含 HTML 规范已要求移动端可读,同理可建议支持 prefers-color-scheme</li>
<li><b>嵌入被拒URL artifact</b>onload 探测失败 → 新标签页按钮降级。</li>
</ul>
<h2 id="s9">9. 调研依据(两份输入报告的来源汇总)</h2>
<ul style="font-size:13.5px">
<li>Claude Artifacts 沙箱与 CSP 行为simonwillison.net/tags/claude-artifactssupport.claude.comartifacts 帮助页)</li>
<li>Open WebUIiframe srcdoc + sandbox="allow-scripts" + IFRAME_CSPcommit 3bba1c2其「同源放行」开关为反面教训</li>
<li>LibreChatSandpack 预览 + 自托管 bundler + static-browser-server每预览独立 origin——多文件浏览器内打包路线的成本参照</li>
<li>bolt.new / StackBlitz WebContainers浏览器内 Node 运行时;本地 daemon 架构下由真实 dev server 替代</li>
<li>v0 Sandbox每会话隔离 VM 的 full app preview 形态参照</li>
<li>MCP Apps官方扩展/ mcp-uisandboxed iframe + JSON-RPC over postMessage本设计交互桥的协议对齐目标</li>
<li>OpenAI Apps SDKmodel-visible 内容与组件私有数据_meta分层的设计参考</li>
<li>MDNiframe sandbox、CSP、postMessage、srcdoc 语义</li>
</ul>
</main>
<button id="tocbtn">目录</button>
<nav id="toc">
<a href="#s1">1. 范围</a>
<a href="#s2">2. 产品形态</a>
<a href="#s3">3. 架构与数据流</a>
<a href="#s4">4. 渲染机制</a>
<a href="#s5">5. postMessage 交互桥</a>
<a href="#s6">6. 安全模型</a>
<a href="#s7">7. 模块改动清单</a>
<a href="#s8">8. 边界与降级</a>
<a href="#s9">9. 调研依据</a>
</nav>
<script>
const btn = document.getElementById('tocbtn');
const toc = document.getElementById('toc');
btn.addEventListener('click', () => {
toc.style.display = toc.style.display === 'block' ? 'none' : 'block';
});
toc.addEventListener('click', () => { toc.style.display = 'none'; });
</script>
</body>
</html>