知识库

让用户选择回答时参考哪些知识库,并在回答下方显示引用来源。本页用插槽、onSend 和业务卡片实现它。

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 } }}
    />
  );
}
工具栏上选知识库;回答的最后是引用来源,点一条在右侧面板看原文新窗口打开

在线示例使用固定文档和脚本回放的回答,选择知识库只改变发送前的 metadata,不会真的改变检索范围。真实后端需要校验访问权限、执行检索并返回引用数据;DocumentPreview 等组件与接口由业务应用提供。

这个功能是什么

分两部分:

  1. 提问时选范围:输入框工具栏上选知识库,选中的显示在输入框上方;发送时把知识库 id 带给后端。
  2. 回答里显示来源:后端返回引用的文档,回答里显示来源列表;点一条在右侧面板看原文。

用到的入口

入口用来做什么
slots['composer.toolbarStart']放「知识库」选择
slots['composer.top']显示已选的知识库
onSend把知识库 id 放进 metadata
renderers.parts把后端返回的引用来源显示成列表
slots.panel 与 openPanel在右侧面板看原文

代码

import { useState } from 'react';
import { Chatkit, useChatkitActions, useChatSessions, type ChatkitDataPartRenderer } from '@volcengine/chatkit';

function Citations({ items }: { items: Citation[] }) {
  const { openPanel } = useChatkitActions();
  return (
    <ol>
      {items.map(item => (
        <li key={item.id}>
          <button onClick={() => openPanel(`doc:${item.id}`)}>{item.title}</button>
        </li>
      ))}
    </ol>
  );
}

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

export function App() {
  const sessions = useChatSessions({ adapter, activeSessionId });
  const [selected, setSelected] = useState<string[]>([]);

  return (
    <Chatkit
      sessions={sessions}
      slots={{
        'composer.toolbarStart': <KnowledgeBasePicker value={selected} onChange={setSelected} />,
        'composer.top': selected.length > 0 ? <SelectedKnowledgeBases ids={selected} /> : null,
        panel: ({ id, close }) =>
          id?.startsWith('doc:') ? <DocumentPreview id={id.slice(4)} onClose={close} /> : null,
      }}
      onSend={input => ({ ...input, metadata: { ...input.metadata, knowledgeBaseIds: selected } })}
      renderers={{ parts: { 'acme.citations': renderCitations } }}
    />
  );
}

后端返回的引用来源怎么变成 acme.citations 这块数据,见业务卡片的 transformOutput。

变化

  • 把知识库里的文件作为附件:await useComposer().addAttachmentsAsync([{ id, name, mimeType, size, source: { type: 'resource', reference } }], { placement: 'separate' })。稳定资源不经过本地上传,但需要开启 features.attachments.allowUploaded 且输入框就绪;检查结果里的 accepted / rejected。见附件。
  • 正文里的角标:后端在正文里输出链接,用 renderers.markdown 的 a 换成角标组件。
  • 没有选知识库时不允许发送:onSend 里返回 false 并提示。

上例的 selected 是页面状态,切换会话不会自动保存各会话的选择。来源数据要在历史中保留或可重新构造,刷新后才能继续显示;右侧面板的 id 只是界面路由标识,不会自动下载文档或校验权限。

为什么不内置

知识库怎么组织、权限怎么控制、检索结果是什么结构,都需要业务方和自己的后端约定。chatkit 提供的是放选择入口的位置、带数据的通道和显示来源的方式。