kimi-code/docs/dynamic-html-research.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

894 lines
30 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" />
<title>kimi-web Dynamic HTML 最终方案</title>
<style>
:root {
color-scheme: light;
--bg: #f7f8fb;
--panel: #ffffff;
--panel-soft: #f1f5f9;
--ink: #17202a;
--muted: #5f6f84;
--line: #d8e0ea;
--accent: #0f766e;
--blue: #2563eb;
--green: #047857;
--amber: #b45309;
--red: #b91c1c;
--code: #0f172a;
--radius: 8px;
--shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
line-height: 1.62;
font-size: 15px;
}
a { color: #0f5f8f; text-decoration: none; }
a:hover { text-decoration: underline; }
.page {
max-width: 1160px;
margin: 0 auto;
padding: 42px 22px 72px;
}
.eyebrow {
margin: 0 0 8px;
color: var(--accent);
font-size: 13px;
font-weight: 750;
}
h1 {
margin: 0;
max-width: 920px;
font-size: clamp(28px, 4vw, 44px);
line-height: 1.12;
letter-spacing: 0;
}
.lede {
max-width: 900px;
margin: 18px 0 0;
color: var(--muted);
font-size: 17px;
}
.meta-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 22px 0 0;
}
.pill {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 4px 10px;
border: 1px solid var(--line);
border-radius: 999px;
background: #fff;
color: var(--muted);
font-size: 13px;
}
.toc {
position: fixed;
right: 18px;
top: 18px;
z-index: 10;
width: 220px;
padding: 10px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.96);
box-shadow: var(--shadow);
backdrop-filter: blur(10px);
}
.toc strong {
display: block;
margin: 0 0 6px;
color: var(--muted);
font-size: 12px;
}
.toc a {
display: block;
padding: 5px 6px;
border-radius: 6px;
color: var(--ink);
font-size: 13px;
}
.toc a:hover {
background: var(--panel-soft);
text-decoration: none;
}
section { margin-top: 34px; }
h2 {
margin: 0 0 14px;
font-size: 24px;
line-height: 1.25;
letter-spacing: 0;
}
h3 {
margin: 0 0 10px;
font-size: 18px;
letter-spacing: 0;
}
p { margin: 8px 0; }
.grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 14px;
}
.span-4 { grid-column: span 4; }
.span-6 { grid-column: span 6; }
.span-8 { grid-column: span 8; }
.span-12 { grid-column: span 12; }
.panel {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
box-shadow: var(--shadow);
}
.panel-body { padding: 18px; }
.verdict { border-left: 5px solid var(--accent); }
.verdict h2 { margin-bottom: 8px; }
.callout {
padding: 14px 16px;
border: 1px solid #c6dfdc;
border-radius: var(--radius);
background: #eefaf8;
color: #124640;
}
.risk {
border-color: #f4d2a3;
background: #fff8ec;
color: #653b07;
}
.danger {
border-color: #f0b7b7;
background: #fff1f1;
color: #751d1d;
}
.muted { color: var(--muted); }
.kpi {
display: grid;
grid-template-columns: 42px 1fr;
gap: 12px;
align-items: start;
}
.num {
display: inline-grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 8px;
background: #e6f4f1;
color: var(--accent);
font-weight: 800;
}
table {
width: 100%;
border-collapse: collapse;
overflow-wrap: anywhere;
}
th,
td {
padding: 11px 12px;
border-bottom: 1px solid var(--line);
vertical-align: top;
text-align: left;
}
th {
color: #2b3848;
background: #f6f8fb;
font-size: 13px;
}
tr:last-child td { border-bottom: 0; }
.tag {
display: inline-flex;
align-items: center;
min-height: 23px;
padding: 2px 8px;
border-radius: 999px;
background: #edf2f7;
color: #3b4a5d;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.tag.good { background: #e6f7ef; color: var(--green); }
.tag.warn { background: #fff1d6; color: var(--amber); }
.tag.bad { background: #ffe4e6; color: var(--red); }
.tag.blue { background: #e8f0ff; color: var(--blue); }
.flow {
width: 100%;
min-height: 230px;
padding: 14px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fbfcfe;
overflow-x: auto;
}
.flow svg {
display: block;
min-width: 920px;
width: 100%;
height: auto;
}
pre {
margin: 0;
padding: 14px;
border-radius: 8px;
background: var(--code);
color: #dbeafe;
overflow-x: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
line-height: 1.55;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.93em;
}
ul { padding-left: 22px; }
li + li { margin-top: 6px; }
.checklist {
display: grid;
gap: 10px;
}
.check {
display: grid;
grid-template-columns: 26px 1fr;
gap: 10px;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
}
.check strong:first-child {
display: inline-grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 999px;
background: #e6f7ef;
color: var(--green);
}
.source-list {
display: grid;
gap: 8px;
padding-left: 18px;
}
@media (max-width: 1100px) {
.toc {
position: static;
width: auto;
margin: 24px 0 0;
box-shadow: none;
}
.toc a { display: inline-block; }
}
@media (max-width: 760px) {
.page { padding: 28px 14px 48px; }
.grid { grid-template-columns: 1fr; }
.span-4,
.span-6,
.span-8,
.span-12 { grid-column: 1; }
th,
td { padding: 9px; }
table { font-size: 13px; }
}
</style>
</head>
<body>
<nav class="toc" aria-label="页面目录">
<strong>快速跳转</strong>
<a href="#verdict">结论</a>
<a href="#experience">最终体验</a>
<a href="#architecture">系统形态</a>
<a href="#contract">数据契约</a>
<a href="#security">安全边界</a>
<a href="#placement">代码落点</a>
<a href="#decisions">取舍结论</a>
<a href="#sources">资料来源</a>
</nav>
<main class="page">
<p class="eyebrow">Dynamic HTML / Interactive Artifact</p>
<h1>kimi-web Dynamic HTML 最终方案</h1>
<p class="lede">
最终形态是一个受控的 HTML artifact 系统:模型或工具产出的可交互 HTML 不直接混进 Markdown也不直接同源打开
而是以 artifact 卡片进入对话,以受限 iframe 预览,以明确的数据契约保存、回放、下载和继续修改。
</p>
<div class="meta-row">
<span class="pill">日期2026-06-11</span>
<span class="pill">仓库:/Users/moonshot/code/kimi-code-web</span>
<span class="pill">范围apps/kimi-web + protocol + daemon</span>
</div>
<section id="verdict" class="panel verdict">
<div class="panel-body">
<h2>结论</h2>
<p>
最终方案采用 <strong>HTML artifact + sandboxed iframe</strong>。HTML 是正式内容类型,有独立 ID、标题、版本、源码、预览入口和下载入口
前端只负责展示 artifact 外壳和 iframedaemon 负责保存 artifact、返回 HTML、加安全响应头。
</p>
<div class="grid">
<div class="span-4">
<div class="kpi">
<span class="num">1</span>
<div>
<h3>不是 Markdown 增强</h3>
<p class="muted">`Markdown.vue` 继续只渲染文本和代码块;动态 HTML 走单独的 artifact block。</p>
</div>
</div>
</div>
<div class="span-4">
<div class="kpi">
<span class="num">2</span>
<div>
<h3>不是同源页面</h3>
<p class="muted">iframe 默认只开 `allow-scripts`,不加 `allow-same-origin`,不让 HTML 拿到 daemon API、cookie 或宿主 DOM。</p>
</div>
</div>
</div>
<div class="span-4">
<div class="kpi">
<span class="num">3</span>
<div>
<h3>不是浏览器 VM</h3>
<p class="muted">WebContainers / Sandpack 解决的是“浏览器里跑项目”。kimi-web 有本地 daemon完整项目预览应走真实 dev server。</p>
</div>
</div>
</div>
</div>
</div>
</section>
<section id="experience">
<h2>最终体验</h2>
<div class="grid">
<div class="span-6 panel">
<div class="panel-body">
<h3>聊天里的 artifact 卡片</h3>
<p>
当模型生成一个 HTML 页面、报告、原型或临时工具时,聊天流里出现一张 artifact 卡片。
卡片展示标题、类型、大小、版本和操作按钮:预览、源码、下载、继续修改。
</p>
<p class="callout">
卡片是对话内容的一部分,所以历史会话回放时仍能看到同一版本,而不是重新从 Markdown 里猜一段 HTML。
</p>
</div>
</div>
<div class="span-6 panel">
<div class="panel-body">
<h3>右侧或弹层预览</h3>
<p>
点开预览后HTML 在专门的 iframe 区域运行。用户可以切换预览和源码,也可以下载完整 HTML。
预览区始终显示 artifact 外壳,避免用户误以为 iframe 内页面就是 kimi-web 原生 UI。
</p>
<p class="callout risk">
iframe 内页面可以有按钮、筛选器、图表和本地状态,但不能直接调用 daemon、访问工作区文件或联网外传数据。
</p>
</div>
</div>
</div>
<div class="panel" style="margin-top: 14px;">
<table>
<thead>
<tr>
<th>入口</th>
<th>用户看到什么</th>
<th>系统怎么处理</th>
</tr>
</thead>
<tbody>
<tr>
<td>模型直接生成 HTML artifact</td>
<td>聊天流出现 artifact 卡片,可预览、看源码、下载、继续修改。</td>
<td>消息 content 里保存 artifact 引用;源码存入 artifact store。</td>
</tr>
<tr>
<td>工具写入 `.html` 文件</td>
<td>工具卡片提供“打开预览”files 面板也能预览。</td>
<td>把文件快照提升为 artifact避免直接 iframe 同源下载路由。</td>
</tr>
<tr>
<td>用户打开已有 HTML 文件</td>
<td>文件面板显示“预览 / 源码”切换。</td>
<td>读取文件内容后创建临时 artifact preview仍按同一 sandbox 策略渲染。</td>
</tr>
<tr>
<td>真实 dev server</td>
<td>聊天或任务面板出现 localhost 预览卡。</td>
<td>这是 app preview不混入 HTML artifact被页面拒绝嵌入时降级为新窗口打开。</td>
</tr>
</tbody>
</table>
</div>
</section>
<section id="architecture">
<h2>系统形态</h2>
<div class="flow" aria-label="HTML artifact 系统架构图">
<svg viewBox="0 0 1040 255" role="img" aria-label="Kimi Web dynamic HTML architecture">
<defs>
<marker id="arrow" markerWidth="9" markerHeight="9" refX="8" refY="4.5" orient="auto">
<path d="M0,0 L9,4.5 L0,9 z" fill="#64748b"></path>
</marker>
</defs>
<rect x="24" y="34" width="150" height="66" rx="8" fill="#e8f6f3" stroke="#8ac5bd"></rect>
<text x="99" y="61" text-anchor="middle" font-size="14" font-weight="700" fill="#134e48">Agent / Tool</text>
<text x="99" y="82" text-anchor="middle" font-size="12" fill="#46615d">产出 HTML</text>
<line x1="174" y1="67" x2="260" y2="67" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"></line>
<rect x="260" y="34" width="170" height="66" rx="8" fill="#eef2ff" stroke="#a5b4fc"></rect>
<text x="345" y="61" text-anchor="middle" font-size="14" font-weight="700" fill="#27357c">Protocol</text>
<text x="345" y="82" text-anchor="middle" font-size="12" fill="#485a9a">artifact content</text>
<line x1="430" y1="67" x2="516" y2="67" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"></line>
<rect x="516" y="34" width="178" height="66" rx="8" fill="#fff7ed" stroke="#fdba74"></rect>
<text x="605" y="61" text-anchor="middle" font-size="14" font-weight="700" fill="#8a3f0a">Daemon</text>
<text x="605" y="82" text-anchor="middle" font-size="12" fill="#8a5c2d">store + secure route</text>
<line x1="694" y1="67" x2="782" y2="67" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"></line>
<rect x="782" y="34" width="190" height="66" rx="8" fill="#f0f9ff" stroke="#93c5fd"></rect>
<text x="877" y="61" text-anchor="middle" font-size="14" font-weight="700" fill="#155e75">Kimi Web</text>
<text x="877" y="82" text-anchor="middle" font-size="12" fill="#3d7187">ArtifactBlock + iframe</text>
<rect x="122" y="155" width="180" height="56" rx="8" fill="#ffffff" stroke="#d8e0ea"></rect>
<text x="212" y="178" text-anchor="middle" font-size="13" font-weight="700" fill="#17202a">HTML source</text>
<text x="212" y="196" text-anchor="middle" font-size="12" fill="#5f6f84">immutable per version</text>
<rect x="430" y="155" width="180" height="56" rx="8" fill="#ffffff" stroke="#d8e0ea"></rect>
<text x="520" y="178" text-anchor="middle" font-size="13" font-weight="700" fill="#17202a">CSP + sandbox</text>
<text x="520" y="196" text-anchor="middle" font-size="12" fill="#5f6f84">default deny</text>
<rect x="738" y="155" width="180" height="56" rx="8" fill="#ffffff" stroke="#d8e0ea"></rect>
<text x="828" y="178" text-anchor="middle" font-size="13" font-weight="700" fill="#17202a">Bridge</text>
<text x="828" y="196" text-anchor="middle" font-size="12" fill="#5f6f84">resize / error / intent</text>
<line x1="212" y1="155" x2="312" y2="105" stroke="#94a3b8" stroke-width="2" marker-end="url(#arrow)"></line>
<line x1="520" y1="155" x2="590" y2="105" stroke="#94a3b8" stroke-width="2" marker-end="url(#arrow)"></line>
<line x1="828" y1="155" x2="868" y2="105" stroke="#94a3b8" stroke-width="2" marker-end="url(#arrow)"></line>
</svg>
</div>
<div class="grid" style="margin-top: 14px;">
<div class="span-6 panel">
<div class="panel-body">
<h3>核心原则</h3>
<ul>
<li>HTML artifact 是对话内容,不是 Markdown 的附属渲染。</li>
<li>HTML 源码按版本不可变保存,用户继续修改时生成新版本。</li>
<li>iframe 是唯一执行边界,宿主和 daemon 不信任 iframe 内任何数据。</li>
<li>bridge 只传递少量白名单消息,例如高度、加载错误、用户请求继续修改。</li>
</ul>
</div>
</div>
<div class="span-6 panel">
<div class="panel-body">
<h3>不做的事情</h3>
<ul>
<li>不使用 `dangerouslySetInnerHTML` 或 `v-html` 执行模型 HTML。</li>
<li>不让 iframe 直接访问 `/api/v1`、`fs:download`、cookies 或 localStorage。</li>
<li>不默认开放任意外部 CDN、fetch、表单提交和弹窗。</li>
<li>不把 WebContainers 当作 HTML artifact 的基础设施。</li>
</ul>
</div>
</div>
</div>
</section>
<section id="contract">
<h2>数据契约</h2>
<div class="grid">
<div class="span-6 panel">
<div class="panel-body">
<h3>消息内容类型</h3>
<pre><code>// packages/protocol/src/message.ts
export const artifactContentSchema = z.object({
type: z.literal('artifact'),
artifact_id: z.string().min(1),
artifact_type: z.literal('html'),
title: z.string().min(1),
version: z.number().int().positive(),
mime_type: z.literal('text/html'),
size: z.number().int().nonnegative(),
preview_height: z.number().int().positive().optional(),
});
export type ArtifactContent =
z.infer&lt;typeof artifactContentSchema&gt;;</code></pre>
</div>
</div>
<div class="span-6 panel">
<div class="panel-body">
<h3>Web 层类型</h3>
<pre><code>// apps/kimi-web/src/api/types.ts
export interface AppArtifactContent {
type: 'artifact';
artifactId: string;
artifactType: 'html';
title: string;
version: number;
mimeType: 'text/html';
size: number;
previewHeight?: number;
}</code></pre>
</div>
</div>
</div>
<div class="panel" style="margin-top: 14px;">
<table>
<thead>
<tr>
<th>字段</th>
<th>作用</th>
<th>约束</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>artifact_id</code></td>
<td>稳定标识一个 artifact。</td>
<td>只用于查 metadata 和版本,不直接暴露文件路径。</td>
</tr>
<tr>
<td><code>version</code></td>
<td>指向某次不可变源码快照。</td>
<td>继续修改生成新版本,历史消息仍指向旧版本。</td>
</tr>
<tr>
<td><code>artifact_type</code></td>
<td>区分 HTML、后续可能的 SVG 或 app preview。</td>
<td>本方案只定义 <code>html</code></td>
</tr>
<tr>
<td><code>preview_height</code></td>
<td>给聊天卡片一个稳定高度。</td>
<td>只能作为建议值iframe 仍要按上限约束。</td>
</tr>
</tbody>
</table>
</div>
</section>
<section id="security">
<h2>安全边界</h2>
<div class="grid">
<div class="span-6 panel">
<div class="panel-body">
<h3>iframe 默认写法</h3>
<pre><code>&lt;iframe
title="HTML artifact preview"
sandbox="allow-scripts"
referrerpolicy="no-referrer"
loading="lazy"
src="/api/v1/artifacts/art_123/versions/1:preview"
&gt;&lt;/iframe&gt;</code></pre>
<p class="callout">
不加 <code>allow-same-origin</code>。同源 iframe 如果同时有 <code>allow-scripts</code><code>allow-same-origin</code>
就可以绕开我们希望建立的隔离边界。
</p>
</div>
</div>
<div class="span-6 panel">
<div class="panel-body">
<h3>daemon 响应头</h3>
<pre><code>Content-Security-Policy:
default-src 'none';
script-src 'unsafe-inline';
style-src 'unsafe-inline';
img-src data: blob:;
font-src data:;
connect-src 'none';
frame-src 'none';
base-uri 'none';
form-action 'none'</code></pre>
<p class="callout risk">
<code>script-src 'unsafe-inline'</code> 是为了让 artifact 内部交互能运行;风险靠 iframe sandbox 和关闭网络来收住。
</p>
</div>
</div>
</div>
<div class="panel" style="margin-top: 14px;">
<table>
<thead>
<tr>
<th>风险</th>
<th>后果</th>
<th>最终处理方式</th>
</tr>
</thead>
<tbody>
<tr>
<td>HTML 与应用同源运行</td>
<td>模型 HTML 能 fetch daemon API读写文件或触发命令。</td>
<td>artifact route 只服务预览内容iframe 不加 <code>allow-same-origin</code>;不要直接 iframe `fs:download`。</td>
</tr>
<tr>
<td>联网外传数据</td>
<td>artifact 把用户输入、对话内容或本地信息发到外部服务。</td>
<td><code>connect-src 'none'</code>;图片默认只允许 <code>data:</code><code>blob:</code></td>
</tr>
<tr>
<td>伪造宿主 UI</td>
<td>用户误以为 iframe 内按钮属于 kimi-web。</td>
<td>artifact 外壳始终显示标题、来源、sandbox 标识;全屏和外链必须走宿主确认。</td>
</tr>
<tr>
<td>postMessage 被伪造</td>
<td>恶意 frame 向宿主发请求。</td>
<td>校验 <code>event.source</code>、schema、artifact instance token宿主只接受白名单消息。</td>
</tr>
<tr>
<td>daemon 本地 API 暴露</td>
<td>其他网页可能尝试访问本机 daemon。</td>
<td>daemon API 应补启动 token 和 Origin 校验;这不是 artifact 独有问题,但 artifact 功能会放大风险。</td>
</tr>
</tbody>
</table>
</div>
</section>
<section id="placement">
<h2>代码落点</h2>
<div class="panel">
<table>
<thead>
<tr>
<th>位置</th>
<th>职责</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>packages/protocol/src/message.ts</code></td>
<td>新增 <code>artifact</code> content schema。</td>
<td>让 REST、WS、持久化和 Web 都认同同一个消息形状。</td>
</tr>
<tr>
<td><code>packages/services</code></td>
<td>新增 artifact store。</td>
<td>保存源码、版本、标题、session 归属、size、hash、created_at。</td>
</tr>
<tr>
<td><code>packages/daemon/src/routes</code></td>
<td>新增 artifact metadata、preview、source、download 路由。</td>
<td>preview 路由返回 HTML 和 CSPsource/download 路由返回源码或文件下载。</td>
</tr>
<tr>
<td><code>apps/kimi-web/src/api/daemon/mappers.ts</code></td>
<td>wire artifact 转 app-facing camelCase。</td>
<td>保持 Vue 层不直接消费 snake_case wire DTO。</td>
</tr>
<tr>
<td><code>apps/kimi-web/src/types.ts</code></td>
<td><code>TurnBlock</code> 新增 artifact block。</td>
<td>保证一轮回复中的 text、tool、artifact 顺序不乱。</td>
</tr>
<tr>
<td><code>apps/kimi-web/src/components/ChatPane.vue</code></td>
<td>遇到 artifact block 渲染 artifact 卡片。</td>
<td>聊天层只负责布局和折叠逻辑。</td>
</tr>
<tr>
<td><code>apps/kimi-web/src/components/ArtifactBlock.vue</code></td>
<td>展示标题、版本、操作按钮和预览入口。</td>
<td>这是用户看到的 artifact 外壳。</td>
</tr>
<tr>
<td><code>apps/kimi-web/src/components/ArtifactIframe.vue</code></td>
<td>承载 iframe、resize、错误显示和 bridge。</td>
<td>所有 iframe 安全属性都集中在这里,避免散落到多个组件。</td>
</tr>
<tr>
<td><code>apps/kimi-web/src/components/FilePreview.vue</code></td>
<td>HTML 文件也用 artifact preview 组件。</td>
<td>不要在文件预览里另写一套 iframe 安全策略。</td>
</tr>
</tbody>
</table>
</div>
<div class="grid" style="margin-top: 14px;">
<div class="span-6 panel">
<div class="panel-body">
<h3>bridge 消息白名单</h3>
<pre><code>type ArtifactFrameMessage =
| { type: 'artifact.ready'; token: string }
| { type: 'artifact.resize'; token: string; height: number }
| { type: 'artifact.error'; token: string; message: string }
| { type: 'artifact.request_fix'; token: string; prompt: string };</code></pre>
</div>
</div>
<div class="span-6 panel">
<div class="panel-body">
<h3>iframe 高度约束</h3>
<pre><code>const MIN_PREVIEW_HEIGHT = 220;
const MAX_PREVIEW_HEIGHT = 720;
function clampPreviewHeight(height: number): number {
return Math.max(
MIN_PREVIEW_HEIGHT,
Math.min(MAX_PREVIEW_HEIGHT, Math.trunc(height)),
);
}</code></pre>
</div>
</div>
</div>
</section>
<section id="decisions">
<h2>取舍结论</h2>
<div class="panel">
<table>
<thead>
<tr>
<th>选项</th>
<th>结论</th>
<th>原因</th>
</tr>
</thead>
<tbody>
<tr>
<td>直接 DOM 插入 HTML</td>
<td><span class="tag bad">不用</span></td>
<td>样式、脚本、事件和宿主页面混在一起,支持交互后风险不可控。</td>
</tr>
<tr>
<td><code>iframe srcdoc</code></td>
<td><span class="tag warn">只作为内部实现细节</span></td>
<td>适合临时预览但缺版本、下载、回放、CSP header 和权限边界,不应作为产品数据模型。</td>
</tr>
<tr>
<td>artifact route + sandboxed iframe</td>
<td><span class="tag good">采用</span></td>
<td>能统一消息、文件预览、历史回放、安全头、源码下载和后续交互桥。</td>
</tr>
<tr>
<td>真实 dev server iframe</td>
<td><span class="tag blue">单独能力</span></td>
<td>适合完整应用预览,不和单文件 HTML artifact 混成一个模型。</td>
</tr>
<tr>
<td>Sandpack / WebContainers</td>
<td><span class="tag bad">不作为基础方案</span></td>
<td>本地 daemon 能跑真实进程,没必要用浏览器里的 Node runtime 作为第一基础设施。</td>
</tr>
</tbody>
</table>
</div>
<div class="panel" style="margin-top: 14px;">
<div class="panel-body">
<h3>最终实现清单</h3>
<div class="checklist">
<div class="check">
<strong></strong>
<div>定义 <code>artifact</code> message content并让 messages API、WS projector、mappers、event reducer 都能保留它。</div>
</div>
<div class="check">
<strong></strong>
<div>新增 artifact store 和 preview/source/download 路由preview 路由统一加 CSP。</div>
</div>
<div class="check">
<strong></strong>
<div>新增 <code>ArtifactBlock.vue</code><code>ArtifactIframe.vue</code>,聊天和文件预览复用同一套 iframe 组件。</div>
</div>
<div class="check">
<strong></strong>
<div>bridge 只支持 ready、resize、error、request_fix任何执行 agent、写文件、打开外链的动作都回到宿主确认。</div>
</div>
<div class="check">
<strong></strong>
<div>daemon API 增加启动 token / Origin 校验,避免本机其他网页直接打 daemon。</div>
</div>
</div>
</div>
</div>
</section>
<section id="products">
<h2>同类产品参考</h2>
<div class="panel">
<table>
<thead>
<tr>
<th>产品 / 技术</th>
<th>参考价值</th>
<th>我们采用什么</th>
</tr>
</thead>
<tbody>
<tr>
<td>Claude Artifacts</td>
<td>artifact 卡片、右侧预览、版本、下载、继续修改。</td>
<td>采用 artifact 体验,但只开放我们能安全承载的 HTML 能力。</td>
</tr>
<tr>
<td>OpenAI Apps SDK</td>
<td>组件 iframe、结构化数据、只给组件看的私有 meta、bridge。</td>
<td>采用“宿主与 iframe 明确通信”的思路,不直接做公开 app SDK。</td>
</tr>
<tr>
<td>Open WebUI / LibreChat</td>
<td>srcdoc、独立 preview origin、Sandpack 的边界经验。</td>
<td>借鉴 iframe 隔离,但不默认依赖外部 CDN 或浏览器 bundler。</td>
</tr>
<tr>
<td>v0 / StackBlitz WebContainers</td>
<td>完整 app preview 的体验方向。</td>
<td>保留为 dev server preview 方向,不作为 HTML artifact 的核心实现。</td>
</tr>
</tbody>
</table>
</div>
</section>
<section id="sources">
<h2>资料来源</h2>
<ul class="source-list">
<li><a href="https://support.claude.com/en/articles/9487310-what-are-artifacts-and-how-do-i-use-them">Claude Help Center: What are artifacts and how do I use them?</a></li>
<li><a href="https://developers.openai.com/apps-sdk/reference/">OpenAI Apps SDK Reference</a></li>
<li><a href="https://v0.app/docs/sandbox">v0 Docs: Sandbox</a></li>
<li><a href="https://webcontainers.io/guides/quickstart">StackBlitz WebContainers Quickstart</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/iframe">MDN: iframe element</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage">MDN: Window.postMessage()</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP">MDN: Content Security Policy</a></li>
</ul>
</section>
</main>
</body>
</html>