断线恢复与停止确认

执行结果与接收连接分别记录。网络断开、超时、普通流结束都不等于后端已经失败或完成。

续接的过程界面与 Core适配器能力函数接入方写后端run 返回的流:事件 e1、e2、e3记下最后收到的事件编号:e3连接断开。这一轮还没有结论,留在进行中续接这一轮调用 resume轮次编号、事件编号 e3发请求带上轮次编号和 e3只发 e3 之后的:e4、e5、结论接在同一条回答后面
续接靠两个编号:轮次编号指明是哪一轮,事件编号指明从哪之后接着发。

两个能力的职责

能力提供什么用在哪里
history完整消息、轮次结论、可选会话摘要与进行中的轮次打开页面、刷新历史、补齐最终结果、确认停止
resume指定 runId 的 eventId 之后的事件补收尚未收到的输出,不重新执行问题

不设置单独的单轮查询方法。状态原本来自会话详情接口时,在 history 函数中组合历史与详情,交给协议解析器一次转换。

自动恢复顺序

  • 打开会话先建立订阅(若配置),再读历史。有明确 activeRun、匹配的消息前缀、eventId 和 resume 时续接。
  • 正在接收时断线:保留内容和轮次身份;已有游标且支持 resume 时优先续接,否则刷新 history。
  • UI hooks 对符合条件的轮次最多自动尝试三次,等待间隔为 500、1500、3000 毫秒;headless Core 没有该定时策略。recover 不重新发起用户问题;OpenAI 续接后若进入工具循环,仍可能调用 run 请求下一步模型。
  • Core HistoryResult.activeRun 缺省表示未提供,null 表示明确没有。联网适配器先从 runs 或任务状态推导,再用顶层 active_run 覆盖;因此原始 active_run 缺省时仍可能有活动轮次。只有文本历史、没有状态与重放能力时,只能展示文本,不能推测执行是否结束。

停止的两层结果

用户点停止后调用后端 abort,继续接收输出并显示“正在停止”。成功响应仅表示受理;终态事件或 history 中该轮明确终态才结束运行、释放执行位。history 可以补齐最终内容。失败或无法确认时显示停止未确认。ADK 当前历史解析不生成轮次终态,须通过协议事件确认;仅关闭 SSE 不算停止成功。缺少 abort 时不显示停止按钮。

recovery.tsx
import { Chatkit, useChatRuntime } from '@volcengine/chatkit';
import { createOpenAiAdapter } from '@volcengine/chatkit/adapters/openai';
import { useMemo, useState } from 'react';

type Mode = 'query' | 'resume' | 'unknown' | 'cancel';
const labels: Record<Mode, string> = {
  query: '断线后刷新完整历史',
  resume: '断线后按游标续流',
  unknown: '没有恢复能力',
  cancel: '停止受理后确认',
};
const frame = (text: string, id: string) =>
  `id: ${id}\ndata: ${JSON.stringify({ choices: [{ index: 0, delta: { content: text }, finish_reason: null }] })}\n\n`;
const stream = (body: string) => new Response(body, { headers: { 'Content-Type': 'text/event-stream' } });

function Scenario({ mode }: { mode: Mode }) {
  const [calls, setCalls] = useState({ run: 0, history: 0, resume: 0, abort: 0 });
  const adapter = useMemo(() => {
    let cancelled = false;
    let currentRun: string | undefined;
    const count = (key: keyof typeof calls) => setCalls(value => ({ ...value, [key]: value[key] + 1 }));
    return createOpenAiAdapter({
      run: async ({ runId }) => {
        currentRun = runId;
        count('run');
        if (mode !== 'cancel') return stream(frame('已收到第一段。', 'e1'));
        return new Response(
          new ReadableStream<Uint8Array>({
            start(controller) {
              controller.enqueue(new TextEncoder().encode(frame('正在执行,请点击停止。', 'e1')));
            },
          })
        );
      },
      ...(mode === 'unknown'
        ? {}
        : {
            history: async () => {
              count('history');
              if (!currentRun)
                return Response.json({ messages: [], runs: [], session: { status: 'idle' }, active_run: null });
              const runId = currentRun;
              return Response.json({
                session: { status: mode === 'resume' ? 'running' : 'idle' },
                runs: [
                  {
                    id: runId,
                    status: cancelled ? 'interrupted' : mode === 'resume' ? 'running' : 'completed',
                    last_event_id: 'e2',
                  },
                ],
                messages: [
                  { id: `user:${runId}`, run_id: runId, role: 'user', content: '开始验证' },
                  {
                    id: `assistant:${runId}`,
                    run_id: runId,
                    role: 'assistant',
                    content: '历史确认:后端已完成,最终结果已补齐。',
                  },
                ],
              });
            },
          }),
      ...(mode === 'resume'
        ? {
            resume: async ({ eventId }: { eventId: string }) => {
              count('resume');
              if (eventId !== 'e1') throw new Error('必须从已有消息前缀的 e1 之后接续');
              return stream(frame('这是续接收到的第二段,没有重新发送问题。', 'e2') + 'data: [DONE]\n\n');
            },
          }
        : {}),
      ...(mode === 'cancel'
        ? {
            abort: async () => {
              count('abort');
              cancelled = true;
              return new Response(null, { status: 202 });
            },
          }
        : {}),
    });
  }, [mode]);
  const runtime = useChatRuntime({ sessionId: `recovery-${mode}`, adapter });
  return (
    <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
      <p style={{ margin: 12 }}>
        本地模拟后端 · run {calls.run} / history {calls.history} / resume {calls.resume} / abort {calls.abort}
      </p>
      <div style={{ flex: 1, minHeight: 0 }}>
        {runtime && (
          <Chatkit
            runtime={runtime}
            settings={{
              welcome: labels[mode],
              recommendations: ['开始验证'],
            }}
          />
        )}
      </div>
    </div>
  );
}
export default function Recovery() {
  const [mode, setMode] = useState<Mode>('query');
  return (
    <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
      <label style={{ padding: 12 }}>
        恢复场景{' '}
        <select aria-label="恢复场景" value={mode} onChange={event => setMode(event.target.value as Mode)}>
          {Object.entries(labels).map(([value, label]) => (
            <option key={value} value={value}>
              {label}
            </option>
          ))}
        </select>
      </label>
      <div style={{ flex: 1, minHeight: 0 }}>
        <Scenario key={mode} mode={mode} />
      </div>
    </div>
  );
}
模拟断线、恢复与停止新窗口打开

需要后端保证

同一轮用稳定 runId;eventId 对应已投影的前缀;重放应只发游标之后的后缀;不要依赖所有适配器去重边界增量;历史和实时使用稳定消息/片段身份。不能用“最新事件编号”配一份更早的文本历史,否则中间内容会永久缺失。