multi-session-parallel-generation.md 8.3 KB

多会话并行生成 — 实施计划

状态:✅ 已完成并人工验证(2026-09-17 归档到 completed/结果:生成状态已按会话隔离(tasks Map + 每轮独立协调器);回归脚本 33/11 项通过; 浏览器人工验证通过(用户确认),含 mock 测试按钮与真实对话。 ⚠️ 过程中踩过一个响应式陷阱(ref(new Map()) 破坏对象同一性)已修复并被断言钉死,见 Session 030。

Context(为什么做这件事)

用户报 bug:提问后切会话不会取消生成,但在另一个会话提交新问题时,原会话变「已取消」、新会话也出问题。根因两层:

  1. 按钮语义用全局 isGeneratingisGenerating ? stop : send)→ 切到别的会话后按钮仍是「停止」,想发送实际触发停止
  2. handleStopGenerate 顺序 bug:先 smc.stopGenerate()(mitt 同步 emit close → 监听器清空 activeGenerationSessionId),再取目标时 getActiveGenerationSession() || currentSession 必然 fallback 到当前会话 → 停止打错对象;且 stopAiMessageensurePendingAiMessage 凭空造空气泡

用户拍板:多会话并行生成(ChatGPT 桌面模式)——每会话独立生成、互不干扰;生成中切会话不打断;另一会话可正常发送;停止只停自己那一轮。后端已支持:409 thread_busyper-threadapi-chat.md:453/537/541,全局 16 并发);瓶颈全在前端单一协调器实例。

参考的主流做法(AI UX Playground 等):停止控件只在生成中的会话可见;停止后保留已生成部分;发送/停止状态按会话算、不按全局算。

核心设计(已由 Plan agent 核实)

决策 结论
任务容器 tasks = ref(new Map<string, ChatGenerationTask>())不要 reactive Map)
协调器粒度 每轮新建 ApiChatCoordinator 实例(生命周期与任务一致,mitt 随实例销毁;补问的 lastInterruptPayload 在轮末拷贝进 sessionInterruptPayloads: Map(非响应式))
isGenerating computed(() => tasks.value.size > 0);新增 currentSessionGenerating computed 给 UI
activeGenerationSessionId / 全局 messageChunkBuffer / smc 单例 / dmsAnswerWrittenFor 全部删除(task 闭包捕获 sessionId 路由内容;chunkBuffer 每 task 一个;DMS 防重写标志放 task 上)

实施步骤

1. 新建 src/components/business-assistant/chat-generation-task.ts(类型 + 纯决策函数,可被 harness 打包)

  • ChatGenerationTask { sessionId; aiMessageId; coordinator: ApiChatCoordinator | null; chunkBuffer; dmsAnswerWritten; finished }(coordinator 用 import type,esbuild 会擦除)
  • 纯函数:isSessionGenerating(tasks, id) / resolveSendAction(tasks, id) → 'start' | 'busy-same-session' / resolveStopTargetTaskId(tasks, currentId) / shouldNormalizeSessionHistory(tasks, id)

2. 重构 useBusinessAssistantChat.ts(主体)

  • task 生命周期createGenerationTask(sessionId, aiMessageId, coordinator) —— 每任务独立的 createAiMessageChunkBuffer(append 闭包路由到本会话 messages);监听器全部闭包捕获 sessionId,且开头校验 tasks.value.get(sessionId) !== task 则空转(totalResponse 已 finish 后 close 不重复写);
    • totalResponse:补写 DMS answer + 首轮同步标题 + finishTask
    • close:未写 DMS 且内容非空则补写(停止/断流)+ finishTask
    • error:finishTaskinfo:不加任务校验(error 已删 task,409/错误提示仍要 toast)
    • finishTask:finished 防重 → flush + cancel chunkBuffer → map.delete → 拷贝 lastInterruptPayload → finishAiMessage → saveHistory
  • stopTask(sessionId):先 stopAiMessage(晚到 chunk 被 Stop 守卫丢弃)→ coordinator.stopGenerate()(同步 close 走监听器)→ finishTask 兜底;stopAllTasks()(logout 用)
  • handleStopGenerate = resolveStopTargetTaskIdstopTask —— 旧 bug 消失(目标在停止前解析,close 只操作闭包会话)
  • sendMessage:守卫改 resolveSendAction(tasks, currentSession?.id) → busy 时 showToast('该会话已有回答在生成中')(替换静默 return);新 ApiChatCoordinator({baseUrl, threadId: session.id}) → createTask → set map → DMS 写入(照旧)→ coordinator.generateAnswer(text)
  • submitQuestionAnswers:同守卫;payload 取 sessionInterruptPayloads.get(session.id);其余同 sendMessage 模式
  • startNewChat删除 if (isGenerating) handleStopGenerate()(后台任务继续)
  • switchSession:删 threadId 同步;归一化守卫改 shouldNormalizeSessionHistory
  • markSessionMessagesAsHistory:按 isSessionGenerating(tasks, session.id) 决定最后一条 AI 是否保持 history=false
  • deleteSessionAt:若该会话有 task → 置 dmsAnswerWritten=truestopTask(抑制孤儿 DMS 行)+ sessionInterruptPayloads.delete
  • mock 流 6 个函数:守卫从全局改 per-session;注册 coordinator: null 的 task;结束走 finishTask
  • 删除:setupCoordinator / smc / getActiveGenerationSession / finishActiveAiMessage / buildAppendHistoryPayload / ensureSession 内 threadId 同步
  • return 调整:isGenerating 签名不变(computed);新增 currentSessionGeneratingtoastMessagestopAllTasks;删 smc/setupCoordinator

3. UI 接线(PC / Mobile)

  • 4 处 :is-generating="isGenerating"currentSessionGenerating(PC :133/:244、Mobile :70/:136);InputShell/Composer 零改动(Enter 守卫自动获得 per-session 语义)
  • 两个壳模板各加 <Toast :message="toastMessage" />(复用 Common/Toast.vue,Teleport 组件)+ import + 解构补字段
  • 删除 setupCoordinator() 调用点(PC/Mobile onMounted,实现时核实)
  • handleAccountLogout 开头加 stopAllTasks()在 loadHistory 之前——storage key 切换后不能让旧账户任务继续写)
  • mock 测试按钮保持绑全局 isGenerating(dev 工具,不精确到会话)

4. 可测纯逻辑 + harness 断言

  • 扩展 _entry-coordinator.ts:导出 ApiChatCoordinator 类;新建 _entry-chat-tasks.ts 导出 4 个纯函数
  • 新建 verify-chat-task-utils.mjs:空 Map/null 边界、send 决策三态、stop 目标解析、归一化守卫、stub task 驱动
  • 新建 verify-coordinator-multi-instance.mjs两个实例事件隔离stopGenerate() 不依赖 fetch;generateAnswer 在空 baseUrl 走 emitError('missing_base_url') 短路)——a.stopGenerate 只触发 a 的监听、b 的收集器全空;隔离性正是并行的地基
  • 收尾:validate-harness.mjs 通过;更新 progress.md / feature_list.json / README

关键风险与对策(已在设计中消化)

  • 响应式ref(new Map()) + tasks.value.set/delete,只追踪 .size;reactive Map 会把 coordinator 实例整体 proxy 化(避免)
  • 事件时序(mitt 同步已实证):正常完成 = totalResponse 先 finishTask → close 空转;停止 = close 补写+finish → abort 的 silent finishTurn 被 turnToken 挡;409 = error→info→close,toast 照发
  • 停止后立即重发 → 后端 409(会话锁保留到线程结束)→ info → toast「该问题还在处理中,请稍候再试」(现在终于有人监听 info 了)
  • DMS 并行enqueueWritesession:/record: 分键,跨会话天然并行、同会话被守卫+409 串行
  • saveHistory 竞态:JS 单线程 + 整体替换写法,「最后写入必为最新全量」,无需改
  • logout 竞态:stopAllTasks 必须在 loadHistory 之前

验证

  1. npm run buildnpx vue-tsc --noEmit(本方案涉及文件无新增错误)
  2. harness:两个新脚本全绿 + 全量旧脚本回归(36/31/8/10/21/30)+ validate-harness
  3. 浏览器端到端(双会话):
    • A 生成中切 B → B 发送成功;切回 A 仍在流式,内容各归各
    • 各自按停止互不影响;A 生成中在 A 再发(推荐问题/Enter)→ toast 拦截,生成不被打断
    • 停止后立即重发 → 409 toast;退出登录 → 全部任务停;删除生成中的会话 → 无孤儿
    • 两会话同时完成 → DMS 两轮各落一行;6 个 mock 按钮逐一回归;刷新恢复正确