mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-20 22:54:26 +00:00
- 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
894 lines
30 KiB
HTML
894 lines
30 KiB
HTML
<!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 外壳和 iframe,daemon 负责保存 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<typeof artifactContentSchema>;</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><iframe
|
||
title="HTML artifact preview"
|
||
sandbox="allow-scripts"
|
||
referrerpolicy="no-referrer"
|
||
loading="lazy"
|
||
src="/api/v1/artifacts/art_123/versions/1:preview"
|
||
></iframe></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 和 CSP;source/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>
|