ChatKit Adapter 与会话 API 契约
本契约确定接入方可使用的接口与行为。只发布 @volcengine/chatkit;会话业务由接入方管理,Adapter 负责消息执行协议,Core 维护单个会话的消息与执行状态,UI 负责展示。本文的能力名称与代码保持一致,不保留旧方法别名。
接入与开发导航:接入内置 adapter、实现自定义 adapter。
阅读前提:先认识这些编号
这些字段出现时都是字符串;是否必填以各接口参数表为准。内置协议处理自身定义的编号映射;独立订阅入口还必须满足下文的统一 runId 约束,自定义协议遵守同样规则。
例如:会话 s1 中发起执行 r1,产生消息 m1;消息里的文本片段是 p1。流事件 e1、e2 可以持续追加到同一个 m1 / p1,因此一条事件不等于一条消息。再次发送会产生新的 runId,会话仍是 s1。
编号使用规则:同一消息/片段在实时输出与历史中必须使用一致的 id,才能正确合并;新消息/片段使用新 id。eventId 用于流续接,cursor 用于历史分页,二者不能混用。
按任务选择 API
工厂统一返回 ChatRuntimeAdapter<对应的 ChatInput>,可直接交给会话 hook。配置类型、回调参数与返回类型是这些 API 的数据说明,无需逐个作为独立接口学习。直接实现 ChatRuntimeAdapter 或使用 ChatCoreRuntime 的进阶接入仍受支持。
1. 先确定职责
SDK 不维护业务会话目录,不实现 sessions.list/create/rename/remove,也不为 fork、归档、置顶增加 adapter 方法。接入方不应直接修改 Core store。
2. 导出入口与最小接入
内置表示已实现协议转换,不表示已提供网络服务、鉴权或会话业务接口。
这里 /api/chat/run 必须返回所选协议的事件流。后端要求先创建会话时,应用先取得真实 sessionId,再打开 Conversation。直接链接到已有 sessionId 不依赖会话列表。
3. 创建配置与 runtime 契约
接入方把能力函数传给工厂,工厂生成 ChatRuntimeAdapter。相同业务动作同名,但两层参数不同:Core 提供统一 input,Adapter 编码成 body 后调用接入函数;接入函数返回原始响应,Adapter 解码并向 Core 提交事实。
不需要自行实现 start 或持有 ingress;只有直接实现底层 ChatRuntimeAdapter 才会涉及它们。底层 run/regenerate/edit 返回 Promise<boolean>,resume 返回 Promise<void | boolean>,steer/abort 返回 Promise<boolean>。这些返回值不替代后端终态事实。
能力函数完整约定
普通能力回调不接收 signal。SDK 在释放运行时时取消 Response 的 reader,或调用异步迭代器的 return();自定义事件流必须在 return() 中移除监听并结束 pending next(),不要关闭其他会话共享的连接。回调返回前的请求超时由应用自己的网络客户端管理。资源释放不请求后端停止。
未提供的能力不挂到 adapter,UI 按有效能力显示执行操作。historyPaging 表示允许历史翻页,与独立方法无关;唯一 history 方法负责第一页与后续页,是否还有内容由 hasMore/cursor 决定。
HTTP 非 2xx 或回调抛错表示调用未成功;非流式 2xx 响应只证明 HTTP 成功,工厂不猜测应用自定义业务码。应用需在能力回调中校验业务响应并抛错。协议事件里的业务失败由对应解码器处理为 failed。受理成功不等于执行完成。流关闭不能推断 completed。OpenAI 工具循环可以在同一 runId 下多次调用 run。
核心数据模型
下表是接入方需要关心的 Core/UI 数据形状;必选为不传即形状不合法,可选列用 ? 表示可省略 / 为 undefined(不是 null)——只有字段显式标 | null 才可赋 null(如 activeRun、adapter 透传的 unknown 数据)。逐字段联合类型以 @volcengine/chatkit/core 导出的类型声明为准。
| 结构 | 必选 | 可选 | 语义与约束 |
| --------------------------------------------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
| RuntimeSnapshot(Core 冻结发布) | sessionId、runs、messages、baseline、scheduling | 见各子对象 | runs: Record<RunId, RunView>;messages: RuntimeMessage[];baseline={ready, reconciliationRequired, cursor?, hasMore, loading};scheduling={activeRunId?, queuedRunIds[]} |
| RunView(runs 的值,实时 run) | id: RunId、origin: RunOrigin、status、phase、workItems、createdAt | connection?、startedAt?、executionStartedAt?、phaseChangedAt?、finishedAt?、abort?、error?、input?、position?、interruptReason?(时间戳列非穷举,以源码为准) | connection 是对象 {status, error?},非裸枚举;abort 见下;status/phase 取值见状态机 |
| HistoryRunSnapshot(HistoryResult.runSnapshots 元素) | 同 RunView 但 status 限定终态 | 同 RunView | Omit<RunView,'status'> & { status: completed/failed/interrupted };不是 RuntimeSnapshot 内嵌的历史快照 |
| RuntimeMessage | id、role、parts | runId?、createdAt?、metadata? | role ∈ system/user/assistant/tool;输出归属当前 runId;先建消息才能追加 delta |
| RuntimePart | id、type + 随 variant 必填 | 随 variant 扩展 | text/reasoning 必有 text;tool-call 必有 toolCallId、toolName;media 必有 mediaType;data 必有 kind、data(这些是该 variant 的必填,不在可选列) |
| ProtocolUpdate | type | 随类型 | message(替换整条)/ delta(只追加文本)/ phase / completed / failed / interrupted;终态必须有协议证据 |
| HistoryResult | messages、hasMore | cursor?、runSnapshots?、activeRun?、session? | messages 重建内容;runSnapshots 恢复明确终态;activeRun 单主要恢复目标(可 | null);cursor 翻页 |
| RunAbortView(即 runSnapshot 的 abort) | requestedAt: number、outputSealed: boolean | acceptedAt?、remote?、remoteStatus? | requestedAt 非负有限;acceptedAt 有值须 >= requestedAt;outputSealed 表中止后输出封口,不要求 interrupted 或 acceptedAt;终态可先于停止 ACK |
| RunError | code、message | retryable?、cause? | Core 在 emitFailed 准入时用 toRunError 归一,接入方不拿到 [object Object] |
| ChatkitActionInput(公开 actions.send) | content | attachments?、metadata? | content 为富文本片段数组;attachments 可省,纯文本最小 { content: [{type:'text',text}] } |
| ChatkitControlledSendInput(受控完整表单) | content、attachments | metadata? | attachments 必传;与公开 action 入参分型,不混称 |
| ContentUpdateResult | ok | 随联合 | 成功 { ok:true, changed };失败 { ok:false, code, message }(无 changed),code ∈ runtime-disposed/wrong-session/target-not-found/ambiguous-target/output-sealed/invalid-change/stale-version/duplicate-change |
UI 通过 store.subscribe(listener) 重读冻结的 RuntimeSnapshot。RunView.connection 是对象 {status: connected/disconnected/recovering, error?},其状态独立于 run 的 status/phase。
4. subscribe 如何工作
没有 subscribe 时,run 发请求并返回这一轮的回答流。有 subscribe 时,发送与接收分离:SDK 先接通会话,再发 run,所有回答从订阅流接收;run/resume/regenerate/edit 只返回受理响应。
SDK 持续读取 Response.body 并解析 SSE,或者通过 for-await 消费接入方返回的异步序列。原始事件先识别 runId,再送到协议解析器,转换成事实进入 Core,最后由 UI 订阅快照更新。
WebSocket 连接、消息监听与缓冲队列由接入方实现。subscribe 不能在服务端确认之前 resolve;释放会话只移除该会话的监听,不能关闭其他会话共用的连接。发送命令也必须等待后端受理,socket.send 返回不等于受理。
OpenAI 订阅事件的当前约定:每个事件含 run_id;本页未发起的轮次先有 type: run.started;choices 中的 finish_reason 表示本次响应结束。普通 Chat Completions chunk 的 id 不是 ChatKit runId。其他协议使用各自的身份规则;自定义协议通过 identifyEvent 提供。
订阅模式拒绝 run/resume/regenerate/edit 返回 Content-Type 为 text/event-stream 的第二条输出流;其受理响应正文释放,不解释应用业务字段。单轮结束不关闭会话订阅。停止分两步:调用 abort 请求后端取消,随后保持接收输出、显示“正在停止”。只有后端终态事件或 history 返回该轮明确终态,才结束这一轮并释放执行位。失败或未确认时不能宣称已停止;缺少 abort 时不显示停止按钮。未派发的排队消息可直接取消。
5. 会话目录和路由由外部控制
SessionInfo 是会话摘要:id 必填,title、createdAt、updatedAt、status、metadata 可选。时间统一为毫秒。业务字段可放 metadata;SDK 不解释置顶、归档、权限等业务含义。status 是 idle / running / completed / error / unknown:running 表示存在活跃 Run;completed / error 表示最近一轮已结束但用户尚未查看;idle 表示没有活跃 Run 且没有未读结果。
SessionList 接收 readonly ChatkitSessionView[],它与 SessionInfo 的摘要字段相容。组件不会修改数组或发后端请求。
实际应用可以直接从 URL 读取 activeSessionId,onSelect 执行导航。浏览器前进后退、深链接和 fork 后跳转都走同一条路。SDK 不自动选择列表首项,不创建虚拟草稿 ID;没有 activeSessionId 时 runtime 为空。
列表筛选掉一个会话不意味着它已被删除,也不会释放其 runtime。SDK 不回写标题或目录数组;应用根据业务接口或运行事件刷新元数据。已打开会话的实时状态从 chat.store 的 openedSessions 合并进列表。后台会话正常结束或失败后分别进入 completed / error;选中后变为 idle,当前会话结束时直接保持 idle。SessionList 不展示 idle,completed 使用蓝色实心圆点,error 使用红色问号,unknown 使用黄色感叹号;状态图标悬停时显示文案。该已读状态只随已打开 runtime 保存在内存;需要刷新或跨设备保留时由应用持久化。
useChatSessions 的资源生命周期
参数:adapter 必填且引用稳定;activeSessionId 可选并持续受控;onError、eventAttributes、stallThresholdMs 与单会话 hook 相同。
切换保留旧 runtime,后台继续执行;直到显式 release、adapter 替换或 hook 卸载才释放。已释放的当前 ID 不会因普通重渲染自动重新打开,可显式 open 或切换 ID。应用负责选择下一个会话以及控制缓存数量;SDK 不内置 LRU 或空闲超时。
新建:应用调用后端 create,更新目录并导航到新 ID。首发前先完成创建;如果先发再导航,用 await chat.actions.open(id) 获取新 runtime,再调用其 actions.send,避免误发到旧会话。
删除:应用调用后端 delete,成功后更新目录、chat.actions.release(id),再决定下一条路由。若需要远端停止,应由后端删除语义或显式取消操作保证。
fork:应用调用后端 fork({ sessionId, messageId }),后端复制上下文并返回新 ID;应用更新目录并导航。SDK 通过 history 读取新会话。复制前端消息不等于后端 fork。单个 session 内的消息树分支切换不在本契约范围内。
6. 扩展入口如何选择
transformInput(input, { defaultInput, sessionId, runId, capability }) 同步返回完整协议 body,不是局部 patch。默认编码先执行;返回值交给能力函数。capability 为 run/resume/steer/regenerate/edit,但只有协议有对应请求体时才调用;例如 OpenAI 的 resume/abort 不调用它。
transformOutput(event, { sessionId, runId?, eventId?, messageId?, source, defaultChanges }) 同步返回 OutputChange[] 或 undefined。undefined 保留默认内容,数组表示采用选中内容,追加时展开 defaultChanges;[] 过滤本事件的默认内容,但不屏蔽执行信号。转换抛错时保留默认内容并报告错误。
以上业务字段示例需按具体事件类型校验。data 片段通过 renderers.parts['shop.order'] 渲染。defaultChanges 是默认内容凭据,只能原样保留或过滤,不能自行构造或传给 updateContent。
runtime.updateContent(change | changes) 返回 Promise<ContentUpdateResult>,用于聊天流之外的更新,例如 await 摘要接口后修改已经存在的工具标题。它不发请求、不持久化、不创建会话或任意消息。
目标必须已存在。不得修改默认文本、工具参数、工具结果或执行状态。正常完成后允许补充,已确认中止的输出拒绝补写。批量原子提交;失败返回 ok:false 和 code/message,成功返回 ok:true 和 changed。
sourceId/changeId/version 用于来源内去重与版本检查;提供 changeId/version 必须同时提供 sourceId。对象浅合并,嵌套对象和数组整体替换,set-part 整体替换。异步操作必须捕获原 runtime 与目标 ID,切换后不得改写当前新会话。
source 的类型包含 stream/history/recovery;当前续流仍使用 stream,历史读取使用 history,不依赖 recovery 必然出现。
7. 新协议作者实现什么
createAdapter({ protocol, ...能力函数 }) 返回统一 ChatRuntimeAdapter。无需注册到全局,也无需修改 UI。只有后端的消息与执行语义无法由内置协议解释时才编写新协议;业务卡片与额外请求字段优先使用上面的扩展入口。
ProtocolUpdate 支持 message、delta、phase、completed、failed、interrupted。message 是完整消息快照;delta 为已有文本或思考片段追加文字;终态只能来自协议证据。未知但有效的业务事件可返回 [],之后仍可交给 transformOutput。
协议作者负责稳定的 messageId/partId/toolCallId、按 sessionId+runId 隔离的拼接状态、历史和实时同构,以及明确的开始/结束识别。identifyEvent 的 endsResponse 只结束本轮读取,不替 decodeEvent 生成 completed。
可运行的完整实现与在线演示见文档站的自定义适配器,使用同一公共工厂,并通过类型检查与一致性测试。
编辑重发与回复重试的统一约定
edit(编辑后重发)与 regenerate(回复后重试 / 重新生成)均覆盖目标轮次及之后的历史,保留目标轮次之前的内容。前者用新输入,后者复用原始输入;旧内容不参与新执行的上下文。能力函数必须让后端同步替换当前主线历史,确保刷新、翻页与重新打开后旧内容不再出现。后端无法覆盖历史时,不提供对应能力。请求未受理时保留旧历史,已受理后生成失败时保留新轮次及失败状态。
8. 历史、恢复与错误
HistoryResult 必含 messages 和 hasMore,可含 cursor、session、runSnapshots、activeRun。session 是摘要,不是业务目录;runSnapshots 只恢复明确提供的终态。activeRun 对象包含 runId/eventId,null 表示确认无执行,省略表示未知。会话 status:running 不能代替可靠 runId 和恢复位置。
history 取快照;subscribe 接实时;resume 补收已知轮次的缺失输出。三者不可互相替代。有订阅时先接收并缓冲,再加载历史;从匹配快照的 eventId 恢复,重放顺序和完整性由后端保证。没有游标不能承诺精确续接,不重新发送旧问题。
输入转换抛错使当前命令失败;输出转换抛错保留默认内容。协议/网络错误通过现有 onError 与运行事件报告;本地 IO 异常不伪造远端失败。取消一个会话的资源不影响其他会话。
协议身份与恢复边界
这些订阅字段必须使用 SDK 的 runId。请求流模式中协议可维护本地与远端 ID 映射,但独立订阅在进入解码器前先按身份路由,因此后端或应用事件封装必须归一编号。历史必须恢复同一身份;不能依赖页面内临时映射在刷新后仍存在。OpenAI 的 finish_reason=tool_calls 仅结束一次模型响应,工具循环可在同一个 runId 内继续。
subscribe 只有在服务端监听和客户端缓冲均就绪时才能 resolve。打开时先连接并暂存实时事件,再读取历史,最后按基线释放;对 history.activeRun 的恢复由 resume 补收对应游标之后的有序输出。后端负责重放完整性,eventId 只做同轮去重,不做大小排序或全局精确一次投递保证。
会话层对已知断连执行或停止未确认执行最多尝试三次 recover,等待间隔为 500、1500、3000 ms。停止未确认时查询 history;断连且有位置与 resume 时尝试续接,否则查询 history。手动 recover 可继续查询;不会重发 run。无活跃执行的空闲订阅没有自动重连循环,需要持续保活时由应用返回的事件序列管理重连。失败的 connect 也不承诺无限重试。
本地执行位是单个,远端被动执行可以多个交错接收。UI 无参数 abort 优先 scheduling.activeRunId;没有执行位时,仅一个远端活跃目标才会停止;多个候选返回 false。指定目标用 Core abort({ runId })。已受理或请求中的重复停止返回 false;失败标记 unknown 后允许重试原目标。队列可通过 Core abort({ runId }) 或 cancelQueued 管理。
停止请求尚未返回时,abort.remote 暂缺,表示请求进行中;不能初始化为 unknown,否则会放行并发重复请求。返回 false 或抛错后才标记 unknown,保留 interrupting、输出接收和执行位,允许重试或通过 history 恢复。该结果只表示取消未确认,不是执行终态;不能回滚整轮快照,以免覆盖请求期间收到的输出或终态。
HistoryResult.activeRun 只声明一个主要恢复目标。存在多个远端执行时,需要后端确定主要目标;其余执行可继续从订阅接收,但本版没有完整多目标历史恢复和任务控制 UI。缺少 resume 能力、事件位置或协议所需身份时,续接返回 false,不推断执行已终止。activeRun=null 表示后端确认无活动执行,但不能单凭空值推断每个已知执行的终态,仍需 runSnapshots 或事件。
内置协议配置
网络协议工厂的 onError 为 (kind: string, error: unknown, extra?: Record<string, unknown>) => void;createAdapter 的前两参相同,不提供协议专有 extra。kind 是诊断类别,不能用它替代执行状态判断。Mock 只报告本地转换异常,因此保留 (error: unknown) => void。通知回调抛错被隔离,未配置时记录 console.error;不产生异步全局异常。协议明确的业务失败主要体现在运行状态,不保证每个失败事件都触发 onError。
transformInput 或编码失败使当前调用失败;transformOutput 抛错保留默认内容。createAdapter 会报告能力调用中的编码、解码或网络异常,再保留原错误向上抛;abort 的调用失败返回 false。内置协议在请求与转换层报告已识别异常,其余异步派发异常由会话层 onError 接收。UI actions.send 的 accepted 表示本地入队;派发错误异步报告;steer 表示后端受理;regenerate/edit 表示新执行入队。Core send 返回的 dispatched 是派发结果 Promise,完成时间取决于 adapter,不能作为后端执行完成通知。
实现新适配器的完整示例见写一个新的适配器,三种内容扩展的用法见扩展。
应用侧 runtime 操作
以下是 useChatRuntime 或 useChatSessions().runtime 的受控操作,区别于网络能力回调和底层 adapter 方法。这些受控入口的入参是 ChatkitControlledSendInput:{ content, attachments, metadata? }。content 按序记录文字、结构化引用和带 placement 的附件出现位置;attachments 是完整资源表。纯文本最小 { content: [{ type: 'text', text: '你好' }], attachments: [] }。内置适配器使用同一输入,网络回调的独立 input 提供规范化原始结构,方便历史持久化。存储、预览、模型读取与迁移见 多模态附件。
与之区分:公开 hook useChatkitActions() 暴露的 send / edit 用更宽松的 ChatkitActionInput——可直接传字符串,或传 { content, attachments?, metadata? }(attachments 可省,内部补 []);useChatkitActions 不暴露 steer(插话只在受控 runtime 层)。字段以上表「核心数据模型」为唯一权威。
runtime.store.getSnapshot() 返回 UI 受控层的 ChatkitControlledSnapshot(形状为只读 { sessionId, messages, sending, history: { loading, hasMore } };与 Core 发布给运行时的 RuntimeSnapshot 是不同层的视图,勿混),使用 store.subscribe(listener) 监听并重读快照,返回退订函数。runtime.events 提供执行事件,不是另一条后端传输通道。runtime.updateContent 使用本文第 6 节的内容变更结构。