在线示例

这些示例展示 React 18 下的接入组件,每个示例同时展示源码与交互预览。除「自定义适配器」外,示例里的对话都是示例数据,由内置的 Mock 适配器(createMockAdapter)提供:它不连接后端,按脚本回放对话,用于本地开发、演示和测试。大多数示例复制到自己的项目时还需引入包样式、设置容器高度,并补充 demo-* 类名对应的页面样式;错误卡片示例自带页面布局,按其接入说明使用。上传、语音、收藏等演示实现也需替换为业务逻辑。

完整示例:对话应用

full-app.tsx
import '@volcengine/chatkit/styles.css';

import {
  Chatkit,
  type ChatkitDataPartRenderer,
  type ChatkitMentionSource,
  ChatkitProvider,
  type ChatkitUploader,
  SessionList,
  useChatSessions,
  useComposer,
  useCurrentRun,
  useCurrentSession,
} from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import { useState } from 'react';

import { localUpload } from '../backends/local-upload';
import { useDemoSessions } from '../sessions';

// 适配器:见「接入后端」。整个应用一个实例,换后端只改这一段。
// transformOutput 把后端的输出变成回答里的业务数据:这里在搜索完成后带上一张参考资料卡片。
const adapter = createMockAdapter({
  transformOutput: (step, context) => {
    const parts = (() => {
      if (step.kind !== 'tool' || step.name !== 'search') return undefined;
      const { results = [] } = step.result as { results?: { title: string }[] };
      return [{ id: 'sources', type: 'data' as const, kind: 'acme.sources', data: results.map(item => item.title) }];
    })();
    return parts && context.messageId
      ? [
          ...context.defaultChanges,
          ...parts.map(part => ({ type: 'set-part' as const, messageId: context.messageId!, part })),
        ]
      : undefined;
  },
});

// 附件:见「内置功能 › 附件」。上传由页面实现,这里用本地地址代替。
const upload: ChatkitUploader = localUpload;

// @ 提及:见「内置功能 › @ 与 /」。
const mention: ChatkitMentionSource = {
  option: [
    { label: '数据分析助手', value: 'data-agent' },
    { label: '代码审查助手', value: 'review-agent' },
  ],
};

// 业务卡片:见「定制 › 业务卡片」。按数据类型登记组件。
const Sources: ChatkitDataPartRenderer = ({ data }) => (
  <div className="demo-card">
    <header>参考资料</header>
    {(data as string[]).map(title => (
      <div key={title}>· {title}</div>
    ))}
  </div>
);

const KNOWLEDGE_BASES = ['产品手册', '常见问题'];
const TEMPLATES = ['总结这个会话的结论', '把上面的回答改写成邮件', '列出还没解决的问题'];

// 页面顶栏:当前会话的标题和这次打开后完成的轮数。公开 hooks 在 ChatkitProvider 下面都能用。
function AppHeader({ rounds }: { rounds: number }) {
  const { session } = useCurrentSession();
  return (
    <header className="demo-app-header">
      <strong>{session?.title ?? '新会话'}</strong>
      <span className="demo-muted">已完成 {rounds} 轮</span>
    </header>
  );
}

// 页面右侧:这一轮的状态,和几条常用提问。点一条把它填进输入框。
function AppRail() {
  const run = useCurrentRun();
  const composer = useComposer();
  return (
    <aside className="demo-app-rail">
      <section className="demo-rail-card">
        <h4>任务进度</h4>
        <div className="demo-rail-row" data-status={run?.status ?? 'idle'}>
          <i />
          {run ? (run.finishedAt ? '这一轮已结束' : '正在处理') : '还没有开始'}
        </div>
        {run?.workItems?.total ? (
          <div className="demo-rail-row" data-status={run.workItems.active ? 'running' : 'completed'}>
            <i />
            步骤 {run.workItems.total - run.workItems.active} / {run.workItems.total}
          </div>
        ) : null}
      </section>
      <section className="demo-rail-card">
        <h4>常用提问</h4>
        {TEMPLATES.map(template => (
          <button
            key={template}
            type="button"
            className="demo-rail-row"
            onClick={() => {
              composer.setText(template);
              composer.focus();
            }}
          >
            <span>{template}</span>
          </button>
        ))}
      </section>
    </aside>
  );
}

export default function FullApp() {
  // 会话层:见「指南 › 两种用法」。会话列表和聊天区用同一个返回值。
  const { chat: sessions, listProps } = useDemoSessions({ adapter });
  const [ratings, setRatings] = useState<Record<string, 'like' | 'dislike' | undefined>>({});
  const [starred, setStarred] = useState<string[]>([]);
  const [knowledgeBase, setKnowledgeBase] = useState<string>();
  const [rounds, setRounds] = useState(0);
  const pickNext = () =>
    setKnowledgeBase(current => KNOWLEDGE_BASES[current ? KNOWLEDGE_BASES.indexOf(current) + 1 : 0]);

  return (
    // ChatkitProvider 把页面包起来,顶栏和右侧栏才能用公开 hooks;props 写在它上面,<Chatkit /> 从它取。
    <ChatkitProvider
      sessions={sessions}
      // 设置:见「参考 › 配置参考」。只填值。
      settings={{
        welcome: '有什么可以帮忙的?',
        placeholder: '问点什么,输入 @ 提及助手',
        recommendations: ['帮我查一下接入步骤', '写一份本周周报', '模拟一次失败'],
      }}
      // 内置功能:见「内置功能 › 总览」。附件、赞踩、提及给了函数或候选项才显示;
      // 重新生成、编辑后重发来自后端,不用配置。
      features={{
        attachments: { upload },
        feedback: {
          ratings,
          onRate: (rating, message) => setRatings(current => ({ ...current, [message.id]: rating })),
        },
        mentions: { mention },
      }}
      // 插槽:见「定制 › 插槽」。按位置放页面自己的内容。
      slots={{
        'session.top': <div className="demo-line demo-line--notice">公告:周五 18:00 起停机维护一小时</div>,
        'composer.toolbarStart': (
          <button type="button" className="demo-chip" aria-pressed={Boolean(knowledgeBase)} onClick={pickNext}>
            知识库
          </button>
        ),
        'composer.bodyTop': knowledgeBase ? <span className="demo-badge">参考:{knowledgeBase}</span> : null,
        'composer.bottom': <div className="demo-legal">内容由人工智能模型生成,请注意甄别</div>,
      }}
      // 渲染器:见「定制 › 业务卡片」「工具展示」「失败卡片与系统提示」。发一句带「失败」的话看失败卡片。
      renderers={{
        parts: { 'acme.sources': Sources },
        tools: { search: { runningLabel: '正在查资料', successLabel: '已查到资料' } },
        errors: {
          MODEL_OVERLOADED: ({ formattedContent }) => (
            <div className="demo-card">
              <header>现在用的人有点多</header>
              <span className="demo-muted">{formattedContent}</span>
            </div>
          ),
        },
      }}
      // 发送前处理:见「定制 › 发送前处理 onSend」。把选中的知识库带给后端。
      onSend={input => ({ ...input, metadata: { ...input.metadata, knowledgeBase } })}
      // 事件:见「可观测 › 事件」。每一轮结束时收到 run_settled。
      onEvent={event => {
        if (event.name === 'run_settled') setRounds(count => count + 1);
      }}
    >
      <div className="demo-app">
        {/* 会话列表:见「内置功能 › 会话列表」。itemActions 在每一行的菜单里,itemBadge 在标题后。 */}
        <SessionList
          {...listProps}
          header={<div className="demo-brand">Acme 助手</div>}
          footer={<div className="demo-user">Chloe</div>}
          itemBadge={({ session }) => (starred.includes(session.id) ? <span>★</span> : null)}
          itemActions={({ session }) => (
            <button type="button" onClick={() => setStarred(current => [...current, session.id])}>
              收藏
            </button>
          )}
        />
        <div className="demo-app-main">
          <AppHeader rounds={rounds} />
          <div className="demo-app-body">
            <div className="demo-main">
              <Chatkit />
            </div>
            <AppRail />
          </div>
        </div>
      </div>
    </ChatkitProvider>
  );
}
完整示例:对话应用新窗口打开

每一段代码对应哪一页文档,见完整示例。

最小接入

quick-start.tsx
import { useDemoSessions } from '../sessions';

import { Chatkit, SessionList } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import '@volcengine/chatkit/styles.css';
import { useState } from 'react';

// 整个应用一个适配器实例,引用要稳定(模块级或 useMemo):引用一变运行时就会重建。
// Mock 适配器不连后端,自带三个预置会话,发送后按脚本回放「思考 → 调工具 → 流式回答」。
const adapter = createMockAdapter();

// 最小接入:适配器 → useChatSessions → 会话列表和聊天区用同一个返回值。随便发一句话试试。
export default function QuickStart() {
  const { chat: sessions, listProps } = useDemoSessions({ adapter });
  const [open, setOpen] = useState(false);
  return (
    <div className="demo-column demo-session-layout" data-placement="left" data-open={open}>
      {/* demo-session-* 是示例页的响应式样式:窄屏默认收起列表,聊天区保持全宽。 */}
      <div className="demo-bar demo-session-toggle-bar">
        <button
          type="button"
          className="demo-chip"
          aria-expanded={open}
          aria-controls="quick-start-sessions"
          onClick={() => setOpen(value => !value)}
        >
          {open ? '收起会话' : '全部会话'}
        </button>
      </div>
      <div className="demo-row">
        <div id="quick-start-sessions" className="demo-session-sidebar">
          <SessionList {...listProps} style={{ width: '100%' }} />
        </div>
        <div className="demo-main">
          <Chatkit sessions={sessions} settings={{ welcome: '有什么可以帮忙的?' }} />
        </div>
      </div>
    </div>
  );
}
最小接入新窗口打开

会话列表

sessions.tsx
import { useDemoSessions } from '../sessions';

import { Chatkit, SessionList } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import '@volcengine/chatkit/styles.css';
import { useState } from 'react';

const adapter = createMockAdapter();

type Placement = 'left' | 'right' | 'drawer';
const PLACEMENTS: { id: Placement; label: string }[] = [
  { id: 'left', label: '列表在左' },
  { id: 'right', label: '列表在右' },
  { id: 'drawer', label: '收进抽屉' },
];

// 会话列表是单独的组件,和聊天区用同一个 useChatSessions 的返回值;摆在哪由页面自己的布局决定。
// 切换会话不打断回答:发一句话后马上切到别的会话,列表里原来那一行显示运行中。
export default function Sessions() {
  const { chat: sessions, listProps } = useDemoSessions({ adapter });
  const [placement, setPlacement] = useState<Placement>('left');
  const [open, setOpen] = useState(false);
  return (
    <div className="demo-column demo-session-layout" data-placement={placement} data-open={open}>
      <div className="demo-bar">
        {/* 窄屏统一用抽屉;宽屏可以比较三种布局。 */}
        {PLACEMENTS.map(item => (
          <label key={item.id} className="demo-session-placement">
            <input
              type="radio"
              checked={placement === item.id}
              onChange={() => {
                setPlacement(item.id);
                setOpen(false);
              }}
            />{' '}
            {item.label}
          </label>
        ))}
        <button
          type="button"
          className="demo-chip demo-session-toggle"
          aria-expanded={open}
          aria-controls="demo-sessions"
          onClick={() => setOpen(value => !value)}
        >
          {open ? '收起会话' : '全部会话'}
        </button>
      </div>
      <div className="demo-row">
        <div id="demo-sessions" className="demo-session-sidebar">
          <SessionList {...listProps} style={{ width: '100%' }} />
        </div>
        <div className="demo-main">
          <Chatkit sessions={sessions} settings={{ welcome: '发一句话试试' }} />
        </div>
      </div>
    </div>
  );
}
会话列表新窗口打开

思考与工具调用的执行流

tool-flow.tsx
import { Chatkit, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import '@volcengine/chatkit/styles.css';

const adapter = createMockAdapter({ getScenarioId: () => 'all-tools' });

// 思考与工具调用合并成一个可折叠的执行流:分组头、步骤行、展开区(入参 / 结果)。
// toolFlow 换成 'step' 是逐个工具一行,'none' 是不显示执行过程。
export default function ToolFlow() {
  const runtime = useChatRuntime({ sessionId: 'tool-flow', adapter });
  if (!runtime) return null;
  return (
    <Chatkit
      runtime={runtime}
      settings={{
        toolFlow: 'group',
        tools: { groupDefaultExpanded: true },
        recommendations: ['把配置文件改一下并跑一遍测试'],
      }}
    />
  );
}
思考与工具调用的执行流新窗口打开

长思考

reasoning.tsx
import { Chatkit, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import '@volcengine/chatkit/styles.css';

const adapter = createMockAdapter({ getScenarioId: () => 'long-reasoning' });

// 思考过程:输出中展开,结束后和工具调用一起收进执行流。这里让它结束后也能直接看到:
// 这一轮完成后不收起执行过程,执行流默认展开,其中的思考保持展开。
export default function Reasoning() {
  const runtime = useChatRuntime({ sessionId: 'reasoning', adapter });
  if (!runtime) return null;
  return (
    <Chatkit
      runtime={runtime}
      settings={{
        reasoning: { visible: true, defaultExpanded: true },
        recommendations: ['仔细想想再回答'],
      }}
    />
  );
}
长思考新窗口打开

公开 Chatkit 的推理步骤

reasoning-step-compare.tsx
import '@volcengine/chatkit/styles.css';

import { Chatkit, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';

// Public-API comparison fixture: the mock thought becomes a real message reasoning part.
// Hide the separate run-status control so the reasoning step is the only folding target.
const adapter = createMockAdapter({ getScenarioId: () => 'long-reasoning' });

export default function ReasoningStepCompare() {
  const runtime = useChatRuntime({ sessionId: 'reasoning-step-compare', adapter });
  if (!runtime) return null;

  return (
    <div style={{ height: '100vh' }}>
      <button
        type="button"
        data-reasoning-step-send=""
        onClick={() =>
          void runtime.actions.send({
            content: [{ type: 'text', text: '展示真实 ReasoningStep' }],
            attachments: [],
          })
        }
      >
        生成 ReasoningStep 对照消息
      </button>
      <Chatkit
        runtime={runtime}
        settings={{ runStatus: { visible: false }, reasoning: { visible: true, defaultExpanded: true } }}
      />
    </div>
  );
}
ReasoningStep 完成态与开合新窗口打开

点击“生成 ReasoningStep 对照消息”,等待回复完成后点击“深度思考”行头展开或收起正文。

运行状态

run-status.tsx
import { Chatkit, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import '@volcengine/chatkit/styles.css';

const adapter = createMockAdapter({});

// 状态行:「正在思考 → 已处理 · 耗时 → 已完成 · 耗时」。完成后收起执行过程,只留最终回答,点状态行展开回看。
// 这里把默认值写出来只为说明;耗时由运行时自己记,不需要传。
export default function RunStatus() {
  const runtime = useChatRuntime({ sessionId: 'run-status', adapter });
  if (!runtime) return null;
  return (
    <Chatkit
      runtime={runtime}
      settings={{
        runStatus: { visible: true, collapseOnComplete: true, longWaitThresholdMs: 15_000 },
        recommendations: ['帮我查一下 chatkit 怎么接入'],
      }}
    />
  );
}
运行状态新窗口打开

Markdown 渲染

markdown.tsx
import { Chatkit, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import '@volcengine/chatkit/styles.css';

const adapter = createMockAdapter({ getScenarioId: () => 'markdown' });

// 正文按 Markdown 渲染:标题、列表、表格、引用、代码高亮、公式。点推荐问题触发。
export default function Markdown() {
  const runtime = useChatRuntime({ sessionId: 'markdown', adapter });
  if (!runtime) return null;
  return <Chatkit runtime={runtime} settings={{ recommendations: ['给我一份 Markdown 排版示例'] }} />;
}
Markdown 全集新窗口打开

OpenUI 渲染

点击推荐问题后,Mock 助手会返回一段 OpenUI DSL,并由固定 renderer 渲染为服务运行报告,包含指标卡、请求趋势图、延迟对比图、来源占比图、服务明细表和分析结论。示例只展示 OpenUI 渲染能力,不连接真实 agent。接入方式见 OpenUI 渲染。

openui.tsx
import '@volcengine/chatkit/styles.css';
import '@volcengine/chatkit/openui.css';
import './openui-demo.css';

import { Chatkit, type ChatkitDataPartRenderer, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import { OpenUiArtifact } from '@volcengine/chatkit/openui';

const OPENUI_PART_KIND = 'demo.openui';
const OPENUI_SOURCE = `root = Stack([header, status, metrics, charts, tableTitle, table, insight])
header = CardHeader("Q3 服务运行报告", "2026 年第三季度 · API 网关与推理服务 · 数据截至 9 月 30 日")
status = Callout("success", "整体运行稳定", "核心服务可用性 99.94%,错误率低于 0.1%。本报告展示标题、状态、KPI、图表、表格和说明组件。")
metrics = Stack([requestCard, successCard, latencyCard, costCard], "row", "m", "stretch", "start", true)
requestCard = Card([TextContent("总请求量", "small"), TextContent("148.2 万", "large-heavy"), TextContent("较上季度 +12.8%", "small")])
successCard = Card([TextContent("请求成功率", "small"), TextContent("99.94%", "large-heavy"), TextContent("较上季度 +0.3 个百分点", "small")])
latencyCard = Card([TextContent("P95 延迟", "small"), TextContent("428 ms", "large-heavy"), TextContent("目标 < 500 ms", "small")])
costCard = Card([TextContent("推理成本", "small"), TextContent("¥ 82,460", "large-heavy"), TextContent("单位请求成本 -6.2%", "small")])
charts = Stack([trafficCard, latencyCardSection, channelCard], "column", "m")
trafficCard = Card([CardHeader("月度请求趋势", "请求量持续增长,峰值出现在 9 月"), trafficChart])
trafficChart = LineChart(months, [requestSeries, previousSeries])
requestSeries = Series("本季度请求量(万)", [41.2, 44.8, 48.7, 51.3, 55.6, 60.1, 66.4])
previousSeries = Series("上季度对比(万)", [38.1, 40.5, 43.2, 45.7, 48.4, 52.8, 58.2])
months = ["3 月", "4 月", "5 月", "6 月", "7 月", "8 月", "9 月"]
latencyCardSection = Card([CardHeader("核心服务 P95 延迟", "当前值与上季度对比,单位:毫秒"), latencyChart])
latencyChart = BarChart(services, [currentLatency, previousLatency], "grouped")
services = ["对话 API", "检索服务", "模型网关", "文件解析"]
currentLatency = Series("本季度", [428, 362, 486, 612])
previousLatency = Series("上季度", [471, 405, 522, 648])
channelCard = Card([CardHeader("请求来源", "本季度总请求占比"), channelChart])
channelChart = PieChart(channels, channelValues, "donut")
channels = ["Web", "API", "移动端", "内部任务"]
channelValues = [46, 28, 18, 8]
tableTitle = TextContent("服务质量明细", "large-heavy")
table = Table([Col("服务", serviceNames), Col("成功率", successRates), Col("P95 延迟", serviceLatency), Col("状态", serviceStatus)])
serviceNames = ["对话 API", "检索服务", "模型网关", "文件解析", "任务调度"]
successRates = ["99.97%", "99.91%", "99.95%", "99.72%", "99.99%"]
serviceLatency = ["428 ms", "362 ms", "486 ms", "612 ms", "94 ms"]
serviceStatus = ["正常", "正常", "正常", "关注", "正常"]
insight = TextCallout("info", "分析结论", "文件解析服务的 P95 延迟高于 500 ms 目标,是本季度唯一需要跟进的指标。计划通过并行读取和缓存优化,在下季度将其降低至 500 ms 以内。")`;

const adapter = createMockAdapter({
  transformOutput: (step, context) => {
    if (!context.messageId || step.kind !== 'data') return undefined;
    if (step.dataKind !== OPENUI_PART_KIND) return undefined;

    return [
      {
        type: 'set-part',
        messageId: context.messageId,
        part: { id: 'openui', type: 'data', kind: OPENUI_PART_KIND, data: { source: OPENUI_SOURCE } },
      },
    ];
  },
  getScenarioId: () => 'openui',
});

interface OpenUiPartData {
  source: string;
}

const renderOpenUi: ChatkitDataPartRenderer = ({ data, isRunning }) => {
  const openUi = data as OpenUiPartData;
  return <OpenUiArtifact source={openUi.source} isStreaming={isRunning} title="OpenUI 示例" />;
};

export default function OpenUi() {
  const runtime = useChatRuntime({ sessionId: 'openui', adapter });
  if (!runtime) return null;

  return (
    <div className="demo-openui">
      <Chatkit
        runtime={runtime}
        root={{ style: { maxWidth: 920, margin: '0 auto' } }}
        settings={{
          welcome: '发送问题后,Mock 回答会返回结构化 OpenUI 内容。',
          recommendations: ['展示 OpenUI 渲染示例'],
        }}
        renderers={{ parts: { [OPENUI_PART_KIND]: renderOpenUi } }}
      />
    </div>
  );
}
OpenUI 渲染新窗口打开

产物预览

点击推荐问题后,Mock 助手会返回多格式产物卡片。点击“预览”会在 ChatKit 右侧浮层中打开文件;示例包括带表格和行动项的 Markdown 报告、12 周 CSV、运营 HTML 面板、源码与嵌套项目树,以及带图表/表格/多页内容的 PDF、DOCX 和 PPTX。文件由宿主 mock load 回调提供,不连接真实 agent 或存储。下载、外开和选择内容也会触发示例宿主回调。接入方式见产物预览。

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>
  );
}
产物预览新窗口打开

失败卡片

failure.tsx
import { Chatkit, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import '@volcengine/chatkit/styles.css';

const adapter = createMockAdapter({ getScenarioId: () => 'run-failed' });

// 后端给出失败结论:显示失败卡片,已经产出的内容保留。点推荐问题触发。
export default function Failure() {
  const runtime = useChatRuntime({ sessionId: 'failure', adapter });
  if (!runtime) return null;
  return <Chatkit runtime={runtime} settings={{ recommendations: ['开始一个会失败的任务'] }} />;
}
失败卡片新窗口打开

停止

stop.tsx
import { Chatkit, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import '@volcengine/chatkit/styles.css';

const adapter = createMockAdapter({ getScenarioId: () => 'slow-abortable' });

// 工具要跑十几秒:运行中发送按钮变成停止按钮,点它这一轮就此结束,状态行显示「已中断」。
export default function Stop() {
  const runtime = useChatRuntime({ sessionId: 'stop', adapter });
  if (!runtime) return null;
  return (
    <Chatkit
      runtime={runtime}
      settings={{ welcome: '发出后点输入框右侧的停止按钮', recommendations: ['跑一个很慢的任务'] }}
    />
  );
}
停止新窗口打开

回答操作:重新生成、编辑后重发

answer-actions.tsx
import '@volcengine/chatkit/styles.css';

import { Chatkit, SessionList } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';

import { useDemoSessions } from '../sessions';

const adapter = createMockAdapter();

// 回答操作不需要配置:适配器实现了 regenerate / edit,按钮就出现。
// 编辑需要消息关联运行记录,重新生成还需要原始输入;只允许编辑最新用户消息、重新生成最新回答。
// 不想要某一个时用 features 关掉,比如 features={{ edit: false }}。
export default function AnswerActions() {
  const { chat: sessions, listProps } = useDemoSessions({ adapter });
  return (
    <div className="demo-row">
      <SessionList {...listProps} style={{ width: 190 }} />
      <div className="demo-main">
        <Chatkit sessions={sessions} settings={{ placeholder: '发一句话,回答完成后试试重新生成和编辑' }} />
      </div>
    </div>
  );
}
回答操作:重新生成、编辑后重发新窗口打开

划词追问

selection-ask.tsx
import { Chatkit, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import '@volcengine/chatkit/styles.css';
import { useState } from 'react';

const adapter = createMockAdapter();

// 划词追问一直有,不需要配置:在回答里选中一段文字,点浮条上的「追问」,文字作为引用进输入框。
// message.selection 里的内容显示在同一个浮条上,函数写法拿到选中的文字。
export default function SelectionAsk() {
  // 打开 Mock 预置的一个会话,里面已经有回答可以选
  const runtime = useChatRuntime({ sessionId: 'mock-session-debug', adapter });
  const [notes, setNotes] = useState<string[]>([]);
  if (!runtime) return null;
  return (
    <div className="demo-row">
      <div className="demo-main">
        <Chatkit
          runtime={runtime}
          settings={{ placeholder: '在回答里选中一段文字试试' }}
          slots={{
            'message.selection': ({ text, clear }) => (
              <button
                type="button"
                className="demo-chip"
                onClick={() => {
                  setNotes(current => [...current, text]);
                  clear();
                }}
              >
                记到笔记
              </button>
            ),
          }}
        />
      </div>
      <aside className="demo-side">
        <h4>笔记</h4>
        {notes.length === 0 ? <p className="demo-muted">选中文字后点「记到笔记」</p> : null}
        {notes.map((note, index) => (
          <pre key={index}>{note}</pre>
        ))}
      </aside>
    </div>
  );
}
划词追问新窗口打开

附件交互

attachments.tsx
import '@volcengine/chatkit/styles.css';

import { Chatkit, type ChatkitUploader, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import { useState } from 'react';

import { localUpload } from '../backends/local-upload';

const adapter = createMockAdapter({});

// 这里只演示附件交互:文件转为 base64 内联资源,不上传也不持久化。
const upload: ChatkitUploader = localUpload;

// 附件:给了 upload 才显示。点输入框左下角的按钮,或把文件拖进来、粘贴进来。
// 可选类型默认取后端声明支持的;Mock 还支持 ZIP、Word、Excel、PowerPoint 等常见文件。
export default function Attachments() {
  const [inline, setInline] = useState(false);
  const runtime = useChatRuntime({ sessionId: 'attachments', adapter });
  if (!runtime) return null;
  return (
    <div className="demo-column">
      <div className="demo-bar">
        <strong>附件交互(base64 内联,不持久化)</strong>
        <label>
          <input type="checkbox" checked={inline} onChange={event => setInline(event.target.checked)} />
          内联混排
        </label>
      </div>
      <div className="demo-main">
        <Chatkit
          runtime={runtime}
          settings={{ welcome: '选一张图片或一个文档,和问题一起发出' }}
          features={{
            attachments: {
              upload,
              mode: inline ? 'inline' : 'strip',
              maxTotalSize: 20 * 1024 * 1024,
            },
          }}
        />
      </div>
    </div>
  );
}
附件栏与内联混排(base64 内联,不持久化)新窗口打开

发送提示与自定义附件菜单

composer-controls.tsx
import '@volcengine/chatkit/styles.css';

import { Chatkit, ComposerAddAttachment, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import { useState } from 'react';

import { localUpload } from '../backends/local-upload';

const adapter = createMockAdapter();

export default function ComposerControls() {
  const runtime = useChatRuntime({ sessionId: 'composer-controls', adapter });
  const [customClicks, setCustomClicks] = useState(0);
  if (!runtime) return null;

  return (
    <div style={{ height: '100%', minHeight: 400, display: 'flex', flexDirection: 'column' }}>
      <p>自定义项点击次数:{customClicks}(点击后菜单保持打开)</p>
      <div style={{ flex: 1, minHeight: 0 }}>
        <Chatkit
          runtime={runtime}
          settings={{ composer: { sendTip: '发送消息(空草稿时也可查看提示)' } }}
          features={{
            attachments: {
              upload: localUpload,
              slot: false,
            },
          }}
          slots={{
            'composer.toolbarStart': (
              <ComposerAddAttachment
                items={[
                  { type: 'file' },
                  {
                    type: 'custom',
                    key: 'keep-open',
                    label: '计数并保持菜单打开',
                    keepMenuOpen: true,
                    onClick: () => setCustomClicks(count => count + 1),
                  },
                  { type: 'custom', key: 'disabled', label: '暂不可用', disabled: true },
                ]}
              />
            ),
          }}
        />
      </div>
    </div>
  );
}
发送提示、自定义/禁用附件项与保持菜单打开新窗口打开
prompt-completion.tsx
import '@volcengine/chatkit/styles.css';

import { Chatkit, type ChatkitPromptCompletionChipProps, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import { useState } from 'react';

const adapter = createMockAdapter();
const items = [
  { key: 'summary', prompt: '请总结这段对话的重点' },
  { key: 'plan', prompt: '帮我制定下一步计划' },
  { key: 'questions', prompt: '还有哪些问题值得追问?' },
];
const longItems = [
  {
    key: 'detailed-review',
    prompt:
      '请完整梳理这段对话的背景、目标和已经确认的结论,按照事情发生的顺序说明每一步的依据。' +
      '对于尚未确认的信息,请标出是谁提出了疑问、目前缺少什么证据,以及需要向谁继续核实。' +
      '再把后续行动整理成清单,分别写明负责人、预期交付物、依赖关系和建议完成时间。' +
      '如果讨论中出现相互矛盾的说法,请并列呈现原意并解释可能造成的影响,不要自行猜测最终决定。' +
      '最后给出一段简短摘要,方便没有参加讨论的同事快速理解当前状态和下一步需要关注的风险。',
  },
  {
    key: 'detailed-plan',
    prompt:
      '请基于当前讨论拟定一份可执行的下一步计划,先列出已经具备的输入和仍待补充的资料。' +
      '把任务拆分为可以独立验收的小步骤,说明每一步的负责人、依赖、预计时间和完成标准。' +
      '针对进度延迟、需求变化和协作阻塞分别提出应对方式,并标记需要团队进一步确认的取舍。' +
      '请在计划末尾附上检查清单,让团队在下一次同步时能逐项核对进展、更新风险并决定是否调整优先级。',
  },
];

/** 只返回非交互视觉节点,不能嵌套 button/link/input/可聚焦元素;Chatkit 管理外层按钮。 */
function CustomChip({ item, action }: ChatkitPromptCompletionChipProps) {
  return (
    <span
      style={{
        display: 'inline-flex',
        alignItems: 'center',
        gap: 8,
        padding: '5px 10px',
        color: '#fff',
        background: '#365ac7',
        borderRadius: 999,
        fontSize: 12,
      }}
      data-suggestion={item.key}
    >
      <span>{action === 'fill' ? '使用建议' : '换一条建议'}</span>
      <kbd style={{ fontFamily: 'inherit' }}>Tab</kbd>
      <span aria-hidden="true">→</span>
    </span>
  );
}

export default function PromptCompletionDemo() {
  const [custom, setCustom] = useState(false);
  const [longExample, setLongExample] = useState(false);
  const [lastAcceptance, setLastAcceptance] = useState<{
    key: string;
    trigger: 'tab' | 'click';
    action: 'fill' | 'replace';
  } | null>(null);
  const [paragraphTheme, setParagraphTheme] = useState(false);
  const [alternateTheme, setAlternateTheme] = useState(false);
  const runtime = useChatRuntime({
    sessionId: longExample ? 'prompt-completion-demo-long' : 'prompt-completion-demo',
    adapter,
  });
  if (!runtime) return null;
  return (
    <div style={{ height: '100%', display: 'flex', flexDirection: 'column', gap: 8 }}>
      <style>{`
        .chatkit-wrapper.prompt-box-theme .chatkit-composer-rich-text-paragraph,
        .chatkit-wrapper.prompt-box-theme-alt .chatkit-composer-rich-text-paragraph {
          box-sizing: border-box;
          width: 95%;
          margin-left: 4px;
          padding: 6px 28px 10px 12px;
          border: 2px solid currentColor;
          border-right-width: 5px;
          text-align: center;
        }
        .chatkit-wrapper.prompt-box-theme-alt .chatkit-composer-rich-text-paragraph {
          font-weight: 700;
          letter-spacing: 1px;
          padding-left: 20px;
        }
      `}</style>
      <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}>
        <label>
          <input
            type="checkbox"
            checked={longExample}
            onChange={event => {
              setLongExample(event.target.checked);
              setLastAcceptance(null);
            }}
          />{' '}
          长候选(切换示例会话,查看多行/滚动)
        </label>
        <label>
          <input type="checkbox" checked={custom} onChange={event => setCustom(event.target.checked)} /> 自定义 chip
          并独立换行
        </label>
        <label>
          <input type="checkbox" checked={paragraphTheme} onChange={event => setParagraphTheme(event.target.checked)} />{' '}
          非对称段落样式
        </label>
        <button type="button" onClick={() => setAlternateTheme(value => !value)} disabled={!paragraphTheme}>
          切换段落主题
        </button>
      </div>
      <output aria-live="polite" data-testid="prompt-last-accept">
        最近一次补全:
        {lastAcceptance ? `${lastAcceptance.action} · ${lastAcceptance.key} · ${lastAcceptance.trigger}` : '尚未填入'}
      </output>
      <div style={{ flex: 1, minHeight: 0 }}>
        <Chatkit
          runtime={runtime}
          root={{ className: paragraphTheme ? (alternateTheme ? 'prompt-box-theme-alt' : 'prompt-box-theme') : '' }}
          features={{
            promptCompletion: {
              items: longExample ? longItems : items,
              rotateIntervalMs: longExample ? false : 5000,
              renderChip: custom ? CustomChip : undefined,
              chipLayout: custom ? 'new-line' : 'auto',
              onAccept: (item, { trigger, action }) => setLastAcceptance({ key: item.key, trigger, action }),
            },
          }}
        />
      </div>
    </div>
  );
}
输入建议:填入与切换新窗口打开

赞踩

feedback.tsx
import { Chatkit, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import '@volcengine/chatkit/styles.css';
import { useState } from 'react';

const adapter = createMockAdapter();

type Rating = 'like' | 'dislike' | undefined;

// 赞踩:给了 onRate 才显示。选中态由接入方保存(通常还要提交到自己的反馈接口),按消息 id 索引。
// 这里打开 Mock 预置的一个会话,回答下方已经有赞踩按钮。
export default function Feedback() {
  const runtime = useChatRuntime({ sessionId: 'mock-session-css', adapter });
  const [ratings, setRatings] = useState<Record<string, Rating>>({});
  if (!runtime) return null;
  return (
    <div className="demo-row">
      <div className="demo-main">
        <Chatkit
          runtime={runtime}
          features={{
            feedback: {
              ratings,
              // 保存选中态;已评价的消息不会再次触发内置赞踩按钮。
              onRate: (rating, message) =>
                setRatings(current => ({
                  ...current,
                  [message.id]: rating,
                })),
            },
          }}
        />
      </div>
      <aside className="demo-side">
        <h4>接入方保存的评价</h4>
        <pre>{JSON.stringify(ratings, null, 2)}</pre>
      </aside>
    </div>
  );
}
赞踩新窗口打开

回答操作栏:复制与赞踩

message-actions.tsx
import { Chatkit, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import '@volcengine/chatkit/styles.css';

const adapter = createMockAdapter();

// 回答下方的操作栏:内置的复制在前,message.actions 里的内容排在后面。
// 函数写法拿到这条消息,可以按消息决定显示什么。
export default function MessageActions() {
  const runtime = useChatRuntime({ sessionId: 'mock-session-debug', adapter });
  if (!runtime) return null;
  return (
    <Chatkit
      runtime={runtime}
      features={{ copy: true }}
      slots={{
        'message.actions': ({ message }) => (
          <button type="button" className="demo-chip" onClick={() => window.alert(`导出消息 ${message.id}`)}>
            导出
          </button>
        ),
      }}
    />
  );
}
回答操作栏:复制与赞踩新窗口打开

@ 与 /

mentions.tsx
import { Chatkit, type ChatkitMentionSource, type ChatkitSlashSource, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import '@volcengine/chatkit/styles.css';

const adapter = createMockAdapter({});

const mention: ChatkitMentionSource = {
  option: [
    { label: '产品经理 Agent', value: 'pm-agent', tips: '需求分析' },
    { label: '代码审查 Agent', value: 'review-agent', tips: '只读仓库' },
    { label: '数据分析 Agent', value: 'data-agent' },
  ],
};

const slash: ChatkitSlashSource = {
  option: [
    { label: '总结', value: 'summarize', tips: '总结上面的对话' },
    { label: '翻译', value: 'translate', tips: '中英互译' },
    { label: '写周报', value: 'weekly-report' },
  ],
};

// 在输入框里输入 @ 选择提及对象,输入 / 选择指令;选中的内容以标签形式插进正文。
// 给了哪个就开哪个。
export default function Mentions() {
  const runtime = useChatRuntime({ sessionId: 'mentions', adapter });
  if (!runtime) return null;
  return (
    <Chatkit
      runtime={runtime}
      settings={{ welcome: '在输入框里输入 @ 或 /', placeholder: '输入 @ 提及,输入 / 选择指令' }}
      features={{ mentions: { mention, slash } }}
    />
  );
}
@ 与 /新窗口打开

语音输入

voice.tsx
import { Chatkit, type ChatkitSpeechRecognizer, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import '@volcengine/chatkit/styles.css';

const adapter = createMockAdapter({});

const SPEECH = '帮我把这个季度的复盘整理成一份文档';

// recognize 由接入方实现:开始收音,把识别结果交给 onText,返回停止函数。
// 这里用一段固定文字代替识别服务:每隔一小段时间多「听」到几个字。
const recognize: ChatkitSpeechRecognizer = ({ onText, onEnd }) => {
  let length = 0;
  const timer = setInterval(() => {
    length = Math.min(SPEECH.length, length + 2);
    const done = length === SPEECH.length;
    onText(SPEECH.slice(0, length), done);
    if (done) {
      clearInterval(timer);
      onEnd();
    }
  }, 150);
  return () => clearInterval(timer);
};

// 语音输入:给了 recognize 才显示。点输入框右下角的语音按钮,文字填进输入框,不会自动发送。
export default function Voice() {
  const runtime = useChatRuntime({ sessionId: 'voice', adapter });
  if (!runtime) return null;
  return (
    <Chatkit runtime={runtime} settings={{ welcome: '点输入框右下角的语音按钮' }} features={{ voice: { recognize } }} />
  );
}
语音输入新窗口打开

欢迎语与推荐问题

welcome.tsx
import { Chatkit, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import '@volcengine/chatkit/styles.css';

const adapter = createMockAdapter({});

// 空会话时的欢迎语与推荐问题:点一条推荐问题直接发出。
export default function Welcome() {
  const runtime = useChatRuntime({ sessionId: 'welcome', adapter });
  if (!runtime) return null;
  return (
    <Chatkit
      runtime={runtime}
      settings={{
        welcome: '有什么可以帮忙的?',
        recommendations: ['帮我查一下 chatkit 怎么接入', '把这段需求整理成待办清单', '解释一下什么是 RAG'],
        placeholder: '问点什么,或者从上面挑一个开始',
      }}
    />
  );
}
欢迎语与推荐问题新窗口打开

英文界面

locale.tsx
import { Chatkit, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import '@volcengine/chatkit/styles.css';
import { useState } from 'react';

const adapter = createMockAdapter({});

// 界面文字跟随 settings.locale:执行流、状态行、按钮提示、占位文字都会切换。
export default function Locale() {
  const runtime = useChatRuntime({ sessionId: 'locale', adapter });
  const [locale, setLocale] = useState<'zh-CN' | 'en-US'>('en-US');
  if (!runtime) return null;
  return (
    <div className="demo-column">
      <div className="demo-bar">
        <label>
          <input type="radio" checked={locale === 'en-US'} onChange={() => setLocale('en-US')} /> en-US
        </label>
        <label>
          <input type="radio" checked={locale === 'zh-CN'} onChange={() => setLocale('zh-CN')} /> zh-CN
        </label>
      </div>
      <div className="demo-main">
        <Chatkit runtime={runtime} settings={{ locale, recommendations: ['Show me what you can do'] }} />
      </div>
    </div>
  );
}
英文界面新窗口打开

插槽

slots.tsx
import { Chatkit, type ChatkitSlots, useChatkitActions, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import '@volcengine/chatkit/styles.css';

const adapter = createMockAdapter();

// 一块带名字的色块,标出这个位置在哪
const mark = (name: string) => <span className="demo-slot">{name}</span>;

// panel 放了内容面板就会显示,所以这里按需打开:点顶栏的按钮才出现
function OpenPanel() {
  const { openPanel } = useChatkitActions();
  return (
    <button type="button" className="demo-chip" onClick={() => openPanel('mark')}>
      打开 panel
    </button>
  );
}

// 聊天区的 17 个位置。session.empty 只在空会话时出现,message.selection 在选中回答里的文字时出现。
const slots: ChatkitSlots = {
  header: (
    <div className="demo-line">
      {mark('header')}
      <OpenPanel />
    </div>
  ),
  'session.top': mark('session.top'),
  'session.bottom': mark('session.bottom'),
  'session.empty': ({ defaultNode }) => (
    <>
      {mark('session.empty')}
      {defaultNode}
    </>
  ),
  'message.header': mark('message.header'),
  'message.footer': mark('message.footer'),
  'message.actions': mark('message.actions'),
  'message.selection': mark('message.selection'),
  'composer.top': mark('composer.top'),
  'composer.bottom': mark('composer.bottom'),
  'composer.bodyTop': mark('composer.bodyTop'),
  'composer.bodyBottom': mark('composer.bodyBottom'),
  'composer.overlay': ({ plainText }) => (plainText ? null : mark('composer.overlay')),
  'composer.toolbarStart': mark('composer.toolbarStart'),
  'composer.toolbarEnd': mark('composer.toolbarEnd'),
  // 只放一项的位置:替换默认的发送按钮,这里把默认按钮留着、旁边加标记
  'composer.sendButton': ({ defaultNode }) => (
    <>
      {mark('composer.sendButton')}
      {defaultNode}
    </>
  ),
  panel: ({ id, close }) =>
    id === 'mark' ? (
      <div className="demo-panel">
        <header>
          {mark('panel')}
          <button type="button" className="demo-chip" onClick={close}>
            关闭
          </button>
        </header>
      </div>
    ) : null,
};

export default function Slots() {
  const runtime = useChatRuntime({ sessionId: 'mock-session-debug', adapter });
  if (!runtime) return null;
  return <Chatkit runtime={runtime} slots={slots} />;
}
插槽新窗口打开

调整内置功能的位置

feature-placement.tsx
import '@volcengine/chatkit/styles.css';

import {
  AttachmentButton,
  Chatkit,
  type ChatkitSpeechRecognizer,
  type ChatkitToolbarSlotName,
  type ChatkitUploader,
  useChatRuntime,
  VoiceInput,
} from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import { useState } from 'react';

import { localUpload } from '../backends/local-upload';

const adapter = createMockAdapter({});

// 上传和语音识别由接入方实现,这里用本地地址和一段固定文字代替。
const upload: ChatkitUploader = localUpload;

const recognize: ChatkitSpeechRecognizer = ({ onText, onEnd }) => {
  const timer = setTimeout(() => {
    onText('帮我写一份本周周报', true);
    onEnd();
  }, 600);
  return () => clearTimeout(timer);
};

// 附件按钮和语音按钮的三种摆法:不写 slot 用默认位置;写位置名换到那里;写 false 表示自己摆。
const PLACEMENTS: {
  label: string;
  attachments?: ChatkitToolbarSlotName | false;
  voice?: ChatkitToolbarSlotName | false;
}[] = [
  { label: '默认(附件在左,语音在右)' },
  { label: '两个对调', attachments: 'composer.toolbarEnd', voice: 'composer.toolbarStart' },
  { label: 'slot: false,自己摆', attachments: false, voice: false },
];

export default function FeaturePlacement() {
  const runtime = useChatRuntime({ sessionId: 'feature-placement', adapter });
  const [index, setIndex] = useState(0);
  if (!runtime) return null;
  const { attachments, voice } = PLACEMENTS[index]!;
  const selfPlaced = attachments === false;
  return (
    <div className="demo-column">
      <div className="demo-bar">
        {PLACEMENTS.map((item, itemIndex) => (
          <label key={item.label}>
            <input type="radio" checked={index === itemIndex} onChange={() => setIndex(itemIndex)} /> {item.label}
          </label>
        ))}
      </div>
      <div className="demo-main">
        <Chatkit
          runtime={runtime}
          settings={{ welcome: '换一种摆法,看输入框里两个按钮的位置' }}
          features={{
            attachments: { upload, ...(attachments === undefined ? {} : { slot: attachments }) },
            voice: { recognize, ...(voice === undefined ? {} : { slot: voice }) },
          }}
          // 自己摆:把导出的组件放进任意插槽,这里是输入框上方
          slots={
            selfPlaced
              ? {
                  'composer.top': (
                    <div className="demo-line">
                      <AttachmentButton />
                      <VoiceInput />
                    </div>
                  ),
                }
              : {}
          }
        />
      </div>
    </div>
  );
}
调整内置功能的位置新窗口打开

在聊天区之外使用内置组件

provider.tsx
import '@volcengine/chatkit/styles.css';

import {
  AttachmentButton,
  Chatkit,
  ChatkitProvider,
  type ChatkitUploader,
  useChatRuntime,
  useComposer,
} from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';

import { localUpload } from '../backends/local-upload';

const adapter = createMockAdapter({});

const TEMPLATE = '请按「背景 / 目标 / 方案 / 风险」四段,帮我写一份方案:';

// 上传由接入方实现,这里用本地地址代替。
const upload: ChatkitUploader = localUpload;

// 聊天区之外的按钮:公开 hooks 在 ChatkitProvider 下面的任何组件里都能用
function InsertTemplate() {
  const composer = useComposer();
  return (
    <button
      type="button"
      className="demo-chip"
      onClick={() => {
        composer.setText(TEMPLATE);
        composer.focus();
      }}
    >
      插入模板
    </button>
  );
}

// ChatkitProvider 把页面包起来:附件按钮和「插入模板」都在页面自己的头部,不在聊天区里。
// props 写在 Provider 上,里面的 <Chatkit /> 从它取。
export default function Provider() {
  const runtime = useChatRuntime({ sessionId: 'provider', adapter });
  if (!runtime) return null;
  return (
    <ChatkitProvider
      runtime={runtime}
      features={{ attachments: { upload, slot: false } }}
      settings={{ welcome: '附件按钮在页面头部', recommendations: ['帮我写一份方案'] }}
    >
      <div className="demo-column">
        <header className="demo-bar">
          <strong>我的应用</strong>
          <InsertTemplate />
          <AttachmentButton />
        </header>
        <div className="demo-main">
          <Chatkit />
        </div>
      </div>
    </ChatkitProvider>
  );
}
在聊天区之外使用内置组件新窗口打开

业务卡片

custom-card.tsx
import '@volcengine/chatkit/styles.css';

import { Chatkit, type ChatkitDataPartRenderer, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import { useState } from 'react';

// Mock 的这个场景在回答里带一块类型为 acme.order-card 的数据;
// 接真实后端时,由适配器的 transformOutput 从后端的输出里取出同样的数据。
const adapter = createMockAdapter({ getScenarioId: () => 'custom-data' });

interface Order {
  orderId: string;
  item: string;
  count: number;
  amount: string;
  status: string;
}

// 按数据类型登记的组件:data 是后端给的,原样交过来
const OrderCard: ChatkitDataPartRenderer = ({ data }) => {
  const order = data as Order;
  return (
    <div className="demo-card">
      <header>
        订单 {order.orderId}
        <span className="demo-badge">{order.status}</span>
      </header>
      {order.item} × {order.count}
      <div>
        <strong>{order.amount}</strong>
      </div>
    </div>
  );
};

// 业务卡片:renderers.parts 按数据类型找组件。没有登记的类型不显示,不会把 JSON 显示给用户。
export default function CustomCard() {
  const runtime = useChatRuntime({ sessionId: 'custom-card', adapter });
  const [registered, setRegistered] = useState(true);
  if (!runtime) return null;
  return (
    <div className="demo-column">
      <div className="demo-bar">
        <label>
          <input type="checkbox" checked={registered} onChange={event => setRegistered(event.target.checked)} />
          登记 acme.order-card 的组件
        </label>
      </div>
      <div className="demo-main">
        <Chatkit
          runtime={runtime}
          settings={{ recommendations: ['帮我买两台云服务器'] }}
          renderers={{ parts: registered ? { 'acme.order-card': OrderCard } : {} }}
        />
      </div>
    </div>
  );
}
业务卡片新窗口打开

工具展示

tool-renderers.tsx
import { Chatkit, type ChatkitToolViewProps, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import '@volcengine/chatkit/styles.css';

// Mock 的这个场景依次调用四个工具:report_heartbeat、query_order、shell、web_search。
const adapter = createMockAdapter({ getScenarioId: () => 'product-tools' });

interface OrderResult {
  orderId: string;
  status: string;
  item: string;
  carrier: string;
  trackingNo: string;
}

// 换展开区:行头不变,展开后是自己的组件
function OrderDetail({ result, status }: ChatkitToolViewProps) {
  if (status !== 'completed') return <p>正在查询订单…</p>;
  const order = result as OrderResult;
  return (
    <div className="demo-card">
      <header>
        订单 {order.orderId}
        <span className="demo-badge">{order.status}</span>
      </header>
      {order.item}
      <div className="demo-muted">
        {order.carrier} {order.trackingNo}
      </div>
    </div>
  );
}

// 整行自己画
function ShellRow({ args, status }: ChatkitToolViewProps) {
  return (
    <div className="demo-chips">
      <span className="demo-badge">{status === 'completed' ? '已执行' : '执行中'}</span>
      <code>{String(args?.command ?? '')}</code>
    </div>
  );
}

// 工具展示的四档,从改得最少到改得最多。
export default function ToolRenderers() {
  const runtime = useChatRuntime({ sessionId: 'tool-renderers', adapter });
  if (!runtime) return null;
  return (
    <Chatkit
      runtime={runtime}
      settings={{
        tools: { groupDefaultExpanded: true, detailDefaultExpanded: true },
        runStatus: { collapseOnComplete: false },
        recommendations: ['查一下我的订单到哪了'],
      }}
      renderers={{
        tools: {
          web_search: { runningLabel: '正在查配送时效', successLabel: '已查到配送时效' }, // 只改文字
          query_order: { detail: OrderDetail }, // 换展开区
          shell: { render: ShellRow }, // 整行自己画
          report_heartbeat: { hidden: true }, // 不显示
        },
      }}
    />
  );
}
工具展示新窗口打开

失败卡片:原样默认、改文案与整卡自定义

此示例展示 SDK 原样默认、覆盖文案和整卡自定义三种模式。源码与已发布包可能不同;npm 接入请按实际安装版本核对类型声明及更新说明。查看三模式对照、Mock 错误码来源和本地构建步骤。

error-renderers.tsx
import '@volcengine/chatkit/styles.css';

import { Chatkit, useChatkitActions, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import type { CSSProperties } from 'react';
import { useState } from 'react';

// Mock 的 run-failed 场景在失败时给出 code: MODEL_OVERLOADED。
const adapter = createMockAdapter({ getScenarioId: () => 'run-failed' });

// 本文件可作为 Vite 的 src/App.tsx 使用;页面布局不依赖文档站的 demo-* 样式。
const buttonStyle = (active = false): CSSProperties => ({
  padding: '6px 10px',
  border: '1px solid #b8b7c3',
  borderRadius: 8,
  background: active ? '#0b0b0f' : '#fff',
  color: active ? '#fff' : '#0b0b0f',
  cursor: 'pointer',
});

// 整卡自定义:按钮发送一条新消息,不重跑失败的原 run。
function BusyCard({ text }: { text: string }) {
  const { send } = useChatkitActions();
  return (
    <div style={{ padding: 12, border: '1px solid #d6d6dd', borderRadius: 8, background: '#fff' }}>
      <header>整卡自定义:现在用的人有点多</header>
      <span style={{ color: '#666' }}>{text}</span>
      <footer>
        <button type="button" style={buttonStyle()} onClick={() => void send('请再试一次')}>
          发送新消息
        </button>
      </footer>
    </div>
  );
}

// 同一个 Mock 失败码、同一个精确 renderer:比较原样默认、覆盖文案和整卡自定义。
export default function ErrorRenderers() {
  const [mode, setMode] = useState<'sdk' | 'patch' | 'custom'>('sdk');
  const runtime = useChatRuntime({ sessionId: 'error-renderers', adapter });
  if (!runtime) return null;
  return (
    <div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', background: '#fff' }}>
      <div
        style={{
          display: 'flex',
          flexWrap: 'wrap',
          gap: 8,
          alignItems: 'center',
          padding: 12,
          borderBottom: '1px solid #e8e8ec',
        }}
      >
        <strong>
          Mock <code>run-failed</code> → <code>MODEL_OVERLOADED</code> → 精确键命中
        </strong>
        <button
          type="button"
          style={buttonStyle(mode === 'sdk')}
          aria-pressed={mode === 'sdk'}
          onClick={() => setMode('sdk')}
        >
          SDK 原样默认
        </button>
        <button
          type="button"
          style={buttonStyle(mode === 'patch')}
          aria-pressed={mode === 'patch'}
          onClick={() => setMode('patch')}
        >
          覆盖默认卡片文案
        </button>
        <button
          type="button"
          style={buttonStyle(mode === 'custom')}
          aria-pressed={mode === 'custom'}
          onClick={() => setMode('custom')}
        >
          整卡自定义
        </button>
      </div>
      <div style={{ flex: 1, minHeight: 0, minWidth: 0 }}>
        <Chatkit
          runtime={runtime}
          settings={{ recommendations: ['开始一个会失败的任务'] }}
          renderers={{
            errors: {
              MODEL_OVERLOADED: ({ formattedContent, renderDefault }) =>
                mode === 'sdk' ? (
                  renderDefault()
                ) : mode === 'patch' ? (
                  renderDefault({
                    title: 'renderDefault:模型暂时繁忙',
                    content: '**正文已由宿主覆盖。**\n\n卡片样式仍由 Chatkit 绘制。',
                  })
                ) : (
                  <BusyCard text={formattedContent} />
                ),
              '*': ({ defaultNode, code }) => (
                <>
                  {defaultNode}
                  <small style={{ color: '#666' }}>错误码 {code},可以反馈给管理员</small>
                </>
              ),
            },
          }}
        />
      </div>
    </div>
  );
}
Mock 错误码命中精确键;切换 SDK 原样默认、覆盖文案与整卡自定义新窗口打开

系统提示

conversation-events.tsx
import { Chatkit, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import '@volcengine/chatkit/styles.css';

const adapter = createMockAdapter({ getScenarioId: () => 'context-compaction' });

// 系统提示(上下文已压缩):没有头像、没有操作栏的提示条,插在用户消息与回答之间。
// 内置的 chatkit.notice 不用登记;自己的类型登记在 renderers.events 里。
export default function ConversationEvents() {
  const runtime = useChatRuntime({ sessionId: 'conversation-events', adapter });
  if (!runtime) return null;
  return <Chatkit runtime={runtime} settings={{ recommendations: ['继续我们刚才的讨论'] }} />;
}
系统提示新窗口打开

发送前处理

on-send.tsx
import '@volcengine/chatkit/styles.css';

import { Chatkit, type ChatkitSendInput, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import { useState } from 'react';

const adapter = createMockAdapter({});

const BLOCKED_WORD = '密码';

// onSend 在每次发送之前调用:返回新的输入表示改写,返回 false 表示拦下(草稿保留)。
// 右侧显示改写之后、交给适配器的输入。
export default function OnSend() {
  const runtime = useChatRuntime({ sessionId: 'on-send', adapter });
  const [sent, setSent] = useState<ChatkitSendInput>();
  const [blocked, setBlocked] = useState(false);
  if (!runtime) return null;
  return (
    <div className="demo-row">
      <div className="demo-main">
        <Chatkit
          runtime={runtime}
          settings={{
            welcome: '发送前先处理',
            placeholder: `带「${BLOCKED_WORD}」两个字的消息会被拦下`,
            recommendations: ['帮我查一下 chatkit 怎么接入', '我的密码是多少'],
          }}
          onSend={input => {
            const text = input.content.map(part => (part.type === 'text' ? part.text : part.type === 'reference' ? part.label : '')).join('');
            setBlocked(text.includes(BLOCKED_WORD));
            if (text.includes(BLOCKED_WORD)) return false;
            const next = { ...input, metadata: { ...input.metadata, knowledgeBaseId: 'kb-handbook', page: '/docs' } };
            setSent(next);
            return next;
          }}
        />
      </div>
      <aside className="demo-side">
        <h4>后端收到</h4>
        {blocked ? <p>这次发送被拦下了,没有到后端。</p> : null}
        {sent ? <pre>{JSON.stringify(sent, null, 2)}</pre> : <p className="demo-muted">发一句话看看</p>}
      </aside>
    </div>
  );
}
发送前处理新窗口打开

主题

theming.tsx
import { Chatkit, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import '@volcengine/chatkit/styles.css';
import { useState } from 'react';

const adapter = createMockAdapter();

// 主题:root.className 直接落在 .chatkit-wrapper 上,theme.appearance 选择实例明暗。
// token 默认声明特异性为 0,普通宿主规则即可覆盖。完整清单见本页下方。
const THEME_CSS = `
.chatkit-wrapper.theme-brand {
  --chatkit-color-primary: #6d28d9;
  --chatkit-color-primary-hover: #7c3aed;
  --chatkit-color-link: #7c3aed;
  --chatkit-color-primary-subtle: #f3edff;
  --chatkit-color-bg-subtle: #f5f0ff;
  --chatkit-color-bg-hover: #ede4ff;
  --chatkit-color-border-strong: #c4b5fd;
}
.chatkit-wrapper.theme-warm {
  --chatkit-color-bg: #fffaf3;
  --chatkit-color-bg-subtle: #fbeedd;
  --chatkit-color-bg-hover: #f7e3c8;
  --chatkit-color-border: #f1dcc0;
  --chatkit-color-border-strong: #e3bf8f;
  --chatkit-color-text: #3b2a1a;
  --chatkit-color-primary: #c2410c;
  --chatkit-color-primary-hover: #ea580c;
  --chatkit-color-link: #c2410c;
  --chatkit-radius-lg: 14px;
  --chatkit-radius-2xl: 24px;
  --chatkit-font-family: Georgia, 'Songti SC', serif;
}
/* 透明底:聊天区透出宿主页面自己的背景 */
.theme-transparent {
  background: linear-gradient(135deg, #e0f2fe 0%, #fce7f3 100%);
}
.chatkit-wrapper.theme-transparent {
  --chatkit-color-bg: transparent;
  --chatkit-color-bg-elevated: #ffffff;
  --chatkit-color-bg-subtle: rgb(255 255 255 / 70%);
}
`;

const THEMES = [
  { id: '', label: '默认' },
  { id: 'theme-brand', label: '品牌紫' },
  { id: 'theme-warm', label: '暖色大圆角' },
  { id: 'theme-dark', label: '完整深色' },
  { id: 'theme-transparent', label: '透明底' },
];

export default function Theming() {
  const runtime = useChatRuntime({ sessionId: 'mock-session-css', adapter });
  const [theme, setTheme] = useState('theme-brand');
  if (!runtime) return null;
  return (
    <div className="demo-column">
      <style>{THEME_CSS}</style>
      <div className="demo-bar">
        {THEMES.map(item => (
          <label key={item.label}>
            <input type="radio" checked={item.id === theme} onChange={() => setTheme(item.id)} /> {item.label}
          </label>
        ))}
      </div>
      <div className={`demo-main ${theme === 'theme-transparent' ? theme : ''}`}>
        <Chatkit
          runtime={runtime}
          theme={{ appearance: theme === 'theme-dark' ? 'dark' : 'inherit' }}
          root={{ className: theme }}
        />
      </div>
    </div>
  );
}
主题新窗口打开

示例:技能选择

recipe-skills.tsx
import { Chatkit, type ChatkitSendInput, useChatRuntime, useComposer } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import '@volcengine/chatkit/styles.css';
import { useState } from 'react';

const adapter = createMockAdapter({});

const SKILLS = [
  { id: 'weekly-report', label: '写周报' },
  { id: 'spreadsheet', label: '做表格' },
  { id: 'research', label: '查资料' },
];

// 工具栏上的「技能」按钮:选中的技能用 insertReference 作为标签插进输入框
function SkillMenu() {
  const composer = useComposer();
  const [open, setOpen] = useState(false);
  return (
    <span className="demo-menu">
      <button type="button" className="demo-chip" onClick={() => setOpen(value => !value)}>
        技能
      </button>
      {open ? (
        <ul>
          {SKILLS.map(skill => (
            <li key={skill.id}>
              <button
                type="button"
                onClick={() => {
                  composer.insertReference({ kind: 'skill', id: skill.id, label: skill.label });
                  composer.focus();
                  setOpen(false);
                }}
              >
                {skill.label}
              </button>
            </li>
          ))}
        </ul>
      ) : null}
    </span>
  );
}

// 技能选择只用到一个插槽和一个 hook。发送时引用随消息一起发出,后端从 references 里读到技能的 id;
// 右侧用 onSend 把发出的内容显示出来。
export default function RecipeSkills() {
  const runtime = useChatRuntime({ sessionId: 'recipe-skills', adapter });
  const [sent, setSent] = useState<ChatkitSendInput['content']>();
  if (!runtime) return null;
  return (
    <div className="demo-row">
      <div className="demo-main">
        <Chatkit
          runtime={runtime}
          settings={{ welcome: '先选一个技能', placeholder: '选一个技能,再写要求' }}
          slots={{ 'composer.toolbarStart': <SkillMenu /> }}
          onSend={input => setSent(input.content)}
        />
      </div>
      <aside className="demo-side">
        <h4>随消息发出的内容</h4>
        {sent ? <pre>{JSON.stringify(sent, null, 2)}</pre> : <p className="demo-muted">选一个技能,发一句话</p>}
      </aside>
    </div>
  );
}
示例:技能选择新窗口打开

示例:连接器

recipe-connectors.tsx
import { Chatkit, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import '@volcengine/chatkit/styles.css';
import { useState } from 'react';

const adapter = createMockAdapter({});

const CONNECTORS = [
  { id: 'calendar', label: '日历' },
  { id: 'mail', label: '邮箱' },
  { id: 'repo', label: '代码仓库' },
];

// 工具栏上的「连接器」按钮:点开是服务列表,每个服务一个开关
function ConnectorMenu({ enabled, onToggle }: { enabled: string[]; onToggle: (id: string) => void }) {
  const [open, setOpen] = useState(false);
  return (
    <span className="demo-menu">
      <button type="button" className="demo-chip" onClick={() => setOpen(value => !value)}>
        连接器 {enabled.length}
      </button>
      {open ? (
        <ul>
          {CONNECTORS.map(item => (
            <li key={item.id}>
              <button type="button" onClick={() => onToggle(item.id)}>
                {item.label}
                <span className="demo-badge">{enabled.includes(item.id) ? '已开启' : '未开启'}</span>
              </button>
            </li>
          ))}
        </ul>
      ) : null}
    </span>
  );
}

// 连接器用到两个插槽和 onSend:开启的服务显示在输入框上方,发送时把它们的 id 放进 metadata。
export default function RecipeConnectors() {
  const runtime = useChatRuntime({ sessionId: 'recipe-connectors', adapter });
  const [enabled, setEnabled] = useState(['calendar']);
  const [received, setReceived] = useState<unknown>();
  if (!runtime) return null;
  const toggle = (id: string) =>
    setEnabled(current => (current.includes(id) ? current.filter(item => item !== id) : [...current, id]));
  return (
    <div className="demo-row">
      <div className="demo-main">
        <Chatkit
          runtime={runtime}
          settings={{ welcome: '先开一个连接器', recommendations: ['看看我明天有什么安排'] }}
          slots={{
            'composer.toolbarStart': <ConnectorMenu enabled={enabled} onToggle={toggle} />,
            'composer.top': (
              <div className="demo-chips">
                这次对话可以使用:
                {enabled.length === 0 ? <span className="demo-muted">没有开启的服务</span> : null}
                {CONNECTORS.filter(item => enabled.includes(item.id)).map(item => (
                  <span key={item.id} className="demo-badge">
                    {item.label}
                  </span>
                ))}
              </div>
            ),
          }}
          onSend={input => {
            const next = { ...input, metadata: { ...input.metadata, connectors: enabled } };
            setReceived(next.metadata);
            return next;
          }}
        />
      </div>
      <aside className="demo-side">
        <h4>后端收到的 metadata</h4>
        {received ? <pre>{JSON.stringify(received, null, 2)}</pre> : <p className="demo-muted">发一句话看看</p>}
      </aside>
    </div>
  );
}
示例:连接器新窗口打开

示例:知识库

recipe-knowledge-base.tsx
import '@volcengine/chatkit/styles.css';

import { Chatkit, type ChatkitDataPartRenderer, useChatkitActions, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import { useState } from 'react';

const BASES = [
  { id: 'kb-handbook', label: '产品手册' },
  { id: 'kb-faq', label: '常见问题' },
];

const DOCUMENTS = [
  { id: 'doc-1', title: '接入指南', text: '接入方只需要实现适配器:把后端的输出翻译成界面认识的内容。' },
  { id: 'doc-2', title: '会话接口说明', text: '会话的新建、重命名、删除由适配器的 sessions 提供。' },
];

// 后端返回的引用来源由适配器的 transformOutput 变成一块数据;这里让 Mock 在最后一段回答之后带上它
const adapter = createMockAdapter({
  transformOutput: (step, context) => {
    const parts = (() => {
      return step.kind === 'text' && step.text.includes('## 结论')
        ? [{ id: 'citations', type: 'data' as const, kind: 'acme.citations', data: DOCUMENTS.map(item => item.id) }]
        : undefined;
    })();
    return parts && context.messageId
      ? [
          ...context.defaultChanges,
          ...parts.map(part => ({ type: 'set-part' as const, messageId: context.messageId!, part })),
        ]
      : undefined;
  },
});

// 引用来源:点一条在右侧面板看原文
function Citations({ ids }: { ids: string[] }) {
  const { openPanel } = useChatkitActions();
  return (
    <div className="demo-chips">
      引用来源:
      {DOCUMENTS.filter(item => ids.includes(item.id)).map(item => (
        <button key={item.id} type="button" className="demo-chip" onClick={() => openPanel(`doc:${item.id}`)}>
          {item.title}
        </button>
      ))}
    </div>
  );
}

const renderCitations: ChatkitDataPartRenderer = ({ data }) => <Citations ids={data as string[]} />;

// 知识库:工具栏上选范围(composer.toolbarStart),onSend 把选中的 id 放进 metadata,
// 回答里的引用来源用 renderers.parts 显示,原文在右侧面板(panel)里看。
export default function RecipeKnowledgeBase() {
  const runtime = useChatRuntime({ sessionId: 'recipe-knowledge-base', adapter });
  const [selected, setSelected] = useState(['kb-handbook']);
  if (!runtime) return null;
  const toggle = (id: string) =>
    setSelected(current => (current.includes(id) ? current.filter(item => item !== id) : [...current, id]));
  return (
    <Chatkit
      runtime={runtime}
      settings={{ welcome: '回答会参考选中的知识库', recommendations: ['chatkit 怎么接入'] }}
      slots={{
        'composer.toolbarStart': BASES.map(base => (
          <button
            key={base.id}
            type="button"
            className="demo-chip"
            aria-pressed={selected.includes(base.id)}
            onClick={() => toggle(base.id)}
          >
            {base.label}
          </button>
        )),
        panel: ({ id, close }) => {
          const document = DOCUMENTS.find(item => `doc:${item.id}` === id);
          return document ? (
            <div className="demo-panel">
              <header>
                {document.title}
                <button type="button" className="demo-chip" onClick={close}>
                  关闭
                </button>
              </header>
              {document.text}
            </div>
          ) : null;
        },
      }}
      onSend={input => ({ ...input, metadata: { ...input.metadata, knowledgeBaseIds: selected } })}
      renderers={{ parts: { 'acme.citations': renderCitations } }}
    />
  );
}
示例:知识库新窗口打开

示例:收藏与分享

recipe-favorites.tsx
import { useDemoSessions } from '../sessions';

import { Chatkit, SessionList, useCurrentSession } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import '@volcengine/chatkit/styles.css';
import { useState } from 'react';

const adapter = createMockAdapter();

// 回答操作栏里的「收藏」:用公开 hook 拿到当前会话,收藏时记下这条消息属于哪个会话
function FavoriteButton({ active, onToggle }: { active: boolean; onToggle: (sessionId: string) => void }) {
  const { session } = useCurrentSession();
  return (
    <button type="button" className="demo-chip" aria-pressed={active} onClick={() => session && onToggle(session.id)}>
      {active ? '★ 已收藏' : '☆ 收藏'}
    </button>
  );
}

// 收藏与分享只用插槽:回答下方的按钮放在 message.actions,
// 会话列表每一行的「分享会话」和收藏标记放在 SessionList 自己的两个位置 itemActions、itemBadge。
export default function RecipeFavorites() {
  const { chat: sessions, listProps } = useDemoSessions({ adapter });
  // 业务方自己的状态:消息 id → 所在的会话 id
  const [favorites, setFavorites] = useState<Record<string, string>>({});
  const toggle = (messageId: string, sessionId: string) =>
    setFavorites(({ [messageId]: existing, ...rest }) => (existing ? rest : { ...rest, [messageId]: sessionId }));
  return (
    <div className="demo-row">
      <SessionList
        {...listProps}
        style={{ width: 200 }}
        itemBadge={({ session }) => (Object.values(favorites).includes(session.id) ? <span>★</span> : null)}
        itemActions={({ session }) => (
          <button type="button" onClick={() => window.alert(`分享链接:https://example.com/s/${session.id}`)}>
            分享会话
          </button>
        )}
      />
      <div className="demo-main">
        <Chatkit
          sessions={sessions}
          slots={{
            'message.actions': ({ message }) => (
              <FavoriteButton active={message.id in favorites} onToggle={sessionId => toggle(message.id, sessionId)} />
            ),
          }}
        />
      </div>
    </div>
  );
}
示例:收藏与分享新窗口打开

示例:定时任务

recipe-schedules.tsx
import { useDemoSessions } from '../sessions';

import '@volcengine/chatkit/styles.css';

import { Chatkit, type ChatkitDataPartRenderer, SessionList, useChatkitActions } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import { createContext, useContext, useState } from 'react';

const SCHEDULE = { id: 'schedule-1', name: '每天查一次 chatkit 的更新', time: '每天 09:00' };

// 让 Mock 在回答的最后带上一块任务数据;真实接入时由适配器的 transformOutput 从后端的输出里取
const adapter = createMockAdapter({
  transformOutput: (step, context) => {
    const parts = (() => {
      return step.kind === 'text' && step.text.includes('## 结论')
        ? [{ id: 'schedule', type: 'data' as const, kind: 'acme.schedule', data: SCHEDULE }]
        : undefined;
    })();
    return parts && context.messageId
      ? [
          ...context.defaultChanges,
          ...parts.map(part => ({ type: 'set-part' as const, messageId: context.messageId!, part })),
        ]
      : undefined;
  },
});

// 业务方自己的状态:任务是否暂停。卡片和面板读同一份
const Paused = createContext({ paused: false, toggle: () => {} });

function PauseButton() {
  const { paused, toggle } = useContext(Paused);
  return (
    <button type="button" className="demo-chip" onClick={toggle}>
      {paused ? '恢复' : '暂停'}
    </button>
  );
}

// 回答里的任务卡片
function ScheduleCard({ schedule }: { schedule: typeof SCHEDULE }) {
  const { paused } = useContext(Paused);
  const { openPanel } = useChatkitActions();
  return (
    <div className="demo-card">
      <header>
        {schedule.name}
        <span className="demo-badge">{paused ? '已暂停' : '运行中'}</span>
      </header>
      {schedule.time}
      <footer>
        <PauseButton />
        <button type="button" className="demo-chip" onClick={() => openPanel('schedules')}>
          全部任务
        </button>
      </footer>
    </div>
  );
}

const renderSchedule: ChatkitDataPartRenderer = ({ data }) => <ScheduleCard schedule={data as typeof SCHEDULE} />;

function SchedulesEntry() {
  const { openPanel } = useChatkitActions();
  return (
    <div className="demo-line">
      <button type="button" className="demo-chip" onClick={() => openPanel('schedules')}>
        定时任务
      </button>
    </div>
  );
}

// 定时任务:回答里的任务卡片用 renderers.parts,入口在 header,管理界面在 panel,
// 由定时任务产生的会话用 SessionList 的 itemBadge 标出来(真实接入时读 session.metadata)。
export default function RecipeSchedules() {
  const { chat: sessions, listProps } = useDemoSessions({ adapter });
  const [paused, setPaused] = useState(false);
  return (
    <Paused.Provider value={{ paused, toggle: () => setPaused(value => !value) }}>
      <div className="demo-row">
        <SessionList
          {...listProps}
          style={{ width: 190 }}
          itemBadge={({ session }) =>
            session.id === 'mock-session-weekly' ? <span className="demo-badge">定时</span> : null
          }
        />
        <div className="demo-main">
          <Chatkit
            sessions={sessions}
            settings={{ placeholder: '比如:每天早上 9 点查一次 chatkit 的更新' }}
            renderers={{ parts: { 'acme.schedule': renderSchedule } }}
            slots={{
              header: <SchedulesEntry />,
              panel: ({ id, close }) =>
                id === 'schedules' ? (
                  <div className="demo-panel">
                    <header>
                      定时任务
                      <button type="button" className="demo-chip" onClick={close}>
                        关闭
                      </button>
                    </header>
                    <div className="demo-card">
                      <header>
                        {SCHEDULE.name}
                        <span className="demo-badge">{paused ? '已暂停' : '运行中'}</span>
                      </header>
                      {SCHEDULE.time}
                      <footer>
                        <PauseButton />
                      </footer>
                    </div>
                  </div>
                ) : null,
            }}
          />
        </div>
      </div>
    </Paused.Provider>
  );
}
示例:定时任务新窗口打开

事件

events.tsx
import '@volcengine/chatkit/styles.css';

import { Chatkit, type ChatkitEvent, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import { useState } from 'react';

const adapter = createMockAdapter({});

// 事件:SDK 自身不上报数据,只调 onEvent,上报到哪里由接入方决定。右侧列出收到的事件,每一轮最后有一条 turn_summary(结论与首字延迟);
// eventAttributes 给每个事件带上自己的维度。
export default function Events() {
  const runtime = useChatRuntime({
    sessionId: 'events',
    adapter,
    eventAttributes: { common: () => ({ page: 'docs-demo' }) },
  });
  const [events, setEvents] = useState<ChatkitEvent[]>([]);
  if (!runtime) return null;
  return (
    <div className="demo-row">
      <div className="demo-main">
        <Chatkit
          runtime={runtime}
          settings={{ recommendations: ['帮我查一下 chatkit 怎么接入'] }}
          onEvent={event => setEvents(current => [event, ...current].slice(0, 40))}
        />
      </div>
      <aside className="demo-side">
        <h4>onEvent 收到的事件</h4>
        {events.length === 0 ? <p className="demo-muted">发一句话,事件会出现在这里</p> : null}
        {events.map((event, index) => (
          <div key={`${event.ts}-${index}`}>
            <strong>{event.name}</strong>
            {event.name === 'run_settled' ? <span> → {event.payload.outcome}</span> : null}
            {event.name === 'turn_summary' ? (
              <span>
                {' '}
                → {event.payload.outcome}
                {event.payload.firstPaintMs === undefined ? '' : ` · 首字 ${Math.round(event.payload.firstPaintMs)} ms`}
              </span>
            ) : null}
          </div>
        ))}
      </aside>
    </div>
  );
}
事件新窗口打开

自定义适配器

custom-adapter.tsx
import '@volcengine/chatkit/styles.css';

import { Chatkit, useChatRuntime } from '@volcengine/chatkit';
import { type AdapterProtocol, buildAcceptedChatInput, type ChatInput, chatInputText, createAdapter } from '@volcengine/chatkit/core';
import { useMemo } from 'react';

type EchoInput = ChatInput;

const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));

type EchoEvent = { type: 'text'; text: string } | { type: 'done' } | { type: 'error' };
const protocol: AdapterProtocol<EchoInput, EchoInput, EchoEvent> = {
  encodeInput: input => input,
  projectInput: (input, { runId }) => [buildAcceptedChatInput(runId, input)],
  decodeEvent: (event, { runId }) =>
    event.type === 'done'
      ? [{ type: 'completed' }]
      : event.type === 'error'
      ? [{ type: 'failed', error: { code: 'ECHO_REFUSED', message: '回显服务拒绝了这个请求' } }]
      : [
          {
            type: 'message',
            message: {
              id: `assistant:${runId}`,
              runId,
              role: 'assistant',
              parts: [{ id: 'text', type: 'text', text: event.text }],
            },
          },
        ],
};
function createEchoAdapter() {
  return createAdapter({
    protocol,
    run: async ({ body }) => ({
      async *[Symbol.asyncIterator]() {
        if (chatInputText(body).trim() === '失败') {
          yield { data: { type: 'error' as const } };
          return;
        }
        const text = `收到:「${chatInputText(body)}」。这段回答通过公共 createAdapter 流式显示。`;
        for (let i = 0; i < text.length; i += 2) {
          await sleep(40);
          yield { eventId: `e${i}`, data: { type: 'text' as const, text: text.slice(0, i + 2) } };
        }
        yield { data: { type: 'done' as const } };
      },
    }),
  });
}

export default function CustomAdapter() {
  const adapter = useMemo(() => createEchoAdapter(), []);
  const runtime = useChatRuntime({ sessionId: 'custom-adapter', adapter });
  if (!runtime) return null;
  return <Chatkit runtime={runtime} settings={{ welcome: '这个后端只会回显', recommendations: ['早上好', '失败'] }} />;
}
自定义适配器新窗口打开