状态:✅ 已完成并人工验证(2026-09-17 归档到
completed/) 结果:生成状态已按会话隔离(tasks Map + 每轮独立协调器);回归脚本 33/11 项通过; 浏览器人工验证通过(用户确认),含 mock 测试按钮与真实对话。 ⚠️ 过程中踩过一个响应式陷阱(ref(new Map())破坏对象同一性)已修复并被断言钉死,见 Session 030。
用户报 bug:提问后切会话不会取消生成,但在另一个会话提交新问题时,原会话变「已取消」、新会话也出问题。根因两层:
isGenerating(isGenerating ? stop : send)→ 切到别的会话后按钮仍是「停止」,想发送实际触发停止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 等):停止控件只在生成中的会话可见;停止后保留已生成部分;发送/停止状态按会话算、不按全局算。
| 决策 | 结论 |
|---|---|
| 任务容器 | 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 上) |
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)useBusinessAssistantChat.ts(主体)createGenerationTask(sessionId, aiMessageId, coordinator) —— 每任务独立的 createAiMessageChunkBuffer(append 闭包路由到本会话 messages);监听器全部闭包捕获 sessionId,且开头校验 tasks.value.get(sessionId) !== task 则空转(totalResponse 已 finish 后 close 不重复写);
finishTaskfinishTaskfinishTask;info:不加任务校验(error 已删 task,409/错误提示仍要 toast)finishTask:finished 防重 → flush + cancel chunkBuffer → map.delete → 拷贝 lastInterruptPayload → finishAiMessage → saveHistorystopTask(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 同步;归一化守卫改 shouldNormalizeSessionHistorymarkSessionMessagesAsHistory:按 isSessionGenerating(tasks, session.id) 决定最后一条 AI 是否保持 history=falsedeleteSessionAt:若该会话有 task → 置 dmsAnswerWritten=true 后 stopTask(抑制孤儿 DMS 行)+ sessionInterruptPayloads.deletecoordinator: null 的 task;结束走 finishTasksetupCoordinator / smc / getActiveGenerationSession / finishActiveAiMessage / buildAppendHistoryPayload / ensureSession 内 threadId 同步isGenerating 签名不变(computed);新增 currentSessionGenerating、toastMessage、stopAllTasks;删 smc/setupCoordinator: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 切换后不能让旧账户任务继续写)isGenerating(dev 工具,不精确到会话)_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 / READMEref(new Map()) + tasks.value.set/delete,只追踪 .size;reactive Map 会把 coordinator 实例整体 proxy 化(避免)enqueueWrite 按 session:/record: 分键,跨会话天然并行、同会话被守卫+409 串行npm run build;npx vue-tsc --noEmit(本方案涉及文件无新增错误)