适配器与内容扩展 API

ChatKit 负责通用的对话交互、协议翻译和运行状态。接入方提供后端能力函数,决定地址、鉴权与部署参数。普通接入只需要 run;有特殊内容时,再使用下面三个入口。

需求入口例子
请求字段与协议默认编码不同transformInput把选中的知识库写到请求 metadata
同一条聊天流或历史里带业务数据transformOutput工具标题总结、订单卡片、引用来源
数据由独立接口异步返回runtime.updateContent搜索结束后补充摘要或审批详情
只改显示方式renderers工具标题配图标,订单用业务组件显示
内容不属于对话应用自己的状态 + slots购物车、流程看板、客户资料

适配器没有业务状态订阅回调。独立业务状态由应用请求并保存,再交给插槽组件;不经过聊天消息也能显示。

三种扩展入口用户输入transformInput 调整请求体后端能力函数地址、鉴权、HTTP / WebSocket聊天流 / 历史 / 恢复transformOutput默认内容 + 业务变更独立业务接口runtime.updateContent同一份会话消息校验、批量提交title / metadata / data执行状态不能被补写ChatKit 渲染器保留 defaultNode或显示业务组件
协议负责执行;扩展负责业务内容;渲染器负责显示。
adapter-extensions.tsx
import '@volcengine/chatkit/styles.css';

import { Chatkit, type ChatkitRuntime, useChatRuntime } from '@volcengine/chatkit';
import { createOpenAiAdapter, type OpenAiChunk } from '@volcengine/chatkit/adapters/openai';
import type { StreamEvent } from '@volcengine/chatkit/core';
import { useState } from 'react';

// 模拟后端的结构化事件序列。真实 WebSocket 接入把订阅消息转换成同一形状。
const adapter = createOpenAiAdapter({
  run: async ({ body, runId }) =>
    (async function* (): AsyncGenerator<StreamEvent<OpenAiChunk>> {
      if (!body.messages.some(message => message.role === 'tool')) {
        yield {
          eventId: `${runId}:1`,
          data: {
            choices: [
              {
                index: 0,
                delta: {
                  tool_calls: [
                    {
                      index: 0,
                      id: `lookup:${runId}`,
                      type: 'function',
                      function: { name: 'lookup', arguments: '{"orderId":"A1024"}' },
                    },
                  ],
                },
              },
            ],
          },
        };
        yield { eventId: `${runId}:2`, data: { tool_title: '正在核实订单 A1024', tool_call_id: `lookup:${runId}` } };
        yield { eventId: `${runId}:3`, data: { choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] } };
      } else {
        yield {
          eventId: `${runId}:4`,
          data: {
            choices: [{ index: 0, delta: { content: '订单已发货,以下是业务卡片。' } }],
            order: { id: 'A1024', status: '已发货' },
          },
        };
        yield { eventId: `${runId}:5`, data: { choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] } };
      }
    })(),
  tools: { lookup: { execute: async () => ({ status: '已发货' }) } },
  transformInput: (_input, { defaultInput }) => ({ ...defaultInput, channel: 'web' }),
  transformOutput: (event, ctx) => {
    if (typeof event.tool_title === 'string' && typeof event.tool_call_id === 'string' && ctx.runId) {
      return [
        ...ctx.defaultChanges,
        {
          type: 'update-part',
          target: { runId: ctx.runId, toolCallId: event.tool_call_id },
          changes: { title: event.tool_title, metadata: { source: '聊天流' } },
        },
      ];
    }
    if (event.order && ctx.messageId) {
      return [
        ...ctx.defaultChanges,
        {
          type: 'set-part',
          messageId: ctx.messageId,
          part: { id: 'order:A1024', type: 'data', kind: 'shop.order', data: event.order },
        },
      ];
    }
    return undefined;
  },
});

async function loadSummary(runtime: ChatkitRuntime) {
  const message = [...runtime.store.getSnapshot().messages]
    .reverse()
    .find(message => message.content.some(part => part.type === 'tool-call'));
  const part = message?.content.find(part => part.type === 'tool-call');
  if (!message || !part?.id) return '先发送问题,生成工具调用';
  // 保存这次请求的运行时和目标,异步返回时不改当前新选中的会话。
  const target = { messageId: message.id, partId: part.id };
  await new Promise(resolve => setTimeout(resolve, 400));
  const result = await runtime.updateContent({
    type: 'update-part',
    target,
    changes: { title: '订单 A1024 已核实:预计明日送达', metadata: { source: '独立摘要接口' } },
    sourceId: 'summary-service',
    changeId: `summary:${part.id}`,
  });
  return result.ok ? '摘要已更新,工具参数、结果与执行状态保持原值' : result.message;
}

export default function AdapterExtensions() {
  const runtime = useChatRuntime({ sessionId: 'extensions-demo', adapter });
  const [status, setStatus] = useState('发送问题后,可模拟独立接口补充标题。');
  if (!runtime) return null;
  return (
    <Chatkit
      runtime={runtime}
      settings={{
        runStatus: { collapseOnComplete: false },
        tools: { groupDefaultExpanded: true, detailDefaultExpanded: true },
        recommendations: ['查一下订单 A1024'],
      }}
      slots={{
        'composer.top': (
          <div className="demo-line">
            <button type="button" className="demo-chip" onClick={() => void loadSummary(runtime).then(setStatus)}>
              模拟独立接口更新标题
            </button>
            <span role="status">{status}</span>
          </div>
        ),
      }}
      renderers={{
        tools: {
          lookup: {
            render: ({ defaultNode, metadata }) => (
              <div>
                {defaultNode}
                <small className="demo-muted">标题来源:{String(metadata?.source ?? '默认')}</small>
              </div>
            ),
          },
        },
        parts: {
          'shop.order': ({ data, part }) => (
            <div className="demo-card">
              <strong>订单 {(data as { id: string }).id}</strong>
              <p>{(data as { status: string }).status}</p>
              <small>稳定片段编号:{part.id}</small>
            </div>
          ),
        },
      }}
    />
  );
}
聊天流与独立接口更新同一份内容新窗口打开

能力函数

ADK、A2A、OpenAI 兼容接口、Hermes 使用同样的能力名称。协议选择决定数据如何翻译,后端函数决定能做哪些操作。Mock 适配器不连接后端,默认实现会话、历史、停止、重新生成和编辑,某项传 false 关闭。

普通能力回调不接收 signal。SDK 在释放运行时时取消 Response 的 reader,或调用异步迭代器的 return();自定义事件流必须在 return() 中移除监听并结束 pending next(),不要关闭其他会话共享的连接。回调返回前的请求超时由应用自己的网络客户端管理。资源释放不请求后端停止。

函数参数返回值说明
runsessionId, runId, bodyPromise<StreamResult<Event>>必需,发起新一轮
resumesessionId, runId, eventId, body?Promise<StreamResult<Event>>从已收到的事件之后续接;body 仅协议需要时提供
abortsessionId, runId, body?Promise<Response>请求远端停止;受理不等于停止完成
steersessionId, runId, bodyPromise<Response>给进行中的一轮追加输入
regeneratesessionId, runId, targetRunId, bodyPromise<StreamResult<Event>>作废目标轮及之后内容,执行新一轮
edit同 regenerate同 regenerate用编辑后的输入重做
historysessionId, cursor?Promise<Response>返回协议历史;ADK/Hermes 不分页

不给对应函数就不启用远端能力。停止必须等待后端确认;会话目录由接入方维护。无历史接口,页面刷新后不恢复旧消息。SDK 不在浏览器落盘。

history 返回会话的协议历史响应,包括消息、已知轮次结果与可选的会话摘要、active_run;不另设单轮查询。连接关闭不代表执行完成,恢复不重新发起用户问题;OpenAI 在续接后执行工具、继续下一模型步骤时仍可能调用 run。

独立会话订阅

subscribe({ sessionId }) 返回 Promise<StreamResult<Event>>,返回时监听已经就绪。有 subscribe 时,run / resume / regenerate / edit 的返回值收窄为 Promise<Response>,仅表示受理;输出全部经过订阅,不能重复返回第二份流。没有 subscribe 时保持上表的返回值。会话监听只在运行时释放时取消,单轮停止不取消它。

输入转换:transformInput

type TransformInput<Input, Body> = (
  input: Input | undefined,
  context: {
    defaultInput: Body;
    sessionId: string;
    runId: string;
    capability: 'run' | 'resume' | 'steer' | 'regenerate' | 'edit';
  }
) => Body;

同步返回完整请求体,建议展开 defaultInput 后改少量字段。恢复请求没有新用户输入,input 可以为空。只有携带协议请求体的能力经过此转换;OpenAI、Hermes 和 createAdapter 的 resume 只有编号与游标,没有 body,因此不调用转换。abort 不经过 transformInput,即使 A2A 的 abort 有 body。对照后端编号由能力函数收到的 runId 为准,内容更新使用 ChatKit 消息里的 runId。

onSend 在界面发送前处理用户输入;transformInput 在适配器默认编码后处理协议请求体。两者发生在不同位置。transformInput 不修改本地用户消息,不用来维护业务状态。

输出转换:transformOutput

type TransformOutput<Event> = (
  event: Event,
  context: {
    sessionId: string;
    runId?: string;
    eventId?: string;
    messageId?: string;
    source: 'stream' | 'history' | 'recovery';
    defaultChanges: readonly DefaultContentChange[];
  }
) => readonly OutputChange[] | undefined;
返回值含义
undefined保留默认内容
context.defaultChanges显式保留默认内容
[...context.defaultChanges, change]保留默认内容,再应用业务变更
[]不显示本事件默认产生的内容;执行状态照常处理

OutputChange = DefaultContentChange | ContentChange。DefaultContentChange 是当前事件的只读默认内容凭据,含 messageId,可原样保留或过滤;不能自行构造、修改或传给 updateContent。这样扩展可以选择显示内容,又不能伪造协议的完成、失败或工具结果。

每次进入输出转换器的事件调用一次;不是每个传输帧都会进入。例如 ADK 心跳和 [DONE]、A2A JSON-RPC 错误不进入此回调,重复投递也不保证只调用一次。无法识别但已成功解析的业务事件可参与转换。messageId 是转换器当前定位到的内容消息,常为回答,但也可能来自本事件默认产生的用户或系统消息;使用前应确认目标。新增 data 必须挂到已有消息。

历史事件形状因适配器而异:ADK 回放事件;OpenAI 把助手/工具历史记录合成为 chunk;Hermes 包成 history.message;A2A 由任务历史合成 status-update/artifact-update;createAdapter 将整份原始历史响应视作 TEvent 调用一次。恢复流仍使用 source: stream,重新取历史使用 history;不能仅因类型里有 recovery 就依赖该值必然出现。

转换必须同步、无网络请求且无副作用。抛错或返回 Promise 时上报 onError 并保留默认内容;非法业务变更批量拒绝并上报,已选择的默认内容仍按选择结果应用;这不是整次回调连同默认内容一起回滚。稳定的消息、片段编号可用于同目标替换,但不提供任意事件的全局去重保证。后端历史必须保留业务字段,刷新后才可重新生成相同扩展。

内容更新:runtime.updateContent

ChatCoreRuntime 和 useChatRuntime 返回的运行时均提供此方法;会话层通过 sessions.runtime 取得当前运行时。

runtime.updateContent(change: ContentChange | readonly ContentChange[]): Promise<ContentUpdateResult>
变更定位允许内容
set-partmessageId完整 { id, type:'data', kind, data, metadata? };同 id 替换,仅能替换 data
update-parttarget: { messageId, partId } 或 { runId, toolCallId }changes: { title?, metadata?, data? }
update-messagemessageIdchanges: { metadata }
  • title 只属于工具片段;工具名、args、result、status 由协议维护。
  • metadata 是 JSON 对象;data 是可 JSON 序列化的业务值,只能用于 data 片段。对象按第一层合并,嵌套对象整份替换;set-part 整块替换。
  • 不允许改正文、推理、消息归属、轮次、执行状态、游标、错误结果。业务状态需要新值时放到 metadata/data。
  • 批量全部校验后原子提交,任何一项失败都不提交。正常完成后仍可补充标题;后端确认中止后内容封存,拒绝补写。
  • 协议消息更新会重新叠加已保存的业务补充;前提是目标消息/片段仍存在且身份有效。目标被删除或轮次作废后清理补充数据。
  • 同一消息内 partId 唯一;按 runId + toolCallId 定位时必须唯一。无法定位或有多个目标时明确拒绝,不缓存等待猜测。

可选 sourceId, changeId, version 用于异步去重:有 changeId/version 必须提供 sourceId;version 为非负安全整数。同一来源、同一目标的 version 只接受更大值,changeId 只接受一次;不同来源按接收顺序合并,不能拿两个来源的版本号比较。

异步请求开始时保存目标 runtime 与消息标识,回来后更新这份 runtime,不重新读取当前会话。runtime 本身绑定会话。删除/重建后旧目标或旧 runtime 会拒绝更新;不要复用已经作废的消息编号。

结果说明
{ ok:true, changed:boolean }成功;空批次 changed 为 false
{ ok:false, code, message }没有修改;调用方决定重试或记录
target-not-found / ambiguous-target目标不存在或不唯一
runtime-disposed / output-sealed已释放或内容已停止封存
invalid-change字段、定位或 JSON 数据不合法
duplicate-change / stale-version同来源目标重复或过期
wrong-session适配器 ingress 请求了其他会话

传输:Response 与事件序列

interface StreamEvent<Event> {
  data: Event;
  eventId?: string;
}
type StreamResult<Event> = Response | AsyncIterable<StreamEvent<Event>>;

HTTP 返回 Response;WebSocket、SDK 回调或自有网关先变成事件序列。data 是该适配器的原始协议事件;eventId 是后端可续接的游标,不是消息 id,也不能用前端计数伪造。

序列结束只代表接收结束,必须收到协议结论或历史中的结论才认定执行结束。取消只归还本次迭代器,不关闭应用共享的 WebSocket;连接复用、重连、分发和鉴权由应用完成。会话、历史 等单次能力仍返回 Response,由适配器统一检查状态码和格式。

websocket-run.ts
import { createOpenAiAdapter, type OpenAiChunk } from '@volcengine/chatkit/adapters/openai';
import type { StreamEvent } from '@volcengine/chatkit/core';

// 网关信封是应用的约定,不是 ChatKit 协议:{sessionId, runId, eventId?, data?, done?}。
// socket 由应用完成鉴权、连接与重连;同一连接可以承载多个会话。
export function subscribeRun(
  socket: WebSocket,
  sessionId: string,
  runId: string
): AsyncIterable<StreamEvent<OpenAiChunk>> {
  const queue: StreamEvent<OpenAiChunk>[] = [];
  let waiter:
    | { resolve: (result: IteratorResult<StreamEvent<OpenAiChunk>>) => void; reject: (reason: unknown) => void }
    | undefined;
  let ended = false;
  let failure: unknown;
  const cleanup = () => {
    socket.removeEventListener('message', message);
    socket.removeEventListener('close', closed);
    socket.removeEventListener('error', closed);
  };
  const stop = (error?: unknown) => {
    if (ended) return;
    ended = true;
    failure = error;
    cleanup();
    if (error) queue.length = 0;
    if (waiter) {
      if (error) waiter.reject(error);
      else waiter.resolve({ done: true, value: undefined });
      waiter = undefined;
    }
  };
  const closed = () => stop(new Error('共享连接断开,执行结果需要查询确认'));
  const message = (event: MessageEvent) => {
    if (typeof event.data !== 'string') return;
    let envelope: Record<string, unknown>;
    try {
      envelope = JSON.parse(event.data);
    } catch {
      return;
    }
    if (!envelope || envelope.sessionId !== sessionId || envelope.runId !== runId) return;
    if (envelope.done) {
      stop();
      return;
    }
    if (!envelope.data || typeof envelope.data !== 'object' || Array.isArray(envelope.data)) return;
    const next: StreamEvent<OpenAiChunk> = {
      data: envelope.data as OpenAiChunk,
      ...(typeof envelope.eventId === 'string' ? { eventId: envelope.eventId } : {}),
    };
    if (waiter) {
      waiter.resolve({ done: false, value: next });
      waiter = undefined;
    } else if (queue.length >= 128) stop(new Error('消息积压超过订阅上限,请查询或续接'));
    else queue.push(next);
  };
  socket.addEventListener('message', message);
  socket.addEventListener('close', closed);
  socket.addEventListener('error', closed);
  return {
    [Symbol.asyncIterator]: () => ({
      next: () => {
        if (failure) return Promise.reject(failure);
        const item = queue.shift();
        if (item) return Promise.resolve({ done: false as const, value: item });
        if (ended) return Promise.resolve({ done: true as const, value: undefined });
        return new Promise((resolve, reject) => {
          waiter = { resolve, reject };
        });
      },
      return: async () => {
        stop();
        queue.length = 0;
        return { done: true as const, value: undefined };
      },
    }),
  };
}

export function createSocketAdapter(socket: WebSocket) {
  return createOpenAiAdapter({
    run: async ({ sessionId, runId, body }) => {
      if (socket.readyState !== WebSocket.OPEN) throw new Error('连接尚未就绪');
      const stream = subscribeRun(socket, sessionId, runId);
      try {
        socket.send(JSON.stringify({ type: 'run', sessionId, runId, body }));
      } catch (error) {
        await stream[Symbol.asyncIterator]().return?.();
        throw error;
      }
      return stream;
    },
  });
}

渲染器

renderers.tools 的组件拿到 title、metadata、defaultNode,以及已有的 toolCallId、toolName、args、result、status 等参数。直接返回 defaultNode 保留默认行,或把它和业务信息组合。配置的 label 优先,其次是单次调用的 title,最后是默认工具说明。

renderers.parts[kind] 拿到 data 与 part,part 含稳定 id 和 metadata。消息 metadata 在 message 上。未知 data 类型不显示;业务组件异常由错误边界隔离。

新协议接入

使用 createAdapter,实现 protocol.encodeInput、protocol.decodeEvent,以及需要的历史、会话解码器和订阅身份函数。应用无需实现 start/execute,也无需保存 RuntimeIngress 或手工接线。

创建工厂管理连接、取消与输出转换;协议函数负责稳定身份、内容转换和明确的执行结论。历史与流的转换粒度、异常出口并不完全相同,具体限制见新适配器页面。

独立接收

subscribe({ sessionId }) 返回 Promise<StreamResult<Event>>,此时 run/resume/regenerate/edit 只返回受理 Response,所有输出经过订阅统一转换。详见发送与接收。