#产物预览
Agent 生成了文件(报告、表格、网页、代码、PDF、Office 文档、整个项目),在回答里显示成文件卡片,点「预览」在右侧面板里打开。
产物预览是按需引入的功能:它不在默认入口里,不引入就不会进包。文件放在哪、怎么鉴权、怎么下载都由应用负责,chatkit 只调用应用给的 load 函数拿内容并显示。
import '@volcengine/chatkit/styles.css';
import '@volcengine/chatkit/artifacts.css';
import './artifacts-demo.css';
import { Chatkit, type ChatkitDataPartRenderer, useChatkitActions, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import {
type ArtifactItem,
ArtifactKind,
ArtifactList,
type ArtifactLoad,
ArtifactPreviewPanel,
type ArtifactPreviewSelection,
} from '@volcengine/chatkit/artifacts';
import { useState } from 'react';
import docxFixtureUrl from './fixtures/incident-review.docx?url';
import pdfFixtureUrl from './fixtures/quarterly-ops-report.pdf?url';
import pptxFixtureUrl from './fixtures/service-health.pptx?url';
import audioFixtureUrl from './fixtures/service-health-alert.wav?url';
const ARTIFACT_LIST_PART_KIND = 'demo.artifacts';
const PROJECT_FILES = [
{ path: 'README.md', name: 'README.md', type: 'file' as const, size: 1240, mimeType: 'text/markdown' },
{ path: 'package.json', name: 'package.json', type: 'file' as const, size: 486, mimeType: 'application/json' },
{ path: 'src/index.ts', name: 'index.ts', type: 'file' as const, size: 312, mimeType: 'text/typescript' },
{ path: 'src/config.ts', name: 'config.ts', type: 'file' as const, size: 654, mimeType: 'text/typescript' },
{ path: 'src/config.xml', name: 'config.xml', type: 'file' as const, size: 194, mimeType: 'application/xml' },
{ path: 'src/settings', name: 'settings', type: 'file' as const, size: 116, mimeType: 'application/json' },
{ path: 'src/domain/metric.ts', name: 'metric.ts', type: 'file' as const, size: 890, mimeType: 'text/typescript' },
{ path: 'src/domain/summary.ts', name: 'summary.ts', type: 'file' as const, size: 1420, mimeType: 'text/typescript' },
{
path: 'src/services/collector.ts',
name: 'collector.ts',
type: 'file' as const,
size: 1960,
mimeType: 'text/typescript',
},
{
path: 'src/services/formatter.ts',
name: 'formatter.ts',
type: 'file' as const,
size: 1080,
mimeType: 'text/typescript',
},
{
path: 'tests/summary.test.ts',
name: 'summary.test.ts',
type: 'file' as const,
size: 972,
mimeType: 'text/typescript',
},
];
const PROJECT_CONTENT: Record<string, string> = {
'README.md': `# Service health dashboard\n\n一个用于演示静态代码项目预览的 TypeScript 示例。\n\n## 目录\n\n- \`src/domain\`:指标与汇总规则\n- \`src/services\`:数据采集和格式化\n- \`tests\`:纯函数用例\n\n## 设计约束\n\n项目预览只加载宿主提供的文件清单和源码,不会安装依赖、构建或执行代码。\n`,
'package.json':
'{\n "name": "service-health-dashboard",\n "private": true,\n "type": "module",\n "scripts": { "check": "tsc --noEmit", "test": "vitest run" }\n}\n',
'src/index.ts':
'export { collectMetrics } from "./services/collector";\nexport { summarizeMetrics } from "./domain/summary";\nexport type { Metric, MetricSummary } from "./domain/metric";\n',
'src/config.ts':
'export interface DashboardConfig {\n serviceName: string;\n windowMinutes: number;\n latencyWarningMs: number;\n}\n\nexport const defaultConfig: DashboardConfig = {\n serviceName: "gateway-api",\n windowMinutes: 15,\n latencyWarningMs: 500,\n};\n',
'src/config.xml':
'<service name="gateway-api">\n <window minutes="15" />\n <latency-warning ms="500" />\n</service>\n',
'src/settings': '{\n "service": "gateway-api",\n "windowMinutes": 15,\n "latencyWarningMs": 500\n}\n',
'src/domain/metric.ts':
'export interface Metric {\n service: string;\n requests: number;\n errors: number;\n latencyP95Ms: number;\n capturedAt: string;\n}\n\nexport interface MetricSummary {\n requestCount: number;\n errorRate: number;\n slowServices: string[];\n}\n',
'src/domain/summary.ts':
'import type { Metric, MetricSummary } from "./metric";\n\nexport function summarizeMetrics(metrics: Metric[], latencyWarningMs: number): MetricSummary {\n const requestCount = metrics.reduce((total, metric) => total + metric.requests, 0);\n const errors = metrics.reduce((total, metric) => total + metric.errors, 0);\n const errorRate = requestCount === 0 ? 0 : errors / requestCount;\n const slowServices = metrics\n .filter(metric => metric.latencyP95Ms > latencyWarningMs)\n .map(metric => metric.service);\n\n return { requestCount, errorRate, slowServices };\n}\n',
'src/services/collector.ts':
'import type { Metric } from "../domain/metric";\n\nexport async function collectMetrics(endpoint: string, signal?: AbortSignal): Promise<Metric[]> {\n const response = await fetch(endpoint, { signal, headers: { accept: "application/json" } });\n if (!response.ok) throw new Error(`Metrics request failed: ${response.status}`);\n\n const payload: unknown = await response.json();\n if (!Array.isArray(payload)) throw new TypeError("Expected a metric array");\n return payload as Metric[];\n}\n',
'src/services/formatter.ts':
'import type { MetricSummary } from "../domain/metric";\n\nexport function formatSummary(summary: MetricSummary): string {\n const errorPercent = (summary.errorRate * 100).toFixed(2);\n const slowServices = summary.slowServices.length ? summary.slowServices.join(", ") : "none";\n return `${summary.requestCount.toLocaleString()} requests · ${errorPercent}% errors · slow: ${slowServices}`;\n}\n',
'tests/summary.test.ts':
'import { describe, expect, it } from "vitest";\nimport { summarizeMetrics } from "../src/domain/summary";\n\ndescribe("summarizeMetrics", () => {\n it("handles an empty window", () => {\n expect(summarizeMetrics([], 500)).toEqual({ requestCount: 0, errorRate: 0, slowServices: [] });\n });\n\n it("reports slow services and error rate", () => {\n const metrics = [{ service: "search", requests: 100, errors: 2, latencyP95Ms: 640, capturedAt: "2026-09-30T10:00:00Z" }];\n expect(summarizeMetrics(metrics, 500).slowServices).toEqual(["search"]);\n expect(summarizeMetrics(metrics, 500).errorRate).toBe(0.02);\n });\n});\n',
};
const ARTIFACTS: ArtifactItem[] = [
{
id: 'report',
name: 'quarterly-service-review.md',
kind: ArtifactKind.Markdown,
description: '复杂 Markdown:GFM、公式、任务清单、安全 HTML、可操作代码块和多语言高亮',
},
{ id: 'table', name: 'service-metrics-q3.csv', kind: ArtifactKind.Csv, description: '12 周、多个服务维度的指标表' },
{
id: 'page',
name: 'operations-dashboard.html',
kind: ArtifactKind.Html,
description: '带指标卡、趋势图和事件时间线的隔离页面',
},
{
id: 'source',
name: 'metrics-pipeline.ts',
kind: ArtifactKind.Code,
description: '带类型、校验、聚合和错误处理的只读源码',
},
{
id: 'audio',
name: 'service-health-alert.wav',
kind: ArtifactKind.Audio,
description: '本地合成的状态提示音,可测试播放和进度控制',
},
{
id: 'video',
name: 'service-health-overview.webm',
kind: ArtifactKind.Video,
description: '本地生成的仪表盘短片,可测试视频播放控件',
},
{
id: 'pdf',
name: 'quarterly-ops-report.pdf',
kind: ArtifactKind.Pdf,
description: '含指标图表和运营结论的多页 PDF',
},
{
id: 'docx',
name: 'incident-review.docx',
kind: ArtifactKind.Docx,
description: '包含事件复盘、时间线和改进行动的 Word 文档',
},
{
id: 'pptx',
name: 'service-health.pptx',
kind: ArtifactKind.Pptx,
description: '服务健康度、容量趋势与优化计划幻灯片',
},
{
id: 'project',
name: 'sample-project',
kind: ArtifactKind.Project,
description: '静态文件树与源码浏览',
projectFiles: PROJECT_FILES,
},
];
const TEXT_CONTENT: Record<string, string> = {
report: `# Q3 服务运行分析报告\n\n> 报告周期:2026-07-01 至 2026-09-30 · 数据来源:API 网关、检索服务与任务平台\n\n## 执行摘要\n\n本季度平台处理 **148.2 万次请求**,较上季度增长 **12.8%**。整体成功率为 **99.94%**,核心链路保持稳定。文件解析服务的 P95 延迟为 612 ms,高于 500 ms 的内部目标,是本季度最需要跟进的性能指标。\n\n| 指标 | 本季度 | 上季度 | 变化 | 状态 |\n| --- | ---: | ---: | ---: | --- |\n| 请求量 | 1,482,000 | 1,313,900 | +12.8% | 正常 |\n| 成功率 | 99.94% | 99.91% | +0.03 pp | 正常 |\n| P95 延迟 | 428 ms | 471 ms | -9.1% | 改善 |\n| 文件解析 P95 | 612 ms | 648 ms | -5.6% | 关注 |\n| 单位请求成本 | ¥0.056 | ¥0.060 | -6.2% | 改善 |\n\n## 主要发现\n\n### 1. 流量增长未带来错误率上升\n\n9 月请求量达到季度峰值,扩容后的模型网关和检索缓存共同吸收了新增负载。错误率连续 7 周低于 0.1%。\n\n### 2. 文件解析尾延迟仍需优化\n\n大体积 Office 文件的解析任务会与普通文本任务竞争 worker。当前观测到的 P95 为 612 ms,P99 为 1.8 s;超时主要集中在超过 20 MB 的文件。\n\n### 3. 成本优化已见效\n\n缓存命中率提升至 73%,相同请求量下单位推理成本下降 6.2%。对低频知识库请求的缓存 TTL 仍有调整空间。\n\n## 下季度行动项\n\n- [ ] 将文件解析任务按文件大小分级,并为大文件使用独立 worker 队列(Owner:文档平台,10 月 18 日)\n- [ ] 为知识库低频查询增加分层缓存,目标命中率达到 78%(Owner:检索平台,10 月 25 日)\n- [ ] 补齐任务取消、重试和超时告警看板(Owner:SRE,11 月 1 日)\n\n## 口径说明\n\n成功率按成功响应数 / 总响应数计算;P95 以服务端请求耗时统计。成本按本季度实际账单折算,不包含一次性迁移费用。\n\n\n## 错误率计算示例\n\n报告中的错误率按请求数加权计算,单个服务的错误率写作 $\\mathrm{errorRate} = \\frac{\\mathrm{errors}}{\\mathrm{requests}}$。多个服务汇总时,先求错误总数与请求总数,再计算整体比例:
$$
\\mathrm{overallErrorRate} = \\frac{\\sum_{i=1}^{n} \\mathrm{errors}_i}{\\sum_{i=1}^{n} \\mathrm{requests}_i}
$$\n\n\`\`\`ts\nconst errorRate = totalRequests === 0 ? 0 : totalErrors / totalRequests;\n\`\`\`\n\n选中报告中的文本可触发宿主的选择回调。`,
table: `周次,对话 API 请求,检索请求,成功率,错误数,P95 延迟 ms,缓存命中率,成本 ¥\nW28,42800,18200,99.91%,39,482,68.2%,2410\nW29,44100,19050,99.93%,31,468,69.0%,2488\nW30,45200,19420,99.94%,27,461,69.8%,2520\nW31,46100,20120,99.92%,37,474,70.1%,2574\nW32,47800,21300,99.95%,24,452,70.9%,2631\nW33,49300,21850,99.96%,20,447,71.4%,2690\nW34,50100,22400,99.94%,30,458,72.0%,2735\nW35,51600,23100,99.95%,25,449,72.5%,2812\nW36,52800,23800,99.97%,16,431,73.1%,2854\nW37,54100,24420,99.95%,27,438,73.5%,2922\nW38,56300,25100,99.94%,34,446,73.8%,3028\nW39,58400,26300,99.96%,23,428,74.2%,3096`,
page: `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>平台运行总览</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; padding: 28px; background: #f4f7fb; color: #172b4d; font: 14px/1.5 system-ui, sans-serif; }
main { max-width: 1040px; margin: auto; }
header { display: flex; justify-content: space-between; align-items: flex-start; gap: 24px; margin-bottom: 22px; }
h1, h2, p { margin: 0; }
h1 { font-size: 25px; letter-spacing: -.03em; }
.muted { margin-top: 5px; color: #66758c; }
.badge { padding: 6px 10px; color: #15734b; background: #e1f5eb; border-radius: 999px; font-weight: 650; white-space: nowrap; }
.grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.card { padding: 17px; background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; box-shadow: 0 2px 8px #1d35570a; }
.label { color: #66758c; font-size: 12px; }
.value { margin-top: 8px; font-size: 25px; font-weight: 700; letter-spacing: -.03em; }
.delta { margin-top: 4px; color: #15734b; font-size: 12px; }
.section { margin-top: 17px; }
h2 { margin-bottom: 12px; font-size: 15px; }
.chart { display: flex; height: 150px; align-items: end; gap: 10px; padding: 12px 8px 0; border-bottom: 1px solid #e3e9f0; }
.bar-group { display: flex; height: 100%; flex: 1; align-items: end; justify-content: center; gap: 4px; }
.bar { width: min(24px, 34%); min-height: 5px; background: #4f7df3; border-radius: 5px 5px 0 0; }
.bar.secondary { background: #9fb6f8; }
.axis { display: flex; justify-content: space-around; margin-top: 7px; color: #718096; font-size: 11px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 11px 9px; border-bottom: 1px solid #edf0f5; text-align: left; }
th { color: #66758c; font-size: 11px; font-weight: 600; }
.status { color: #15734b; font-weight: 600; }
.status.warn { color: #a86700; }
.timeline { display: grid; grid-template-columns: 72px 1fr; gap: 10px 14px; color: #53637a; }
.time { color: #8794a8; font-variant-numeric: tabular-nums; }
@media (max-width: 760px) { body { padding: 16px; } .grid { grid-template-columns: repeat(2, 1fr); } header { flex-direction: column; } }
</style>
</head>
<body>
<main>
<header><div><h1>平台运行总览</h1><p class="muted">生产环境 · 最近 7 天 · 更新时间 10:24</p></div><span class="badge">● 所有系统正常</span></header>
<section class="grid" aria-label="关键指标">
<article class="card"><div class="label">请求总量</div><div class="value">42.8 万</div><div class="delta">↗ 12.8% 对比上周</div></article>
<article class="card"><div class="label">成功率</div><div class="value">99.96%</div><div class="delta">↗ 0.04 个百分点</div></article>
<article class="card"><div class="label">P95 响应时间</div><div class="value">428 ms</div><div class="delta">↘ 9.1% 对比上周</div></article>
<article class="card"><div class="label">缓存命中率</div><div class="value">74.2%</div><div class="delta">↗ 3.6 个百分点</div></article>
</section>
<section class="card section"><h2>每周请求量趋势</h2><div class="chart" role="img" aria-label="W33 至 W39 请求量持续上升">
<div class="bar-group"><i class="bar" style="height:46%"></i><i class="bar secondary" style="height:31%"></i></div>
<div class="bar-group"><i class="bar" style="height:52%"></i><i class="bar secondary" style="height:35%"></i></div>
<div class="bar-group"><i class="bar" style="height:59%"></i><i class="bar secondary" style="height:39%"></i></div>
<div class="bar-group"><i class="bar" style="height:67%"></i><i class="bar secondary" style="height:44%"></i></div>
<div class="bar-group"><i class="bar" style="height:73%"></i><i class="bar secondary" style="height:50%"></i></div>
<div class="bar-group"><i class="bar" style="height:81%"></i><i class="bar secondary" style="height:56%"></i></div>
<div class="bar-group"><i class="bar" style="height:92%"></i><i class="bar secondary" style="height:63%"></i></div>
</div><div class="axis"><span>W33</span><span>W34</span><span>W35</span><span>W36</span><span>W37</span><span>W38</span><span>W39</span></div></section>
<section class="card section"><h2>服务质量</h2><table><thead><tr><th>服务</th><th>请求量</th><th>成功率</th><th>P95</th><th>状态</th></tr></thead><tbody>
<tr><td>对话 API</td><td>218,400</td><td>99.97%</td><td>428 ms</td><td class="status">正常</td></tr>
<tr><td>检索服务</td><td>102,300</td><td>99.91%</td><td>362 ms</td><td class="status">正常</td></tr>
<tr><td>模型网关</td><td>81,200</td><td>99.95%</td><td>486 ms</td><td class="status">正常</td></tr>
<tr><td>文件解析</td><td>26,100</td><td>99.72%</td><td>612 ms</td><td class="status warn">关注</td></tr>
</tbody></table></section>
<section class="card section"><h2>最近事件</h2><div class="timeline">
<span class="time">09:42</span><span>检索缓存命中率达到 74%,高于本周目标。</span>
<span class="time">昨天 16:10</span><span>文件解析队列延迟短时升高,扩容后恢复。</span>
<span class="time">周二 11:35</span><span>完成模型网关灰度升级,未观察到错误率回归。</span>
</div></section>
</main>
</body>
</html>`,
source: `import type { Metric } from "./domain/metric";
interface MetricPayload {
service: unknown;
requests: unknown;
errors: unknown;
latencyP95Ms: unknown;
capturedAt: unknown;
}
export function parseMetric(payload: MetricPayload): Metric {
if (typeof payload.service !== "string" || payload.service.length === 0) {
throw new TypeError("Metric service must be a non-empty string");
}
if (typeof payload.requests !== "number" || payload.requests < 0) {
throw new TypeError("Metric requests must be a non-negative number");
}
if (typeof payload.errors !== "number" || payload.errors < 0 || payload.errors > payload.requests) {
throw new TypeError("Metric errors must be between zero and requests");
}
if (typeof payload.latencyP95Ms !== "number" || payload.latencyP95Ms < 0) {
throw new TypeError("Metric latency must be a non-negative number");
}
if (typeof payload.capturedAt !== "string" || Number.isNaN(Date.parse(payload.capturedAt))) {
throw new TypeError("Metric timestamp must be a valid date");
}
return {
service: payload.service,
requests: payload.requests,
errors: payload.errors,
latencyP95Ms: payload.latencyP95Ms,
capturedAt: payload.capturedAt,
};
}
export function aggregateMetrics(metrics: Metric[]) {
const requests = metrics.reduce((sum, metric) => sum + metric.requests, 0);
const errors = metrics.reduce((sum, metric) => sum + metric.errors, 0);
const latencyP95Ms = metrics.length
? Math.max(...metrics.map(metric => metric.latencyP95Ms))
: 0;
return {
requests,
errors,
errorRate: requests === 0 ? 0 : errors / requests,
latencyP95Ms,
servicesOverBudget: metrics.filter(metric => metric.latencyP95Ms > 500).map(metric => metric.service),
};
}
`,
};
TEXT_CONTENT.report += `
## 复盘补充:组件与发布状态
普通强调、**重点结论**、*补充说明* 与 ~~已废弃策略~~ 都属于这份 GFM 示例的一部分。更多背景见[主要发现](#主要发现)。
| 检查项 | 结果 | 说明 |
| --- | --- | --- |
| API 兼容性 | 通过 | 未发现破坏性变更 |
| 浏览器预览 | 关注 | 大文件解析仍需观察 |
| 文档更新 | 通过 | 示例包含表格、列表与代码 |
<details>
<summary>展开查看 HTML 细节块</summary>
预览保留常见 HTML 结构,并清理不安全标签与属性。这里的内容使用原生 details / summary 展开。
</details>
### 发布前检查
- [x] 复核 API 兼容性
- [ ] 完成浏览器回归
- [ ] 验证 Chrome 与 Firefox
- [ ] 检查窄窗口下的表格滚动
- [ ] 更新对外发布说明
### 多语言代码块
SQL 查询:
\`\`\`sql
SELECT service, SUM(errors) / NULLIF(SUM(requests), 0) AS error_rate
FROM service_metrics
GROUP BY service;
\`\`\`
JSON 配置:
\`\`\`json
{
"latencyWarningMs": 500,
"cacheHitRateTarget": 0.78
}
\`\`\`
> 预览中的标题层级、GFM 表格、任务清单、删除线、引用和代码高亮可以分别检查;点击并选中文本还会触发宿主的 selection 回调。
`;
const adapter = createMockAdapter({
transformOutput: (step, context) => {
if (step.kind !== 'data' || step.dataKind !== ARTIFACT_LIST_PART_KIND || !context.messageId) return undefined;
return [
{
type: 'set-part',
messageId: context.messageId,
part: { id: 'artifacts', type: 'data', kind: ARTIFACT_LIST_PART_KIND, data: ARTIFACTS },
},
];
},
getScenarioId: () => 'artifacts',
pace: 0.35,
});
const loadMockArtifact: ArtifactLoad = async (artifact, options) => {
if (options.signal.aborted) throw new DOMException('Preview load was aborted', 'AbortError');
if (artifact.id === 'audio') return { type: 'url', url: audioFixtureUrl, mimeType: 'audio/wav' };
if (artifact.id === 'video') return { type: 'blob', blob: await getVideoFixture(), mimeType: 'video/webm' };
if (artifact.id === 'pdf') return { type: 'url', url: pdfFixtureUrl, mimeType: 'application/pdf' };
if (artifact.id === 'docx' || artifact.id === 'pptx') {
const fixtureUrl = artifact.id === 'docx' ? docxFixtureUrl : pptxFixtureUrl;
const response = await fetch(fixtureUrl, { signal: options.signal });
if (!response.ok) throw new Error(`Unable to load ${artifact.name}`);
const mimeType =
artifact.id === 'docx'
? 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
: 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
return { type: 'blob', blob: await response.blob(), mimeType };
}
if (artifact.id === 'project') {
if (options.path) {
const text = PROJECT_CONTENT[options.path];
if (text === undefined) throw new Error(`Unknown project file: ${options.path}`);
return { type: 'text', text };
}
return { type: 'project', files: PROJECT_FILES };
}
const text = TEXT_CONTENT[artifact.id];
if (text === undefined) throw new Error(`Unknown mock artifact: ${artifact.id}`);
return { type: 'text', text };
};
interface ArtifactListPartProps {
artifacts: readonly ArtifactItem[];
onAction: (message: string) => void;
}
function ArtifactListPart(props: ArtifactListPartProps) {
const { artifacts, onAction } = props;
const actions = useChatkitActions();
return (
<ArtifactList
artifacts={artifacts}
onPreview={artifact => {
onAction('');
actions.openPanel(artifact.id);
}}
onDownload={artifact => onAction(`已调用宿主下载 mock:${artifact.name}`)}
/>
);
}
let videoFixturePromise: Promise<Blob> | undefined;
function getVideoFixture(): Promise<Blob> {
if (!videoFixturePromise) {
videoFixturePromise = Promise.resolve()
.then(createVideoFixture)
.catch(error => {
videoFixturePromise = undefined;
throw error;
});
}
return videoFixturePromise;
}
function createVideoFixture(): Promise<Blob> {
if (typeof MediaRecorder === 'undefined') return Promise.reject(new Error('浏览器不支持视频录制'));
const canvas = document.createElement('canvas');
canvas.width = 960;
canvas.height = 540;
const context = canvas.getContext('2d');
const mimeType = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm'].find(type =>
MediaRecorder.isTypeSupported(type)
);
if (!context || !canvas.captureStream || !mimeType) return Promise.reject(new Error('浏览器无法生成 WebM 示例'));
const stream = canvas.captureStream(12);
const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 450_000 });
const chunks: Blob[] = [];
let frame = 0;
return new Promise((resolve, reject) => {
const stopStream = () => stream.getTracks().forEach(track => track.stop());
const drawTimer = window.setInterval(() => drawVideoFrame(context, frame++), 80);
recorder.addEventListener('dataavailable', event => {
if (event.data.size) chunks.push(event.data);
});
recorder.addEventListener(
'stop',
() => {
window.clearInterval(drawTimer);
stopStream();
const blob = new Blob(chunks, { type: mimeType });
if (blob.size) resolve(blob);
else reject(new Error('视频示例生成失败'));
},
{ once: true }
);
recorder.addEventListener(
'error',
() => {
window.clearInterval(drawTimer);
stopStream();
reject(new Error('视频示例生成失败'));
},
{ once: true }
);
drawVideoFrame(context, frame++);
recorder.start();
window.setTimeout(() => {
if (recorder.state !== 'inactive') recorder.stop();
}, 1800);
});
}
function drawVideoFrame(context: CanvasRenderingContext2D, frame: number): void {
const background = context.createLinearGradient(0, 0, 960, 540);
background.addColorStop(0, '#eef4ff');
background.addColorStop(1, '#f8fbff');
context.fillStyle = background;
context.fillRect(0, 0, 960, 540);
context.fillStyle = '#173052';
context.font = '600 28px system-ui, sans-serif';
context.fillText('Service health · live preview', 56, 68);
context.fillStyle = '#64748b';
context.font = '16px system-ui, sans-serif';
context.fillText('Operations overview · last 7 days', 56, 100);
const cards = [
['Requests', '428.6k', '+12.8%'],
['Success rate', '99.96%', '+0.04 pp'],
['P95 latency', '428 ms', '−9.1%'],
];
cards.forEach(([label, value, delta], index) => {
const x = 56 + index * 278;
context.fillStyle = '#ffffff';
context.fillRect(x, 132, 250, 112);
context.strokeStyle = '#dce5f1';
context.strokeRect(x, 132, 250, 112);
context.fillStyle = '#64748b';
context.font = '15px system-ui, sans-serif';
context.fillText(label, x + 18, 162);
context.fillStyle = '#173052';
context.font = '700 26px system-ui, sans-serif';
context.fillText(value, x + 18, 201);
context.fillStyle = '#16734b';
context.font = '13px system-ui, sans-serif';
context.fillText(delta, x + 150, 201);
});
context.fillStyle = '#ffffff';
context.fillRect(56, 274, 848, 214);
context.strokeStyle = '#dce5f1';
context.strokeRect(56, 274, 848, 214);
context.fillStyle = '#173052';
context.font = '600 18px system-ui, sans-serif';
context.fillText('Weekly request volume', 78, 308);
[72, 95, 82, 126, 117, 154, 137].forEach((base, index) => {
const height = base + Math.round(18 * Math.sin(frame / 8 + index));
const x = 112 + index * 105;
context.fillStyle = '#4f7df3';
context.fillRect(x, 452 - height, 30, height);
context.fillStyle = '#a8bdf4';
context.fillRect(x + 38, 452 - Math.round(height * 0.72), 30, Math.round(height * 0.72));
context.fillStyle = '#64748b';
context.font = '13px system-ui, sans-serif';
context.fillText(`W${33 + index}`, x + 6, 474);
});
}
export default function Artifacts() {
const runtime = useChatRuntime({ sessionId: 'artifacts', adapter });
const [actionMessage, setCurrentActionMessage] = useState('');
if (!runtime) return null;
const renderArtifactList: ChatkitDataPartRenderer = ({ data }) => (
<ArtifactListPart artifacts={data as ArtifactItem[]} onAction={setCurrentActionMessage} />
);
return (
<div className="demo-artifacts">
<Chatkit
runtime={runtime}
root={{ style: { maxWidth: 920, margin: '0 auto' } }}
settings={{
welcome: '发送问题后,Mock 回答会附带可预览的多格式产物。',
recommendations: ['展示多格式产物列表'],
}}
slots={{
'session.top': actionMessage ? <div className="demo-artifacts__action-message">{actionMessage}</div> : null,
panel: ({ id, close }) => {
const artifact = ARTIFACTS.find(item => item.id === id);
if (!artifact) return null;
return (
<ArtifactPreviewPanel
artifact={artifact}
load={loadMockArtifact}
onClose={close}
onDownload={item => setCurrentActionMessage(`已调用宿主下载 mock:${item.name}`)}
onOpenExternal={item => setCurrentActionMessage(`已调用宿主外开 mock:${item.name}`)}
onSelection={(selection: ArtifactPreviewSelection) => {
const value = selection.kind === 'text' ? selection.text : selection.kind;
setCurrentActionMessage(`已回传选择内容:${value.slice(0, 60)}`);
}}
/>
);
},
}}
renderers={{ parts: { [ARTIFACT_LIST_PART_KIND]: renderArtifactList } }}
/>
</div>
);
}#引入
import '@volcengine/chatkit/styles.css';
import '@volcengine/chatkit/artifacts.css';
import { ArtifactList, ArtifactPreviewPanel } from '@volcengine/chatkit/artifacts';artifacts.css 只包含产物组件的样式;主题变量与基础样式仍来自 styles.css。
#用法
后端在回答里返回一块产物数据,用 renderers.parts 显示成文件列表;点「预览」时用 openPanel 打开右侧面板,面板插槽里放 ArtifactPreviewPanel。数据怎么从后端到达这一步,见业务卡片。
import { type ChatkitDataPartRenderer, useChatkitActions } from '@volcengine/chatkit';
import {
type ArtifactItem,
type ArtifactLoad,
ArtifactList,
ArtifactPreviewPanel,
} from '@volcengine/chatkit/artifacts';
const ArtifactListCard: ChatkitDataPartRenderer = ({ data }) => {
const { openPanel } = useChatkitActions();
return (
<ArtifactList artifacts={data as ArtifactItem[]} onPreview={item => openPanel(item.id)} onDownload={download} />
);
};
const load: ArtifactLoad = async (artifact, { signal, path }) => {
const res = await fetch(`/api/artifacts/${artifact.id}${path ? `?path=${encodeURIComponent(path)}` : ''}`, {
signal,
});
return { type: 'blob', blob: await res.blob() };
};
<Chatkit
sessions={sessions}
renderers={{ parts: { 'acme.artifacts': ArtifactListCard } }}
slots={{
panel: ({ id, close }) => {
const artifact = id ? findArtifact(id) : undefined;
return artifact ? (
<ArtifactPreviewPanel artifact={artifact} load={load} onClose={close} onDownload={download} />
) : null;
},
}}
/>;面板插槽的用法见插槽 · 右侧面板。
#产物的描述 ArtifactItem
| 字段 | 必需 | 说明 |
|---|---|---|
id | 是 | 稳定的标识,传回给 load 和各个回调 |
name | 是 | 显示的文件名 |
kind | 否 | 文件类型(ArtifactKind);不给时按 mimeType、再按文件扩展名推断 |
status | 否 | ready(默认)、pending(生成中,不能预览和下载)、error(失败,卡片显示 error 文案) |
description | 否 | 卡片上的说明 |
mimeType | 否 | 文件的 MIME 类型 |
size | 否 | 文件大小(字节) |
createdAt | 否 | 生成时间(毫秒时间戳);列表里每项都有时按时间倒序排列 |
projectFiles | 否 | 项目类产物的文件清单 |
disabled | 否 | 为 true 时卡片不能预览和下载 |
#load 返回什么
load(artifact, { signal, path }) 返回下面四种之一。切换预览或关闭面板时,chatkit 会通过 signal 取消还没完成的加载。
| 返回 | 适用的类型 |
|---|---|
{ type: 'text', text } | Markdown、HTML、SVG、源码、纯文本、CSV、TSV |
{ type: 'blob', blob } | 所有类型;Word、PowerPoint、Excel 只能返回 Blob |
{ type: 'url', url } | PDF、图片、音频、视频:浏览器直接加载这个地址 |
{ type: 'project', files } | 项目:返回文件清单;用户点开某个文件时,chatkit 再次调用 load 并带上 path |
Office 文件要求 Blob,是为了让鉴权和下载留在应用这一侧:chatkit 不替应用请求带鉴权的地址。
#预览的范围
- 只读:Office 文档只做查看;Excel 公式显示文件里保存的计算结果,不重新计算。
- HTML 不执行脚本:HTML 在沙箱里静态显示,脚本、外部请求都被禁止;可以切换到「查看源码」。
- 项目只浏览:项目预览展示文件树和源码,不安装依赖、不构建、不运行。
- 不支持的类型:显示「该格式暂不支持在线预览,请下载原文件查看。」
#面板参数
| 参数 | 说明 |
|---|---|
artifact | 要预览的产物 |
load | 取内容的函数,见上一节 |
onClose | 关闭面板;在面板插槽里传插槽给的 close |
onDownload | 给了才显示下载按钮 |
onOpenExternal | 给了才显示外部打开按钮,打开方式由应用决定 |
onSelection | 用户在预览里选中文字、表格区域或图片区域时回调,可用来做「带着选区追问」 |
returnFocusElement | 关闭面板后键盘焦点回到的元素,一般是打开面板的按钮 |
#写在回答正文里
后端也可以直接在 Markdown 正文里输出产物列表。createArtifactMarkdownRenderers 生成一组标签组件,登记到 renderers.markdown。点「预览」要调用 openPanel,所以聊天区放在 ChatkitProvider 里,在同一个组件里取 useChatkitActions:
import { ChatkitProvider, useChatkitActions } from '@volcengine/chatkit';
import { createArtifactMarkdownRenderers } from '@volcengine/chatkit/artifacts';
function ChatArea() {
const { openPanel } = useChatkitActions();
const markdown = useMemo(
() => createArtifactMarkdownRenderers({ onPreview: item => openPanel(item.id), onDownload: download }),
[openPanel]
);
return <Chatkit renderers={{ markdown }} slots={{ panel: renderPanel }} />;
}
<ChatkitProvider sessions={sessions}>
<ChatArea />
</ChatkitProvider>;报告已生成:
<artifact-list value='[{"id":"report","name":"q3-report.pdf","kind":"pdf"},{"id":"metrics","name":"metrics.csv"}]'></artifact-list>value 里是 ArtifactItem 数组的 JSON,缺少 id 或 name 的项不显示。后端的字段名和 ArtifactItem 不一致时,传 resolveArtifact(source) 把每一项映射过来:source.attributes 是这一项的原始字段,返回 null 表示这一项不是产物。renderPanel 与上面用法里的面板插槽相同。
#在聊天区之外使用
ArtifactList、ArtifactPreviewPanel 也可以放在聊天区之外,例如页面自己的侧栏。它们自带 chatkit 的样式作用域;要和聊天区共用深色或自定义主题,放在同一个 ChatkitProvider 下面。