产物预览

Agent 生成了文件(报告、表格、网页、代码、PDF、Office 文档、整个项目),在回答里显示成文件卡片,点「预览」在右侧面板里打开。

产物预览是按需引入的功能:它不在默认入口里,不引入就不会进包。文件放在哪、怎么鉴权、怎么下载都由应用负责,chatkit 只调用应用给的 load 函数拿内容并显示。

artifacts.tsx
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>
  );
}
点「预览」在右侧面板打开 Markdown、CSV、HTML、源码、音视频、PDF、Office 与项目文件树新窗口打开

引入

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 下面。