소스 검색

feat(chat): 按会话并行生成,修「在另一会话提交导致原会话取消、新会话出错」

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

① 发送按钮语义用**全局** isGenerating(InputShell `isGenerating ? stop : send`)
   → 切到别的会话后按钮仍是「停止」,用户想发送却触发了停止
② handleStopGenerate 先 smc.stopGenerate()(mitt **同步** emit close → 监听器清空
   activeGenerationSessionId),再取目标时 `getActiveGenerationSession() ||
   currentSession` **必然** fallback 到当前会话 → 停止打错对象;且 stopAiMessage
   的 ensurePendingAiMessage 会凭空造出一条空的「请求已取消」消息

按用户拍板改为「多会话并行生成」(ChatGPT 桌面模式)。后端本就支持:
409 thread_busy 是 per-thread,全局 16 并发,瓶颈全在前端的单一协调器实例。

实现:
- 新增 chat-generation-task.ts:任务类型 + 4 个纯决策函数
  (isSessionGenerating / resolveSendAction / resolveStopTargetTaskId /
  shouldNormalizeSessionHistory),无 vue/网络依赖,可被 harness 直接断言
- 生成状态从「一个全局 smc + 一个 activeGenerationSessionId」改为
  tasks Map(key=sessionId);**每轮新建 ApiChatCoordinator 实例**
  (生命周期=任务,mitt 随实例销毁),监听器闭包捕获 sessionId 路由内容,
  每任务独立 chunkBuffer;删除 setupCoordinator/getActiveGenerationSession/
  finishActiveAiMessage/messageChunkBuffer/dmsAnswerWrittenFor
- handleStopGenerate 改为「先解析目标、再停」,旧 bug 自然消失
- startNewChat 不再打断生成;退出登录先 stopAllTasks(避免旧账户任务写到新身份下);
  删除正在生成的会话先停任务并抑制 DMS 补写
- 顺带修两处静默失败:info 事件此前**无人监听**(409/错误提示用户完全无感)→
  接到新增的全局 Toast;同会话重复发送从静默 return 改为提示
- UI:4 处 :is-generating 绑定改 currentSessionGenerating;InputShell/Composer 零改动

验证:npm run build 通过;vue-tsc 本次涉及文件无新增错误;
verify-chat-task-utils 24/24、verify-coordinator-multi-instance 11/11
(后者验证两个协调器实例的事件完全隔离——并行化的地基);
全量回归 31/8/10/21/30/36 全绿;validate-harness 通过。
⚠️ 浏览器双会话端到端未人工验证(含 6 个 mock 测试按钮回归)。

另:修正 dms-chat-storage 条目里停留在「一会话一行」旧版的描述——该粒度已在
上一轮改回「一问一答一条」,描述没跟上(记录比改动旧)。policy-card-list-sidebar
补上 passing(用户已点验,此前只改了证据没改状态)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 3 일 전
부모
커밋
6b3f5b237d

+ 11 - 0
README.md

@@ -157,3 +157,14 @@
 - 计划归档:`harness/docs/exec-plans/completed/` 开始启用,
   已实现的两个计划(DMS 落地、政策列表侧边栏)移入并更新状态;
   结构校验脚本新增两条检查(`active/` 里状态写着已完成、`completed/` 里状态仍写进行中)
+- **按会话并行生成**(改为 ChatGPT 桌面那种模型):
+  - 此前全局只有一个协调器实例 + 一个全局 `isGenerating`,于是「同一时间只能一个会话在生成」,
+    并衍生出两个 bug:生成中切到别的会话,发送按钮仍显示「停止」(想发送却停了生成);
+    停止时目标会话解析错误(同步事件把它提前清空 → 回落到「当前会话」),
+    会把停止打在看的那条会话上、并凭空造出一条空的「请求已取消」消息
+  - 现在生成状态按会话隔离(`tasks` Map + 每轮独立协调器实例 + 监听器闭包捕获会话 id),
+    各会话并行、互不干扰;停止只停自己那一轮;新建会话不再打断正在生成的会话
+  - 顺带修好了两处静默失败:后端 409 等提示此前**无人监听**(现接到全局 Toast)、
+    同会话重复发送此前静默无反应(现提示「该会话已有回答在生成中」)
+  - 新增回归脚本:`verify-chat-task-utils.mjs`(24 项)、
+    `verify-coordinator-multi-instance.mjs`(11 项,验证多实例事件隔离——并行化的地基)

+ 83 - 0
harness/docs/exec-plans/active/multi-session-parallel-generation.md

@@ -0,0 +1,83 @@
+# 多会话并行生成 — 实施计划
+
+> **状态**:🔄 已实现,待浏览器人工验证
+> **结果**:生成状态已按会话隔离(tasks Map + 每轮独立协调器);回归脚本 24/11 项通过
+
+## 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<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:`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 语义)
+- 两个壳模板各加 `<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 并行**:`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 按钮逐一回归;刷新恢复正确

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 20 - 3
harness/feature_list.json


+ 70 - 0
harness/progress.md

@@ -28,6 +28,76 @@
 
 ## 会话记录
 
+## Session 029
+
+- **日期**:2026-09-17
+- **本轮目标**:修「生成中在另一个会话提交 → 原会话被取消、新会话也出问题」,
+  按用户拍板改为**多会话并行生成**(ChatGPT 桌面模式)
+- **方案**:[`docs/exec-plans/active/multi-session-parallel-generation.md`](docs/exec-plans/active/multi-session-parallel-generation.md)
+
+### 根因(两层,均已实证)
+
+1. **按钮语义是全局的**:`InputShell` 的 `isGenerating ? emit('stop') : emit('send')`
+   用的是全局 `isGenerating` → 切到别的会话后按钮仍是「停止」,用户想发送却触发了停止
+2. **停止打错对象**:`handleStopGenerate` 先 `smc.stopGenerate()`(mitt **同步** emit close
+   → 监听器把 `activeGenerationSessionId` 清空),再取目标时
+   `getActiveGenerationSession() || currentSession` **必然** fallback 到当前会话 →
+   停止打在用户正在看的会话上;`stopAiMessage` 还会 `ensurePendingAiMessage`
+   **凭空造出一条空的"请求已取消"消息**
+
+### 已完成
+
+- **新增 `chat-generation-task.ts`**(类型 + 4 个纯决策函数,无 vue/网络依赖,可被 harness 打包):
+  `isSessionGenerating` / `resolveSendAction` / `resolveStopTargetTaskId` / `shouldNormalizeSessionHistory`
+- **composable 重构**:生成状态从「一个全局 smc + 一个 activeGenerationSessionId」
+  改为 **`tasks = ref(new Map<sessionId, ChatGenerationTask>())`**;
+  - **每轮新建 ApiChatCoordinator 实例**(生命周期 = 任务,mitt 随实例销毁)
+  - 每个任务**自己的 chunkBuffer**、监听器**闭包捕获 sessionId**(不再读「当前会话」)
+  - 新增 `finishTask` / `stopTask` / `stopAllTasks`;补问数据按会话存 `sessionInterruptPayloads`
+  - 删除 `smc` / `messageChunkBuffer` / `activeGenerationSessionId` / `dmsAnswerWrittenFor` /
+    `getActiveGenerationSession` / `finishActiveAiMessage` / `buildAppendHistoryPayload` / `setupCoordinator`
+  - `isGenerating` 改 `computed(() => tasks.size > 0)`;新增 `currentSessionGenerating` 给按钮用
+- **`handleStopGenerate`** 改为 `resolveStopTargetTaskId` → `stopTask` —— **旧 bug 自然消失**
+- **`startNewChat` 不再打断生成**(后台任务继续,与主流产品一致)
+- **新增 toast 通道**:`toastMessage` + `showToast`;PC/Mobile 各挂 `<Toast>`;
+  `info` 事件**终于有人监听**了(此前 409/错误提示用户完全无感)
+  - 同会话重复发送 → toast「该会话已有回答在生成中」(替换原来的静默 return)
+- **退出登录先 `stopAllTasks()`**(在 loadHistory 之前,避免旧账户任务写到新身份下)
+- **删除正在生成的会话** → 先停任务并抑制 DMS 补写(不留孤儿行)
+- UI:4 处 `:is-generating` 绑定改 `currentSessionGenerating`;InputShell/Composer **零改动**
+
+### 运行过的验证
+- `npm run build` 通过;`npx vue-tsc --noEmit` **本次涉及文件无新增类型错误**
+- **新增 `verify-chat-task-utils.mjs` — 24/24**:空/ null 边界、发送决策三态、
+  停止目标解析(**「当前在 B 时不会误停 A」**)、归一化守卫、多会话并行组合语义
+- **新增 `verify-coordinator-multi-instance.mjs` — 11/11**:
+  **两个协调器实例的事件完全隔离**(停 A 时 B 零事件、B 出错时 A 零事件、
+  各自计数独立)—— 这是并行化的地基
+- 全量回归:政策卡列表 31/31、卡字段 8/8、详情反查 10/10、政策库 21/21、
+  判空口径 30/30、DMS 36/36;`validate-harness` 通过
+
+### 已记录证据
+本文件 Session 029;两个新脚本;`exec-plans/active/multi-session-parallel-generation.md`
+
+### 更新过的文件或工件
+`src/components/business-assistant/chat-generation-task.ts`(新增)、
+`src/components/business-assistant/useBusinessAssistantChat.ts`、
+`src/components/BusinessAssistantPC.vue`、`src/components/BusinessAssistantMobile.vue`、
+`harness/tools/{_entry-chat-tasks.ts,verify-chat-task-utils.mjs,verify-coordinator-multi-instance.mjs,_entry-coordinator.ts}`、
+`harness/docs/exec-plans/active/multi-session-parallel-generation.md`、根 `README.md`、本文件
+
+### 已知风险或未解决问题
+- ⚠️ **浏览器端到端未人工验证**(这是本轮唯一缺口,自动化证据齐备):
+  A 生成中切 B 发送成功、各自停止互不影响、同会话重复发送被 toast 拦截、
+  退出登录停全部、删除生成中的会话无孤儿、**6 个 mock 测试按钮逐一回归**(本轮动过它们)
+- ⚠️ 每轮新建协调器实例,稳态最多 16 个存活实例(后端并发上限),无累积
+- ⚠️ 停止后立即重发可能撞后端 409(会话锁保留到执行线程结束)→ 现在有 toast 提示,
+  属预期行为而非 bug
+- ⚠️ mock 测试流任务没有协调器,其终止依赖 `typewriterTestSession`,已同步改为 per-session 判断
+
+### 下一步最佳动作
+浏览器双会话实测(上述清单),特别是 mock 测试按钮回归
+
 ## Session 028
 
 - **日期**:2026-09-17

+ 7 - 0
harness/tools/_entry-chat-tasks.ts

@@ -0,0 +1,7 @@
+/** esbuild 入口:按会话并行生成的纯决策函数 */
+export {
+  isSessionGenerating,
+  resolveSendAction,
+  resolveStopTargetTaskId,
+  shouldNormalizeSessionHistory,
+} from '../../src/components/business-assistant/chat-generation-task';

+ 9 - 2
harness/tools/_entry-coordinator.ts

@@ -1,2 +1,9 @@
-/** esbuild 入口:暴露适配层里可做纯逻辑验证的函数 */
-export { buildPolicyTableContent } from '../../src/components/api-chat-coordinator';
+/**
+ * esbuild 入口:暴露适配层里可做纯逻辑验证的东西。
+ *
+ * - `buildPolicyTableContent`:政策卡片标记生成(纯函数)
+ * - `ApiChatCoordinator`:协调器类。**验证多实例事件隔离**用——
+ *   每实例自带 mitt 事件总线,这是「按会话并行生成」的地基:
+ *   若两个实例的事件会串台,并行就会互相污染。
+ */
+export { buildPolicyTableContent, ApiChatCoordinator } from '../../src/components/api-chat-coordinator';

+ 84 - 0
harness/tools/verify-chat-task-utils.mjs

@@ -0,0 +1,84 @@
+/**
+ * 验证「按会话并行生成」的决策函数。
+ *
+ * 为什么要测:这几个函数决定「能不能发」「停止该停谁」「切会话要不要归一化」——
+ * 旧版本的 bug 正是死在"停止该停谁"上(停止打在了用户当前看的会话,而不是
+ * 真正在生成的那个)。这类错误不写断言很容易再犯。
+ *
+ * 怎么跑(在项目根目录):
+ *   npx esbuild harness/tools/_entry-chat-tasks.ts --bundle --format=esm \
+ *     --outfile=harness/tools/_chat-tasks.mjs
+ *   node harness/tools/verify-chat-task-utils.mjs
+ */
+
+import {
+  isSessionGenerating,
+  resolveSendAction,
+  resolveStopTargetTaskId,
+  shouldNormalizeSessionHistory,
+} from './_chat-tasks.mjs';
+
+let pass = 0;
+let fail = 0;
+const check = (name, cond, extra = '') => {
+  if (cond) {
+    pass++;
+    console.log('  ok   ' + name);
+  } else {
+    fail++;
+    console.log('  FAIL ' + name + '  ' + extra);
+  }
+};
+
+/** 造一个结构上够用的 stub task(决策函数只读 sessionId) */
+const stubTask = (sessionId) => ({
+  sessionId,
+  aiMessageId: `ai_${sessionId}`,
+  coordinator: null,
+  chunkBuffer: { push() {}, flush() {}, cancel() {} },
+  dmsAnswerWritten: false,
+  finished: false,
+});
+
+const tasksOf = (...ids) => new Map(ids.map((id) => [id, stubTask(id)]));
+
+console.log('场景:会话 A 正在生成,用户切到会话 B\n');
+const withA = tasksOf('A');
+
+console.log('【1】isSessionGenerating');
+check('A 在生成 → true', isSessionGenerating(withA, 'A') === true);
+check('B 没在生成 → false', isSessionGenerating(withA, 'B') === false);
+check('空 Map 不抛错 → false', isSessionGenerating(new Map(), 'A') === false);
+check('null Map 不抛错 → false', isSessionGenerating(null, 'A') === false);
+check('null 会话 id → false', isSessionGenerating(withA, null) === false);
+check('undefined 会话 id → false', isSessionGenerating(withA, undefined) === false);
+
+console.log('\n【2】resolveSendAction(发送守卫只按会话算)');
+check('在 B 发送 → 放行(**并行的关键**)', resolveSendAction(withA, 'B') === 'start', resolveSendAction(withA, 'B'));
+check('在 A 发送 → 拦截 busy-same-session', resolveSendAction(withA, 'A') === 'busy-same-session', resolveSendAction(withA, 'A'));
+check('没有会话(新建)→ 放行', resolveSendAction(withA, null) === 'start');
+check('空 Map → 放行', resolveSendAction(new Map(), 'A') === 'start');
+check('A、B 同时在生成时,C 仍可发', resolveSendAction(tasksOf('A', 'B'), 'C') === 'start');
+
+console.log('\n【3】resolveStopTargetTaskId(停止只停自己)');
+check('当前在 B、B 没生成 → null(按钮此时是"发送")', resolveStopTargetTaskId(withA, 'B') === null, String(resolveStopTargetTaskId(withA, 'B')));
+check('当前在 A、A 在生成 → 停 A', resolveStopTargetTaskId(withA, 'A') === 'A', String(resolveStopTargetTaskId(withA, 'A')));
+check('**关键:当前在 B 时不会误停 A**', resolveStopTargetTaskId(withA, 'B') !== 'A');
+check('null 会话 → null', resolveStopTargetTaskId(withA, null) === null);
+check('空 Map → null', resolveStopTargetTaskId(new Map(), 'A') === null);
+
+console.log('\n【4】shouldNormalizeSessionHistory');
+check('生成中的会话 → 不归一化', shouldNormalizeSessionHistory(withA, 'A') === false);
+check('没在生成的会话 → 归一化', shouldNormalizeSessionHistory(withA, 'B') === true);
+check('null 会话 → 归一化(与旧行为一致)', shouldNormalizeSessionHistory(withA, null) === true);
+check('空 Map → 归一化', shouldNormalizeSessionHistory(new Map(), 'A') === true);
+
+console.log('\n【5】多会话并行时的组合语义');
+const parallel = tasksOf('A', 'B');
+check('A、B 同时生成:各自都算"生成中"', isSessionGenerating(parallel, 'A') && isSessionGenerating(parallel, 'B'));
+check('A、B 同时生成:在 C 仍可发', resolveSendAction(parallel, 'C') === 'start');
+check('A、B 同时生成:停 A 不影响 B', resolveStopTargetTaskId(parallel, 'A') === 'A' && isSessionGenerating(parallel, 'B'));
+check('A、B 都在生成 → 两者都不归一化', shouldNormalizeSessionHistory(parallel, 'A') === false && shouldNormalizeSessionHistory(parallel, 'B') === false);
+
+console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
+process.exit(fail ? 1 : 0);

+ 99 - 0
harness/tools/verify-coordinator-multi-instance.mjs

@@ -0,0 +1,99 @@
+/**
+ * 验证 ApiChatCoordinator **多实例之间的事件隔离**。
+ *
+ * 为什么必须测:把「同一时间只能一个会话生成」改成「每会话并行」的前提,就是
+ * 两个协调器实例的事件总线互不串台。一旦串台,A 会话的流式内容会写进 B、
+ * 停止 A 会把 B 也停掉 —— 也就是重构前的那个 bug 的翻版。
+ *
+ * 不需要联网:`stopGenerate()` 纯本地;`generateAnswer()` 在空 baseUrl 下
+ * 直接 emitError('missing_base_url') 短路,不会发请求。
+ *
+ * 怎么跑(在项目根目录):
+ *   npx esbuild harness/tools/_entry-coordinator.ts --bundle --format=esm \
+ *     --outfile=harness/tools/_coordinator.mjs --alias:@=./src --define:import.meta.env='{}'
+ *   node harness/tools/verify-coordinator-multi-instance.mjs
+ */
+
+import { ApiChatCoordinator } from './_coordinator.mjs';
+
+let pass = 0;
+let fail = 0;
+const check = (name, cond, extra = '') => {
+  if (cond) {
+    pass++;
+    console.log('  ok   ' + name);
+  } else {
+    fail++;
+    console.log('  FAIL ' + name + '  ' + extra);
+  }
+};
+
+/** 给一个协调器实例挂上收集器,返回它收到的事件名数组 */
+const instrument = (coordinator) => {
+  const received = [];
+  for (const evt of ['message', 'loadingState', 'totalResponse', 'close', 'error', 'info']) {
+    coordinator.addEventListener(evt, () => received.push(evt));
+  }
+  return received;
+};
+
+const makeA = () => new ApiChatCoordinator({ baseUrl: '', threadId: 'thread-A' });
+const makeB = () => new ApiChatCoordinator({ baseUrl: '', threadId: 'thread-B' });
+
+console.log('场景:两个会话各自的协调器实例\n');
+
+console.log('【1】停止 A 不得影响 B');
+{
+  const a = makeA();
+  const b = makeB();
+  const gotA = instrument(a);
+  const gotB = instrument(b);
+
+  a.stopGenerate();
+
+  check('A 收到了自己的 close', gotA.includes('close'), gotA.join(','));
+  check('A 收到了自己的 loadingState', gotA.includes('loadingState'), gotA.join(','));
+  check('**B 完全没收到任何事件**', gotB.length === 0, gotB.join(','));
+}
+
+console.log('\n【2】B 出错/发请求失败不得影响 A');
+{
+  const a = makeA();
+  const b = makeB();
+  const gotA = instrument(a);
+  const gotB = instrument(b);
+
+  await b.generateAnswer('问题'); // 空 baseUrl → 立即 emitError,不发网络请求
+
+  check('B 收到了 error', gotB.includes('error'), gotB.join(','));
+  check('B 收到了 info(错误文案通道)', gotB.includes('info'), gotB.join(','));
+  check('**A 完全没收到任何事件**', gotA.length === 0, gotA.join(','));
+}
+
+console.log('\n【3】两个实例各自独立停止(并行会话同时生成后各停各的)');
+{
+  const a = makeA();
+  const b = makeB();
+  const gotA = instrument(a);
+  const gotB = instrument(b);
+
+  a.stopGenerate();
+  const afterA = gotB.length;
+  b.stopGenerate();
+
+  check('A 停完时 B 仍为零事件', afterA === 0, String(afterA));
+  check('B 随后收到自己的 close', gotB.includes('close'), gotB.join(','));
+  check('A 与 B 的事件计数各自独立', gotA.filter((e) => e === 'close').length === 1 && gotB.filter((e) => e === 'close').length === 1);
+}
+
+console.log('\n【4】实例状态互不污染');
+{
+  const a = makeA();
+  const b = makeB();
+  a.stopGenerate();
+  check('停 A 后,B 的 threadId 不受影响', b.threadId === 'thread-B', b.threadId);
+  check('两个实例不是同一个对象', a !== b);
+}
+
+console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
+process.exit(fail ? 1 : 0);

+ 11 - 4
src/components/BusinessAssistantMobile.vue

@@ -1,5 +1,7 @@
 <template>
   <div class="ba-mobile-container">
+    <!-- 全局提示(按会话并行生成后,发送冲突/后端 409 等都要给用户可见反馈) -->
+    <Toast :message="toastMessage" />
     <header v-if="currentPage === 'home' || currentPage === 'chat'" class="mobile-header"
       :class="{ 'chat-header': currentPage === 'chat' }">
       <button v-if="currentPage === 'home'" type="button" class="icon-btn menu-btn" aria-label="打开会话菜单"
@@ -67,7 +69,7 @@
           <BusinessAssistantComposer
             v-model="inputText"
             :uploaded-files="uploadedFiles"
-            :is-generating="isGenerating"
+            :is-generating="currentSessionGenerating"
             :is-deep-think="isDeepThink"
             :is-voice-input-mode="isVoiceInputMode"
             :is-voice-input-starting="isVoiceInputStarting"
@@ -133,7 +135,7 @@
           <BusinessAssistantComposer
             v-model="inputText"
             :uploaded-files="uploadedFiles"
-            :is-generating="isGenerating"
+            :is-generating="currentSessionGenerating"
             :is-deep-think="isDeepThink"
             :is-voice-input-mode="isVoiceInputMode"
             :is-voice-input-starting="isVoiceInputStarting"
@@ -391,6 +393,7 @@
 <script setup lang="ts">
 import { computed, nextTick, onBeforeUnmount, onMounted, provide, reactive, ref, watch } from 'vue';
 import BusinessRecord from "./Chat/BusinessRecord.vue";
+import Toast from "./Common/Toast.vue";
 import ScrollList from './Common/ScrollList.vue';
 import BusinessAssistantComposer from './business-assistant/BusinessAssistantComposer.vue';
 import { useBusinessAssistantChat } from './business-assistant/useBusinessAssistantChat';
@@ -521,9 +524,11 @@ const {
   currentSessionSuggestions,
   inputText,
   isGenerating,
+  currentSessionGenerating,
+  toastMessage,
+  stopAllTasks,
   isDeepThink,
   loadHistory,
-  setupCoordinator,
   sendMessage,
   sendChatStreamReplayTestMessage,
   sendTypewriterTestMessage,
@@ -557,6 +562,9 @@ const {
 const { isLoggedIn, creditCode, initAuth } = useEnterpriseAuth();
 
 const handleAccountLogout = () => {
+  // ⚠️ 必须先停掉全部生成任务:退出登录会切换 localStorage key,
+  // 旧账户的任务继续写会把内容落到新身份下
+  stopAllTasks();
   closeDrawer();
   currentPage.value = 'home';
   loadHistory();
@@ -927,7 +935,6 @@ onMounted(() => {
   if ((currentSession.value?.messages.length || 0) > 0) {
     currentPage.value = 'chat';
   }
-  setupCoordinator();
   updateListHeight();
   void observeChatSuggestionsHeight();
   window.addEventListener('resize', updateListHeight);

+ 11 - 4
src/components/BusinessAssistantPC.vue

@@ -1,5 +1,7 @@
 <template>
   <div class="ba-pc-container">
+    <!-- 全局提示(按会话并行生成后,发送冲突/后端 409 等都要给用户可见反馈) -->
+    <Toast :message="toastMessage" />
     <div class="bg-image" v-show="!currentSession" id="particle-bg" aria-hidden="true"></div>
 
     <aside class="aside-nav">
@@ -130,7 +132,7 @@
               class="home-input"
               :modelValue="inputText"
               :uploaded-files="uploadedFiles"
-              :is-generating="isGenerating"
+              :is-generating="currentSessionGenerating"
               :is-deep-think="isDeepThink"
               :is-voice-input-mode="isVoiceInputMode"
               :is-voice-input-starting="false"
@@ -241,7 +243,7 @@
                 class="chat-composer-shell"
                 :modelValue="inputText"
                 :uploaded-files="uploadedFiles"
-                :is-generating="isGenerating"
+                :is-generating="currentSessionGenerating"
                 :is-deep-think="isDeepThink"
                 :is-voice-input-mode="isVoiceInputMode"
                 :is-voice-input-starting="false"
@@ -340,6 +342,7 @@
 import { ref, onMounted, onBeforeUnmount, nextTick, computed, provide, watch, reactive } from 'vue';
 import { useResizeObserver } from '@vueuse/core';
 import BusinessRecord from "./Chat/BusinessRecord.vue";
+import Toast from "./Common/Toast.vue";
 import ScrollList from './Common/ScrollList.vue';
 import ASR from '@/three-libs/asr/index';
 import ParticleBackground from "./Common/ParticleBackground.vue";
@@ -398,10 +401,12 @@ const {
   currentSessionSuggestions,
   inputText,
   isGenerating,
+  currentSessionGenerating,
+  toastMessage,
+  stopAllTasks,
   isDeepThink,
   isChatting,
   loadHistory,
-  setupCoordinator,
   sendMessage,
   sendChatStreamReplayTestMessage,
   sendTypewriterTestMessage,
@@ -1027,6 +1032,9 @@ useResizeObserver(chatMessagesRef, (entries) => {
 });
 
 const handleAccountLogout = () => {
+  // ⚠️ 必须先停掉全部生成任务:退出登录会切换 localStorage key,
+  // 旧账户的任务继续写会把内容落到新身份下
+  stopAllTasks();
   void scrollToBottom(true);
   loadHistory();
   startNewChat();
@@ -1036,7 +1044,6 @@ onMounted(() => {
   document.addEventListener("policyPanelWidthChange", handlePanelWidthChange);
   document.addEventListener("refLinksPanelWidthChange", handlePanelWidthChange);
   loadHistory();
-  setupCoordinator();
   const fetchAndMergeRemoteSessions = async () => {
     const { fetchRemoteSessions } = await import('@/network/api/chat-sessions');
     const remoteSessions = await fetchRemoteSessions(creditCode.value);

+ 84 - 0
src/components/business-assistant/chat-generation-task.ts

@@ -0,0 +1,84 @@
+/**
+ * 「按会话并行生成」的任务模型与决策函数。
+ *
+ * 背景(2026-09-17 重构):此前整个应用只有**一个** `ApiChatCoordinator` 实例和一个
+ * 全局 `isGenerating`,于是「同一时间只能有一个会话在生成」。带来两个后果:
+ *   ① 发送按钮的语义是全局的 —— 切到别的会话后按钮仍是「停止」,想发送却触发了停止
+ *   ② 停止时按全局 `activeGenerationSessionId` 找目标,而它在同步事件里已被清空,
+ *      于是 fallback 到「当前会话」,把停止打在了用户正在看的那个会话上
+ *
+ * 现在改成 ChatGPT 桌面那种模型:**每个会话各自一个生成任务,互不干扰**。
+ * 后端支持(409 thread_busy 是 per-thread,见 reference/api-chat.md)。
+ *
+ * 本文件只放类型与**纯决策函数** —— 不 import vue、不发网络请求,
+ * 这样 harness 的 esbuild 脚本能直接打包它们做断言(见 tools/verify-chat-task-utils.mjs)。
+ */
+
+import type { ApiChatCoordinator } from "../api-chat-coordinator";
+
+/**
+ * 流式内容缓冲。结构与 `shared.ts` 的 `createAiMessageChunkBuffer` 返回值兼容,
+ * 这里用结构化接口而不是 import,避免把 shared 的依赖拖进纯逻辑层。
+ */
+export interface AiMessageChunkBufferLike {
+  push: (content: string) => void;
+  flush: () => void;
+  cancel: () => void;
+}
+
+/** 一个会话的「一轮生成」任务:每轮发送/补问/测试流各建一个 */
+export interface ChatGenerationTask {
+  /** 归属会话 id —— 内容路由与 DMS 写入都靠它(闭包捕获,不再读全局"当前生成会话") */
+  sessionId: string;
+  /** 本轮预置 AI 消息的 id:DMS 的 c_record_id,也是反馈定位用的 record.id */
+  aiMessageId: string;
+  /** 本轮独立的协调器实例;mock/测试流任务为 null */
+  coordinator: ApiChatCoordinator | null;
+  /** 本任务独立的内容缓冲:flush 时只写进本会话的 messages */
+  chunkBuffer: AiMessageChunkBufferLike;
+  /** answer 已补写进 DMS —— close 时不再重复写一次 */
+  dmsAnswerWritten: boolean;
+  /** 防重:totalResponse 与 close 会连续触发,只用第一个走清理 */
+  finished: boolean;
+}
+
+/** 该会话是否正在生成 */
+export const isSessionGenerating = (
+  tasks: ReadonlyMap<string, ChatGenerationTask> | null | undefined,
+  sessionId: string | null | undefined
+): boolean => !!sessionId && !!tasks && tasks.has(sessionId);
+
+/** 发送时的决策结果 */
+export type SendAction = "start" | "busy-same-session";
+
+/**
+ * 发送守卫:**同一会话**已有任务在跑 → 拦截(提示"已有回答在生成中");
+ * 别的会话在跑、或没有会话 → 放行。这是并行化的核心:冲突只按会话算。
+ */
+export const resolveSendAction = (
+  tasks: ReadonlyMap<string, ChatGenerationTask> | null | undefined,
+  sessionId: string | null | undefined
+): SendAction => (isSessionGenerating(tasks, sessionId) ? "busy-same-session" : "start");
+
+/**
+ * 停止的目标:只停**当前会话自己**的任务。
+ * 当前会话没在生成(按钮此时显示的是"发送"而不是"停止")→ null,什么都不做。
+ *
+ * ⚠️ 这个函数的存在就是为了根治旧 bug:以前是在 `stopGenerate()` 之后才去找目标,
+ * 而那个调用会同步触发 close、把"正在生成的会话"清空,于是必然 fallback 到当前会话、
+ * 把停止打在错误的会话上。现在目标在动手**之前**解析,且由任务的闭包决定写哪。
+ */
+export const resolveStopTargetTaskId = (
+  tasks: ReadonlyMap<string, ChatGenerationTask> | null | undefined,
+  currentSessionId: string | null | undefined
+): string | null =>
+  isSessionGenerating(tasks, currentSessionId) ? (currentSessionId as string) : null;
+
+/**
+ * 切换会话时,是否对该会话做历史归一化。
+ * 生成中的会话不能归一化 —— 归一化会把还在流式的 pending 内容判成"中断"并写死文案。
+ */
+export const shouldNormalizeSessionHistory = (
+  tasks: ReadonlyMap<string, ChatGenerationTask> | null | undefined,
+  sessionId: string | null | undefined
+): boolean => !isSessionGenerating(tasks, sessionId);

+ 233 - 197
src/components/business-assistant/useBusinessAssistantChat.ts

@@ -1,10 +1,17 @@
-import { computed, provide, ref, watch } from "vue";
+import { computed, nextTick, provide, ref, watch } from "vue";
 import cardAPI from '@/network/api/card/index';
 import { isZhaoshangLLM } from '@/utils';
 import { getChatApiBaseUrl } from '@/utils/runtime-config';
 // [旧协议] 旧聊天流式协调器(/knowledge/chat),已由 api-chat-coordinator 取代
 // import { StreamMessageCoordinator } from '../stream-message-coordinator';
-import { ApiChatCoordinator, toChatQuestionAnswer } from '../api-chat-coordinator';
+import { ApiChatCoordinator, toChatQuestionAnswer, type ChatInterruptPayload } from '../api-chat-coordinator';
+import {
+  ChatGenerationTask,
+  isSessionGenerating,
+  resolveSendAction,
+  resolveStopTargetTaskId,
+  shouldNormalizeSessionHistory,
+} from './chat-generation-task';
 import {
   BusinessAssistantInputFileTransmission,
   BusinessAssistantSession,
@@ -44,18 +51,28 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
   const currentSessionIndex = ref(-1);
   const currentSessionVisitedBefore = ref(false);
   const inputText = ref('');
-  const isGenerating = ref(false);
+  /**
+   * 按会话并行的生成任务表(key = sessionId)。
+   *
+   * 用 `ref(new Map())` 而不是 `reactive(new Map())`:只需要 `.size` 这一个响应式依赖,
+   * reactive 会把 task(含协调器实例)整体 proxy 化,既无意义又可能出怪问题。
+   */
+  const tasks = ref(new Map<string, ChatGenerationTask>());
+  /** 最近一次补问数据,按会话存(补问提交发生在上一轮结束之后,协调器实例已销毁) */
+  const sessionInterruptPayloads = new Map<string, ChatInterruptPayload>();
+  /** 是否**有任何**会话在生成(mock 测试按钮等全局场景用) */
+  const isGenerating = computed(() => tasks.value.size > 0);
+  /** **当前会话**是否在生成 —— 发送按钮的语义由它决定(并行化的关键) */
+  const currentSessionGenerating = computed(() =>
+    isSessionGenerating(tasks.value, currentSession.value?.id ?? null)
+  );
+  /** 全局 toast 文案(PC/Mobile 各挂一个 <Toast :message="toastMessage" />) */
+  const toastMessage = ref('');
   const isZhaoshangMode = isZhaoshangLLM();
   const storedThinking = isZhaoshangMode ? null : localStorage.getItem('enable_thinking');
   const isDeepThink = ref(isZhaoshangMode ? false : storedThinking !== null ? storedThinking === 'true' : true);
-  // [旧协议] let smc: StreamMessageCoordinator | null = null;
-  let smc: ApiChatCoordinator | null = null;
-  let messageChunkBuffer: ReturnType<typeof createAiMessageChunkBuffer> | null = null;
-  let activeGenerationSessionId: string | null = null;
   let typewriterTestTimer: number | null = null;
   let typewriterTestSession: BusinessAssistantSession | null = null;
-  /** 已把 answer 写进 DMS 的那条 AI 消息 id(避免 totalResponse 与 close 各写一次) */
-  let dmsAnswerWrittenFor: string | null = null;
 
   const clearTypewriterTestTimer = () => {
     if (typewriterTestTimer === null) {
@@ -192,11 +209,10 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     return chatHistory.value.find((session) => session.id === sessionId) || null;
   };
 
-  const getActiveGenerationSession = () => getSessionById(activeGenerationSessionId);
-
+  /** 当前会话是否在生成 —— 只影响「该会话最后一条 AI 消息保持进行中」的展示 */
   const markSessionMessagesAsHistory = (session: BusinessAssistantSession) => {
-    const activeLastMessageIndex =
-      session.id === activeGenerationSessionId ? session.messages.length - 1 : -1;
+    const generatingHere = isSessionGenerating(tasks.value, session.id);
+    const activeLastMessageIndex = generatingHere ? session.messages.length - 1 : -1;
 
     session.messages.forEach((message, index) => {
       if (index === activeLastMessageIndex && message.role === 'ai') {
@@ -208,62 +224,73 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     });
   };
 
-  const finishActiveAiMessage = () => {
-    const finishingSessionId = activeGenerationSessionId;
-    isGenerating.value = false;
-    messageChunkBuffer?.flush();
-    const targetSession = getSessionById(finishingSessionId);
-    activeGenerationSessionId = null;
-    if (!targetSession) {
-      return;
+  /** 全局提示(PC/Mobile 各挂一个 Toast 组件消费) */
+  const showToast = (message: string) => {
+    if (!message) return;
+    // Toast 靠 message 变化触发:同一条文案要能再次弹出,先清空再置回
+    if (toastMessage.value === message) {
+      toastMessage.value = '';
     }
-
-    finishAiMessage(targetSession.messages);
+    void nextTick(() => {
+      toastMessage.value = message;
+    });
   };
 
-  const buildAppendHistoryPayload = (payload: any) => {
-    const historyPayload: any = {
-      question: payload?.question || smc?.question,
-      answer: payload?.answer || smc?.totalResponse,
-      record_id: payload?.record_id || smc?.record_id,
-      session: payload?.session || smc?.session,
-      knowledge_ids: payload?.knowledge_ids || smc?.knowledge_ids,
-    };
 
-    if (isZhaoshangMode) {
-      historyPayload.source = (globalThis as any).source || 'zhaoshang';
-      historyPayload.credit_code = globalThis.transmission?.credit_code;
+  /**
+   * 收尾一个生成任务:落残余内容 → 释放缓冲 → 从任务表移除 → 收尾消息状态 → 存档。
+   *
+   * 会被 totalResponse / close / error / stopTask 多条路径调用,靠 `task.finished`
+   * 与「表里还是不是这个 task」双重防重(mitt 是**同步** emit,事件会连着来)。
+   */
+  const finishTask = (task: ChatGenerationTask) => {
+    if (task.finished || tasks.value.get(task.sessionId) !== task) {
+      return;
+    }
+    task.finished = true;
+
+    task.chunkBuffer.flush();
+    task.chunkBuffer.cancel();
+    tasks.value.delete(task.sessionId);
+
+    // 补问提交发生在**下一轮**(协调器实例已销毁),所以把最近一次补问数据留在会话上
+    if (task.coordinator?.lastInterruptPayload) {
+      sessionInterruptPayloads.set(task.sessionId, task.coordinator.lastInterruptPayload);
     }
 
-    return historyPayload;
+    const session = getSessionById(task.sessionId);
+    if (session) {
+      finishAiMessage(session.messages); // 已是 Stop 态则内部跳过
+    }
+    saveHistory();
   };
 
-  const setupCoordinator = () => {
-    // [旧协议] 旧后端 /knowledge/chat(或 /dialog/chat)与 StreamMessageCoordinator
-    // const cardURL = options.apiBaseUrl ?? getApiBaseUrl();
-    // const sseURL = cardURL + (isKnowledgeApi() ? '/knowledge/chat' : '/dialog/chat');
-    // smc = new StreamMessageCoordinator({
-    //   card_id: globalThis.card_id,
-    //   session: currentSession.value?.id || globalThis.currentSession || createBusinessAssistantId(),
-    //   SSEURL: sseURL,
-    //   token: globalThis.token,
-    //   isStrict: false,
-    // });
-
-    // 新版协议:POST {VITE_CHAT_API}/api/chat,thread_id 即 session id
-    smc = new ApiChatCoordinator({
-      baseUrl: getChatApiBaseUrl(),
-      threadId: currentSession.value?.id || globalThis.currentSession || createBusinessAssistantId(),
-    });
+  /**
+   * 建一个生成任务,并把该会话的事件监听挂到它自己的协调器上。
+   *
+   * ⚠️ 所有监听器**闭包捕获 sessionId**,一律不读「当前会话」「当前生成会话」——
+   * 这正是并行化的关键:用户在 A 生成时切到 B、在 B 提问,两条流各写各的会话,
+   * 不会因为"谁是当前会话"而串台。
+   */
+  const createGenerationTask = (
+    sessionId: string,
+    aiMessageId: string,
+    coordinator: ApiChatCoordinator | null, // mock/测试流任务传 null
+  ): ChatGenerationTask => {
+    const task: ChatGenerationTask = {
+      sessionId,
+      aiMessageId,
+      coordinator,
+      chunkBuffer: null as unknown as ChatGenerationTask['chunkBuffer'],
+      dmsAnswerWritten: false,
+      finished: false,
+    };
 
-    messageChunkBuffer = createAiMessageChunkBuffer({
+    task.chunkBuffer = createAiMessageChunkBuffer({
       append: (content) => {
-        const targetSession = getActiveGenerationSession();
-        if (!targetSession) {
-          return;
-        }
-
-        appendAiMessageChunk(targetSession.messages, content);
+        const session = getSessionById(sessionId);
+        if (!session) return;
+        appendAiMessageChunk(session.messages, content);
       },
       onFlush: () => {
         void options.onMessageUpdated?.();
@@ -271,112 +298,93 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       },
     });
 
-    smc.addEventListener('message', (msg: string) => {
-      if (!getActiveGenerationSession()) {
-        return;
-      }
-
-      messageChunkBuffer?.push(msg);
+    coordinator?.addEventListener('message', (msg: string) => {
+      if (tasks.value.get(sessionId) !== task) return;
+      if (!getSessionById(sessionId)) return;
+      task.chunkBuffer.push(msg);
     });
 
-    // [旧协议] 新协议没有 record_id 与 <followup> 标签,这两个监听已无对应事件;
-    // 卡片/来源/补问等内容由协调器翻译成 <scope>/POLICY_TABLE/<ref_links>/<question-cards>
-    // 后经上面统一的 message 通道下发,渲染层无需再感知新协议。
-    // smc.addEventListener('recordId', (recordId: string) => {
-    //   if (!recordId) {
-    //     return;
-    //   }
-    //
-    //   const targetSession = getActiveGenerationSession();
-    //   if (!targetSession) {
-    //     return;
-    //   }
-    //
-    //   const lastAiMessage = getLastAiMessage(targetSession.messages);
-    //   if (!lastAiMessage || lastAiMessage.id === recordId) {
-    //     return;
-    //   }
-    //
-    //   lastAiMessage.id = recordId;
-    //   saveHistory();
-    // });
-    //
-    // smc.addEventListener('followupSuggestions', (followupSuggestions: BusinessAssistantSuggestion[]) => {
-    //   const targetSession = getActiveGenerationSession();
-    //   if (!targetSession) {
-    //     return;
-    //   }
-    //
-    //   targetSession.followupSuggestions = Array.isArray(followupSuggestions)
-    //     ? followupSuggestions.filter((item) => item?.text)
-    //     : [];
-    //   targetSession.followupSuggestionsHidden = false;
-    //   saveHistory();
-    // });
-
-    smc.addEventListener('loadingState', (payload: any) => {
-      if (payload && payload.state === false) {
-        isGenerating.value = false;
-      }
-    });
-
-    smc.addEventListener('totalResponse', (payload: any) => {
-      // ⚠️ 必须在 finishActiveAiMessage() 之前捕获:它会清空 activeGenerationSessionId,
-      // 而且用户此刻可能已经切走会话,之后取 currentSession 会指错对象
-      const targetSession = getActiveGenerationSession();
-      const lastAiMessage = targetSession ? getLastAiMessage(targetSession.messages) : null;
-
-      finishActiveAiMessage();
-      const historyPayload = buildAppendHistoryPayload(payload);
-      // appendHistory 已禁用:历史记录改由 chat 接口的 enable_history 控制
-      // if (historyPayload.question) {
-      //   cardAPI.appendHistory(historyPayload);
-      // }
-      saveHistory();
+    coordinator?.addEventListener('totalResponse', (payload: any) => {
+      if (tasks.value.get(sessionId) !== task) return;
+      const session = getSessionById(sessionId);
+      const lastAiMessage = session ? getLastAiMessage(session.messages) : null;
 
       // 回答完成 → 把 answer 补进这一轮的 DMS 记录(提交时已写 question)
-      if (targetSession && lastAiMessage) {
-        dmsAnswerWrittenFor = lastAiMessage.id; // 标记:close 时不必再补一次
+      if (session && lastAiMessage && lastAiMessage.id === aiMessageId) {
+        task.dmsAnswerWritten = true; // 标记:close 时不必再补一次
         saveTurnToDms({
-          sessionId: targetSession.id,
+          sessionId,
           recordId: lastAiMessage.id,
           ...(payload?.question ? { question: payload.question } : {}),
           ...(payload?.answer ? { answer: payload.answer } : {}),
         });
       }
 
-      // 首轮完成时同步标题(会话行在 sendMessage 里已建,这里按 c_session_id 更新)
-      const session = currentSession.value;
+      // 首轮完成时同步标题(会话行在发送时已建,这里按 c_session_id 更新)
       if (session && session.messages.length <= 2) {
         syncSessionTitleToServer(session.id, session.title || '');
       }
+
+      finishTask(task);
     });
 
-    smc.addEventListener('close', () => {
-      // 停止/断流走这里(没有 totalResponse):把已经生成出来的内容补写进 DMS,
-      // 否则被中断的问答在库里会只有 question 没有 answer
-      const targetSession = getActiveGenerationSession();
-      const lastAiMessage = targetSession ? getLastAiMessage(targetSession.messages) : null;
-      finishActiveAiMessage();
-      saveHistory();
+    coordinator?.addEventListener('close', () => {
+      if (tasks.value.get(sessionId) !== task) return; // totalResponse 已收尾,这里空转
+      const session = getSessionById(sessionId);
+      const lastAiMessage = session ? getLastAiMessage(session.messages) : null;
 
+      // 停止/断流走这里(没有 totalResponse):把已生成的内容补写进 DMS,
+      // 否则被中断的问答在库里会只有 question 没有 answer
       if (
-        targetSession &&
+        session &&
         lastAiMessage &&
-        lastAiMessage.id !== dmsAnswerWrittenFor && // 正常完成时已写过,别重复
+        !task.dmsAnswerWritten &&
         String(lastAiMessage.content || '').trim()
       ) {
-        saveTurnToDms({
-          sessionId: targetSession.id,
-          recordId: lastAiMessage.id,
-          answer: lastAiMessage.content,
-        });
+        saveTurnToDms({ sessionId, recordId: lastAiMessage.id, answer: lastAiMessage.content });
       }
+      finishTask(task);
     });
 
-    smc.addEventListener('error', () => {
-      finishActiveAiMessage();
+    coordinator?.addEventListener('error', () => {
+      finishTask(task);
     });
+
+    // 提示类事件不校验任务归属:出错时 emitError 会**先**发 error(本任务已被收尾、
+    // 从表里移除)再发 info,加了校验就会把 409 之类的提示吞掉。
+    coordinator?.addEventListener('info', (text: string) => {
+      if (text) showToast(text);
+    });
+
+    return task;
+  };
+
+  /** mock/测试流任务:没有协调器,由外部手动驱动 */
+  const createMockTask = (sessionId: string, aiMessageId: string): ChatGenerationTask =>
+    createGenerationTask(sessionId, aiMessageId, null);
+
+  /** 停止某个会话的生成(只停这一个) */
+  const stopTask = (sessionId: string) => {
+    const task = tasks.value.get(sessionId);
+    if (!task) return;
+
+    // 先置 Stop:晚到的 chunk 会被 appendAiMessageChunk 的 Stop 守卫丢弃
+    const session = getSessionById(sessionId);
+    if (session) {
+      stopAiMessage(session.messages);
+    }
+
+    if (task.coordinator) {
+      // 同步 emit close → close 监听器负责 DMS 补写与收尾
+      task.coordinator.stopGenerate();
+    }
+    finishTask(task); // 兜底:收尾不依赖监听器(mock 任务没有协调器)
+    saveHistory();
+  };
+
+  /** 停掉全部生成(退出登录等场景:必须赶在 storage key 切换之前) */
+  const stopAllTasks = () => {
+    [...tasks.value.keys()].forEach((sessionId) => stopTask(sessionId));
   };
 
   const ensureSession = (titleSeed: string) => {
@@ -396,10 +404,6 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     chatHistory.value.unshift(session);
     currentSessionIndex.value = 0;
     saveCurrentSessionId(sessionId);
-    if (smc) {
-      // [旧协议] smc.session = sessionId;
-      smc.threadId = sessionId;
-    }
     return session;
   };
 
@@ -410,18 +414,20 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     }
 
     const text = inputText.value.trim();
-    if (!text || isGenerating.value) {
+    if (!text) {
+      return;
+    }
+
+    // 守卫按**会话**算:同一会话已有回答在生成中 → 拦截并提示;
+    // 别的会话在生成不受影响(这是按会话并行的前提)
+    if (resolveSendAction(tasks.value, currentSession.value?.id ?? null) === 'busy-same-session') {
+      showToast('该会话已有回答在生成中');
       return;
     }
 
     const session = ensureSession(text);
     session.followupSuggestionsHidden = true;
 
-    if (smc) {
-      // [旧协议] smc.session = session.id;
-      smc.threadId = session.id;
-    }
-
     if (!globalThis.transmission) {
       globalThis.transmission = {};
     }
@@ -437,9 +443,14 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     // 也是反馈组件用的 record.id —— 三条链路同一个值,反馈才定位得到
     const pendingAiMessage = ensurePendingAiMessage(session.messages);
     inputText.value = '';
-    isGenerating.value = true;
-    activeGenerationSessionId = session.id;
-    dmsAnswerWrittenFor = null; // 新一轮开始,允许 close 补写
+
+    // 本轮独立的协调器:thread_id 就是这个会话 id;实例的生命周期与本轮任务一致
+    const coordinator = new ApiChatCoordinator({
+      baseUrl: getChatApiBaseUrl(),
+      threadId: session.id,
+    });
+    tasks.value.set(session.id, createGenerationTask(session.id, pendingAiMessage.id, coordinator));
+
     saveHistory();
     await options.afterSend?.();
 
@@ -448,7 +459,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     void upsertDmsSession({ sessionId: session.id, title: session.title });
     saveTurnToDms({ sessionId: session.id, recordId: pendingAiMessage.id, question: text });
 
-    smc?.generateAnswer(text);
+    void coordinator.generateAnswer(text);
   };
 
   const playMockStreamMessage = async ({
@@ -461,38 +472,43 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     delayMs: number;
   }) => {
     const beforeResult = await options.beforeSend?.();
-    if (beforeResult === false || isGenerating.value) {
+    if (beforeResult === false) {
       return;
     }
 
     const session = ensureSession(text);
+    if (isSessionGenerating(tasks.value, session.id)) {
+      showToast('该会话已有回答在生成中');
+      return;
+    }
     typewriterTestSession = session;
     session.followupSuggestionsHidden = true;
     session.messages.push(createUserMessage(text));
-    ensurePendingAiMessage(session.messages);
+    const pendingAiMessage = ensurePendingAiMessage(session.messages);
     inputText.value = '';
-    isGenerating.value = true;
+    const mockTask = createMockTask(session.id, pendingAiMessage.id);
+    tasks.value.set(session.id, mockTask);
     saveHistory();
     await options.afterSend?.();
 
     let index = 0;
 
     const finishMockStreamMessage = () => {
-      if (!typewriterTestSession || !isGenerating.value) {
+      if (!typewriterTestSession || !isSessionGenerating(tasks.value, typewriterTestSession.id)) {
         clearTypewriterTestTimer();
         return;
       }
 
-      isGenerating.value = false;
       finishAiMessage(typewriterTestSession.messages);
       saveHistory();
       void options.onMessageUpdated?.();
       typewriterTestSession = null;
       typewriterTestTimer = null;
+      finishTask(mockTask);
     };
 
     const appendNextChunk = () => {
-      if (!typewriterTestSession || !isGenerating.value) {
+      if (!typewriterTestSession || !isSessionGenerating(tasks.value, typewriterTestSession.id)) {
         clearTypewriterTestTimer();
         return;
       }
@@ -517,18 +533,23 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
 
   const sendTypewriterTestMessage = async () => {
     const beforeResult = await options.beforeSend?.();
-    if (beforeResult === false || isGenerating.value) {
+    if (beforeResult === false) {
       return;
     }
 
     const text = '测试打字机效果';
     const session = ensureSession(text);
+    if (isSessionGenerating(tasks.value, session.id)) {
+      showToast('该会话已有回答在生成中');
+      return;
+    }
     typewriterTestSession = session;
     session.followupSuggestionsHidden = true;
     session.messages.push(createUserMessage(text));
-    ensurePendingAiMessage(session.messages);
+    const pendingAiMessage = ensurePendingAiMessage(session.messages);
     inputText.value = '';
-    isGenerating.value = true;
+    const mockTask = createMockTask(session.id, pendingAiMessage.id);
+    tasks.value.set(session.id, mockTask);
     saveHistory();
     await options.afterSend?.();
 
@@ -1214,21 +1235,21 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     );
 
     const finishTypewriterTestMessage = () => {
-      if (!typewriterTestSession || !isGenerating.value) {
+      if (!typewriterTestSession || !isSessionGenerating(tasks.value, typewriterTestSession.id)) {
         clearTypewriterTestTimer();
         return;
       }
 
-      isGenerating.value = false;
       finishAiMessage(typewriterTestSession.messages);
       saveHistory();
       void options.onMessageUpdated?.();
       typewriterTestSession = null;
       typewriterTestTimer = null;
+      finishTask(mockTask);
     };
 
     const appendNextChunk = () => {
-      if (!typewriterTestSession || !isGenerating.value) {
+      if (!typewriterTestSession || !isSessionGenerating(tasks.value, typewriterTestSession.id)) {
         clearTypewriterTestTimer();
         return;
       }
@@ -1433,27 +1454,26 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     });
   };
 
+  /**
+   * 停止生成 —— 只停**当前会话**自己的那一轮。
+   *
+   * ⚠️ 这里曾有一个隐蔽的 bug:旧实现先调 `stopGenerate()`(同步 emit close →
+   * 监听器把"正在生成的会话"清空),再去找目标,于是 `getActiveGenerationSession()
+   * || currentSession` 必然 fallback 到**用户当前看的会话** —— 停止打错了对象,
+   * 还会凭空给那个会话造出一条空的"请求已取消"消息。
+   * 现在目标由 `resolveStopTargetTaskId` 在动手**之前**解析,且任务的监听器只操作
+   * 自己闭包里的会话。
+   */
   const handleStopGenerate = () => {
     clearTypewriterTestTimer();
-    if (typewriterTestSession) {
-      stopAiMessage(typewriterTestSession.messages);
-      typewriterTestSession = null;
-    }
-    smc?.stopGenerate();
-    messageChunkBuffer?.cancel();
-    isGenerating.value = false;
-    const targetSession = getActiveGenerationSession() || currentSession.value;
-    activeGenerationSessionId = null;
-    if (targetSession) {
-      stopAiMessage(targetSession.messages);
-      saveHistory();
+    const targetTaskId = resolveStopTargetTaskId(tasks.value, currentSession.value?.id ?? null);
+    if (targetTaskId) {
+      stopTask(targetTaskId);
     }
   };
 
+  /** 新建会话:**不再**打断正在生成的会话 —— 后台任务继续跑(与主流产品一致) */
   const startNewChat = () => {
-    if (isGenerating.value) {
-      handleStopGenerate();
-    }
     currentSessionIndex.value = -1;
     currentSessionVisitedBefore.value = false;
     inputText.value = '';
@@ -1471,11 +1491,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     markSessionMessagesAsHistory(selectedSession);
     selectedSession.viewed = true;
     currentSessionVisitedBefore.value = true;
-    if (smc && currentSession.value?.id && !activeGenerationSessionId) {
-      // [旧协议] smc.session = currentSession.value.id;
-      smc.threadId = currentSession.value.id;
-    }
-    if (currentSession.value && currentSession.value.id !== activeGenerationSessionId) {
+    if (currentSession.value && shouldNormalizeSessionHistory(tasks.value, currentSession.value.id)) {
       const normalized = normalizeSessionHistory(currentSession.value);
       if (normalized) {
         saveHistory();
@@ -1515,6 +1531,16 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     }
 
     const sessionId = chatHistory.value[index].id;
+
+    // 该会话若正在生成:先停掉它的任务(否则流会继续跑、任务残留在表里)。
+    // 抑制这次 DMS 补写——会话马上要被删掉,留一行孤儿问答没有意义。
+    const runningTask = tasks.value.get(sessionId);
+    if (runningTask) {
+      runningTask.dmsAnswerWritten = true;
+      stopTask(sessionId);
+    }
+    sessionInterruptPayloads.delete(sessionId);
+
     chatHistory.value.splice(index, 1);
     if (currentSessionIndex.value === index) {
       currentSessionIndex.value = -1;
@@ -1601,26 +1627,31 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
 
   const submitQuestionAnswers = async (answers: any[]) => {
     const session = currentSession.value;
-    if (!session || !smc) {
-      console.warn('[submitQuestionAnswers] No session or smc available');
+    if (!session) {
+      console.warn('[submitQuestionAnswers] No session available');
       return;
     }
-    if (isGenerating.value) {
+    if (resolveSendAction(tasks.value, session.id) === 'busy-same-session') {
+      showToast('该会话已有回答在生成中');
       return;
     }
 
     // 原 <question-cards> 提交的是 {answers} 对象,这里翻译成协议要求的 question 字符串。
-    // 传入最近一次补问数据:公司候选要按 label 反查、拼出选项的全部内容。
-    const answerText = toChatQuestionAnswer(answers, smc.lastInterruptPayload);
+    // 传入最近一次补问数据(按会话存):公司候选要按 label 反查、拼出选项的全部内容。
+    const answerText = toChatQuestionAnswer(answers, sessionInterruptPayloads.get(session.id));
     if (!answerText) {
       return;
     }
 
     session.messages.push(createUserMessage('已提交企业信息补充'));
     const pendingAiMessage = ensurePendingAiMessage(session.messages);
-    isGenerating.value = true;
-    activeGenerationSessionId = session.id;
-    dmsAnswerWrittenFor = null;
+
+    const coordinator = new ApiChatCoordinator({
+      baseUrl: getChatApiBaseUrl(),
+      threadId: session.id,
+    });
+    tasks.value.set(session.id, createGenerationTask(session.id, pendingAiMessage.id, coordinator));
+
     saveHistory();
     await options.afterSend?.();
 
@@ -1629,7 +1660,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     void upsertDmsSession({ sessionId: session.id, title: session.title });
     saveTurnToDms({ sessionId: session.id, recordId: pendingAiMessage.id, question: answerText });
 
-    smc.generateAnswer(answerText);
+    void coordinator.generateAnswer(answerText);
   };
 
   provide('submitQuestionAnswers', submitQuestionAnswers);
@@ -1657,12 +1688,15 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     currentSessionTitle,
     currentSessionSuggestions,
     inputText,
+    /** 是否有**任何**会话在生成(mock 测试按钮等全局场景用) */
     isGenerating,
+    /** **当前会话**是否在生成 —— 发送/停止按钮的语义由它决定 */
+    currentSessionGenerating,
+    /** 全局提示文案(壳组件挂 <Toast :message="toastMessage" />) */
+    toastMessage,
     isDeepThink,
     isChatting,
-    smc: () => smc,
     loadHistory,
-    setupCoordinator,
     saveHistory,
     sendMessage,
     sendChatStreamReplayTestMessage,
@@ -1672,6 +1706,8 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     sendRefLinksStreamTestMessage,
     sendQuestionCardTestMessage,
     handleStopGenerate,
+    /** 停止全部会话的生成(退出登录等场景用) */
+    stopAllTasks,
     startNewChat,
     switchSession,
     switchSessionById,

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.