完整示例:对话应用

一个对话应用的接入示例,组件代码放在一个文件中、使用公开接口:左边会话列表,中间聊天区,右边是任务进度和常用提问;附件、赞踩、@ 提及都开着;页面自己加了公告、知识库选择、业务卡片、轮次计数和右侧栏。代码从上到下每一段对应文档里的一页。示例的 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>
  );
}
点推荐问题,或者切换左侧的会话新窗口打开

代码里的每一段

代码里的部分做了什么详见
createMockAdapter对接后端;这里用不连接后端、按脚本回放的示例适配器接入后端、Mock 适配器
useChatSessions一个适配器得到会话列表和每个会话的运行时两种用法
SessionList会话列表,每一行的菜单里加了「收藏」会话列表
settings欢迎语、推荐问题、占位文字欢迎语与推荐问题
features.attachments附件,上传由页面实现附件
features.feedback赞踩,选中态由页面保存赞踩
features.mentions@ 提及的候选项@ 与 /
slots消息区上方的公告、工具栏的知识库按钮、输入框下方的提示插槽
renderers.parts参考资料卡片业务卡片
renderers.tools搜索工具换了文字工具展示
renderers.errors模型繁忙时的失败卡片失败卡片与系统提示
onSend在发送输入的 metadata 中记录选中的知识库发送前处理
onEvent累计当前页面收到的 run_settled 事件事件
ChatkitProvider把页面包起来,顶栏和右侧栏才能用公开 hooksHooks
AppHeader、AppRail页面自己的顶栏和右侧栏:会话标题、这一轮的状态、常用提问Hooks

没有出现在代码里、但界面上有的功能(重新生成、编辑后重发、划词追问)默认开启:重新生成、编辑后重发还需适配器具备对应能力,划词追问不依赖这两项能力,见内置功能总览。

示例的轮次计数累加所有已打开会话的 run_settled,包含失败和停止,不等于当前会话的成功轮数。上传使用本地对象 URL,赞踩、收藏和知识库选择保存在页面状态中;生产接入需补充上传服务、持久化和统计筛选。

给一个客服 Agent 做对话页面:七步

以「已经有一个客服 Agent 的后端,要给它做对话页面」为例:

步骤做什么谁来做对应上面的代码
1在业务前端的工程里安装 @volcengine/chatkit业务前端import
2按后端的协议选适配器,为要用的每项能力写一个发请求的函数业务前端createMockAdapter 换成真实适配器
3在业务自己的服务端加一个代理:校验登录态,带上密钥,转发给 Agent 后端业务后端能力函数里的地址指向它
4填 settings,在 features 里给上传、赞踩这些函数业务前端settings、features
5用 slots 放业务入口(工单号、知识库),用 renderers 显示业务卡片业务前端slots、renderers
6用 createTelemetry 把事件接到业务自己的埋点,把 onError 接到错误监控业务前端createTelemetry、onError
7用 --chatkit-* 变量对齐品牌色业务前端--chatkit-* 样式变量(需自行添加)

怎么部署:chatkit 是一个前端的包,和业务前端一起构建、一起发布,没有需要单独部署的服务。第 3 步的代理是业务服务端里的一个接口,不是 chatkit 的组成部分。

上线前的检查项见上线清单。

换成真实后端

可复用页面结构和多数配置。上传需换成真实服务,transformOutput 要按目标协议取业务数据,onSend 中的 metadata 也要确认如何传给后端。OpenAI 兼容接口与 ADK 的完整写法,以及各后端上会显示哪些功能,见完整示例:接入真实后端。