# 多会话并行生成 — 实施计划 > **状态**:✅ **已完成并人工验证**(2026-09-17 归档到 `completed/`) > **结果**:生成状态已按会话隔离(tasks Map + 每轮独立协调器);回归脚本 33/11 项通过; > **浏览器人工验证通过(用户确认)**,含 mock 测试按钮与真实对话。 > ⚠️ 过程中踩过一个响应式陷阱(`ref(new Map())` 破坏对象同一性)已修复并被断言钉死,见 Session 030。 ## Context(为什么做这件事) 用户报 bug:提问后切会话不会取消生成,但在**另一个会话提交新问题**时,原会话变「已取消」、新会话也出问题。根因两层: 1. 按钮语义用**全局** `isGenerating`(`isGenerating ? stop : send`)→ 切到别的会话后按钮仍是「停止」,想发送实际触发停止 2. `handleStopGenerate` 顺序 bug:先 `smc.stopGenerate()`(mitt **同步** emit close → 监听器清空 `activeGenerationSessionId`),再取目标时 `getActiveGenerationSession() || currentSession` 必然 fallback 到**当前会话** → 停止打错对象;且 `stopAiMessage` 会 `ensurePendingAiMessage` 凭空造空气泡 **用户拍板:多会话并行生成(ChatGPT 桌面模式)**——每会话独立生成、互不干扰;生成中切会话不打断;另一会话可正常发送;停止只停自己那一轮。后端已支持:409 `thread_busy` 是 **per-thread**(`api-chat.md:453/537/541`,全局 16 并发);瓶颈全在前端单一协调器实例。 参考的主流做法([AI UX Playground](https://aiuxplayground.com/pattern/interrupt-and-resume) 等):停止控件只在生成中的会话可见;**停止后保留已生成部分**;发送/停止状态按会话算、不按全局算。 ## 核心设计(已由 Plan agent 核实) | 决策 | 结论 | |---|---| | 任务容器 | `tasks = ref(new Map())`(**不要** 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:`finishTask`;**info:不加任务校验**(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` = `resolveStopTargetTaskId` → `stopTask` —— **旧 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=true` 后 `stopTask`(抑制孤儿 DMS 行)+ `sessionInterruptPayloads.delete` - mock 流 6 个函数:守卫从全局改 per-session;注册 `coordinator: null` 的 task;结束走 finishTask - 删除:`setupCoordinator` / `smc` / `getActiveGenerationSession` / `finishActiveAiMessage` / `buildAppendHistoryPayload` / `ensureSession` 内 threadId 同步 - return 调整:`isGenerating` 签名不变(computed);**新增** `currentSessionGenerating`、`toastMessage`、`stopAllTasks`;删 `smc`/`setupCoordinator` ### 3. UI 接线(PC / Mobile) - 4 处 `:is-generating="isGenerating"` → `currentSessionGenerating`(PC :133/:244、Mobile :70/:136);InputShell/Composer **零改动**(Enter 守卫自动获得 per-session 语义) - 两个壳模板各加 ``(复用 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 并行**:`enqueueWrite` 按 `session:/record:` 分键,跨会话天然并行、同会话被守卫+409 串行 - **saveHistory 竞态**:JS 单线程 + 整体替换写法,「最后写入必为最新全量」,无需改 - **logout 竞态**:stopAllTasks 必须在 loadHistory 之前 ## 验证 1. `npm run build`;`npx 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 按钮逐一回归;刷新恢复正确