# F-260616-09 B 阶段 — AiSession 单例→多会话并发架构设计 > **状态**:设计稿(2026-06-16) | **关联**:[docs/待决策.md F-260616-09](../待决策.md) / [todo F-260616-12](../todo.md) / memory `aichat-arch-extensibility` > **决策基线**(已定,勿推翻):a A 先隔离 + B 立项 / b 仅拆 generating+stop_flag per-conv(非整 HashMap) / c 复用 llm_concurrency.global 限并发会话数 / d1 侧栏 + d2 独立窗口都做(先 d1 后 d2) / e 切换不退出旧 loop 各自跑完(真并发) > **范围**:仅本设计文档,不改代码。本文 file:line 证据均独立 grep/read 核验(不信文档/会话描述,防上下文污染)。 --- ## 1. 现状盘点(AiSession 单例 + 字段消费点) ### 1.1 单例定义与初始化 **单例锚点**(state.rs:165): ```rust pub ai_session: Arc>, // init: state.rs:216 ai_session: Arc::new(Mutex::new(AiSession::new())), ``` 整个应用**一份** `AiSession`,被 `Arc>` 包裹,所有 IPC 命令竞争同一把锁。 **AiSession 字段**(mod.rs:166-216,共 10 字段): | 字段 | 行 | 类型 | 多会话语义 | |---|---|---|---| | `messages` | 168 | `ContextManager` | **会话级**(每对话一份历史),当前单例:切换走 `restore_from_messages` 覆盖 | | `active_provider_id` | 170 | `Option` | 应用级(provider 全局共享),保持单例 | | `active_conversation_id` | 172 | `Option` | **会话级**(当前活跃 conv 路由键),单例=只一个 active | | `active_conv_created_at` | 174 | `Option` | **会话级**(懒创建时间戳,随 active_conv) | | `pending_approvals` | 186 | `HashMap` | **会话级**(已带 `conversation_id` 字段 audit.rs:598/276),单例下靠 retain 过滤 | | `generating` | 188 | `bool` | **会话级**(每对话独立流式态),单例=全局互斥 | | `agent_language` | 190 | `Option` | **会话级**(每对话独立语言),单例下切换须清空 | | `stop_flag` | 192 | `Arc` | **会话级**(每对话独立停止信号),单例=全局唯一 | | `notify` | 203 | `Arc` | **会话级**(随 stop_flag 配对),单例=全局唯一 | | `iteration_used` | 215 | `usize` | **会话级**(每对话独立计数,mod.rs:214 注释已标"F-09 B 改 per-conv") | **关键观察**:`mod.rs:214` 注释自身已写「**当前 AiSession 是全局单例(F-09 B 多会话架构落地时改 per-conv)**」——本设计是早已预留的债。 ### 1.2 字段消费点 grep 核验(file:line) > 全部为 grep 实测命中,非文档转述。`state.ai_session.lock().await` 共 **41 处**(commands.rs 主集中)。 **`generating` 写/读点**(单例互斥根因): | 文件:行 | 操作 | 说明 | |---|---|---| | `commands.rs:48/51` | read+write | ai_regenerate: `if session.generating {return Err}` + `=true` | | `commands.rs:125` | read | ai_is_generating IPC(前端 force_send 预检用) | | `commands.rs:145/148` | read+write | **ai_chat_send 互斥入口**:`if generating {Err "正在生成中"}` + `=true` | | `commands.rs:399/420` | read+write | ai_edit_last 同款互斥 | | `commands.rs:505/534/540/565/602/636/644` | write | force_send/stop/continue/stop_loop 多路复位 | | `commands.rs:848/850` | read+write | **ai_conversation_create:生成中强制结束旧会话**(B-260615-10 软复位) | | `commands.rs:935` | read | ai_conversation_switch:**生成中 readonly 切换**(:935-941,后台 loop 不动 active) | | `agentic.rs:117` | guard | GeneratingGuard RAII 收敛复位(:85-94 Drop 兜底) | | `agentic.rs:585` | read | try_continue_agent_loop:读 generating + pending 决定续跑 | | `lib.rs:43/46` | read+write | L0 握手:HMR/刷新清残留 generating | **`stop_flag` 读写点**(会话级停止信号): | 文件:行 | 操作 | 说明 | |---|---|---| | `agentic.rs:162` | clone | loop 入口 `session.stop_flag.clone()` 取 Arc 副本 | | `agentic.rs:178/455` | load | loop 顶部 + stream 后查 stop_flag | | `commands.rs:52/149/421/507/541/548/610/643/852/872` | store | send/regenerate/edit/stop/force/continue/create 多路置位 | | `stream_recv.rs:141/171/321` | load | stream select! 内查 stop_flag | **`pending_approvals` 读写点**(会话级审批,已带 conversation_id): | 文件:行 | 操作 | 说明 | |---|---|---| | `commands.rs:236` | remove | ai_approve 按 tool_call_id 路由(精确键) | | `commands.rs:347` | filter | ai_pending_tool_calls 按 conversation_id O(n) 过滤 | | `commands.rs:365/506/539/851/868/948/969` | clear/retain | 多路清理 | | `agentic.rs:583-585` | iter | try_continue:取任一审批的 conversation_id | | `agentic.rs:593-597` | read | 审批等待态判断 | | `audit.rs:269/593` | insert | restore_pending_approvals + process_tool_calls(均带 conversation_id) | **`agent_language` 读写点**(会话级语言): - 写:`commands.rs:53/150/422`(send/regenerate/edit 入口设) - 写:`commands.rs:873`(create 清空,F-260616-09 A 路线补漏) - 读:`agentic.rs:650`(try_continue 恢复循环读) **`iteration_used` 读写点**(会话级计数,F-260616-11 落地): - 读:`commands.rs:272/325`(ai_approve 两处续跑累计) - 写:`agentic.rs:210`(loop 每轮 `iteration_used = iteration + 1`) - 重置:`commands.rs:152`(ai_chat_send 新消息=0)/`ai_continue_loop`(达 max 续跑重计=0) ### 1.3 run_agentic_loop 单例锁与陈旧 loop 退出逻辑 **loop signature**(agentic.rs:102-115)——`session_arc: Arc>` 持整个单例 Arc,loop 内全程竞争全局锁。 **B-260615-11 陈旧 loop 退出逻辑**(agentic.rs:194-211,427-434,380-388)——**多会话 B 阶段必须改造的核心点**: ```rust // agentic.rs:197-211 每轮开始校验对话一致性 let mut session = session_arc.lock().await; if session.active_conversation_id.as_deref() != Some(conv_id.as_str()) { tracing::warn!("[ai] 对话已切换,旧 loop 退出(B-260615-11)避免污染新对话"); return; // ← 决策 e「不退出各自跑完」:此处须删除/改判 } ``` **当前语义**:loop 快照 `conv_id`(入参),每轮查单例 `active_conversation_id`,不一致即 return。 - 这是为了**单例下防污染**:用户新建/切换会话,旧 loop 继续跑会 push 到新对话。 - **决策 e 真并发下**:旧 loop 跑的是自己的 conv,messages/pending 已按 conv reload 或 per-conv,该退出**反成阻碍**——两个对话各持 conv_id,旧 loop 不应因 `active_conversation_id` 变更而退出。 同样的 push 前校验在 agentic.rs:380-388(MidStream 保文)/427-434(stream 后 push),三处一致——全部依赖单例 `active_conversation_id` 作「当前 loop 归属」判据,**B 阶段须改判据为 per-conv 的 session 索引**。 ### 1.4 commands.rs create/switch/force_send 漏清与冲突 **ai_conversation_create**(commands.rs:835-876): - 生成中强制结束旧 loop(:848-862 `generating=false` + `pending_approvals.clear()` + `stop_flag=true` + emit AiCompleted) - F-260616-09 A 路线已补:`stop_flag.store(false)`(:872)+ `agent_language=None`(:873) - **B 阶段冲突**:「新建会话强制结束旧 loop」与决策 e「不退出各自跑完」直接矛盾。B 阶段应**删除该强制结束块**,新会话直接开 per-conv state。 **ai_conversation_switch**(commands.rs:918-955): - 生成中 `readonly: true`(:935-941)只返回 messages 不改 active——**单例下不得已的妥协** - **B 阶段**:切换=前端切视图 + 后端切 active_conversation_id(应用级路由键),不触碰任一 conv 的 per-conv state。readonly 分支可删除(切走不杀 loop)。 **ai_chat_force_send**(commands.rs:494-523): - 复位全局 generating + clear pending + stop_flag=true(:503-509) - **B 阶段语义变化**:force_send 改为「针对**目标 conv** 强制复位其 per-conv generating」+「stop 该 conv 的 loop」,不影响其他 conv。 **ai_is_generating**(commands.rs:123-126): - 返回全局 `session.generating`(单 bool) - **B 阶段**:改为 `ai_is_generating(conv_id) -> bool`,前端 useAiWindow.ts:97 `resumeInDetached` 改传 conv_id。 ### 1.5 ContextManager(messages)——已是会话级语义 `ContextManager`(context.rs:163-219)内部 `Vec` + token 缓存,无全局共享状态。 - `push`/`clear`/`restore_from_messages`(context.rs:176/188/414)均是自包含操作。 - **B 形态决策 b 的关键支撑**:`messages` 当前之所以单例,只是因为 AiSession 单例;一旦 generating/stop_flag 拆 per-conv,messages 也可随之 per-conv(HashMap),**侵入面与拆 generating 同量级**。 - 决策 b 选「messages 走 conv reload」(不拆 per-conv)是为了**最小侵入**——保留 `restore_from_messages` 路径,切换时从 DB reload。**但真并发下旧 loop 还在往单例 messages push**,会污染刚 reload 的新 active conv。故**最终 B 形态建议**(见 §2.3)messages 也拆 per-conv,否则 §1.3 陈旧 loop 退出逻辑无替代判据。 ### 1.6 llm_concurrency per_conv 当前语义(state.rs:93-133) **当前 per_conv 是应用级单一 Semaphore**(:101 `per_conv: Arc>>`,**非 HashMap**),state.rs:94-97 注释自陈: > per_conv 当前是应用级单一信号量(非 per-conv map)。因 AiSession 为单例 + generating 互斥,同一时刻仅一个对话的 loop 在跑,per_conv 退化为"单对话内并发"(主循环 stream_llm + 标题生成 + 知识提炼)。**未来若支持多对话并发,需改为 HashMap。** - `global` Semaphore(state.rs:100)构造时 permits=3(state.rs:221 `LlmConcurrency::new(3, 2)`) - **决策 c**:B 阶段 global 改「并发会话数上限」语义——原本限 LLM 调用并发,改限**并发会话数**(每会话占 1 permit 入口)。 - per_conv 改 HashMap,每对话内限流(主循环 + 标题 + 提炼)。 - **F-260616-12 依赖点**(todo:86):重试循环(agentic.rs:247-248)持有 global+per_conv permit 不释放。多会话后,重试期阻塞其他对话。B 阶段须处理(§3.3)。 ### 1.7 前端现状 **stores/ai.ts**(模块级单例 reactive state,45-82): - 会话级字段:`messages`/`streaming`/`currentText`/`generatingConvId`/`activeConversationId`/`pendingApprovals`/`queue`/`agentRound` - **单 webview 共享**(注释:32-34),分离窗口是**独立 webview 各自 state**(useAiWindow.ts:32-34) - **B 阶段 d1(侧栏)**:可保持单 store,只需支持「后台 conv 的 generating 态不被切走清零」(useAiEvents.ts:142-144 已按 conversation_id 路由 generatingConvId,基础设施就绪) - **B 阶段 d2(独立窗口)**:每窗口一个 webview 一个 store,需窗口间状态同步(localStorage 快照 useAiWindow.ts:29 已有雏形) **useAiEvents.ts**(handleEvent :123-):**事件按 conversation_id 路由**已就绪(:125 `convId = event.conversation_id`),:133-140 isCurrent 判断决定是否污染当前视图。**conversation_id 全覆盖核验**见 §4。 **useAiWindow.ts**(1-191):`detachPanel`/`reattachPanel`/`resumeInDetached`/`dockDetached`/`syncToMain`/`startFollowMain` 已完整。单窗口(label='ai-detached'),detach 时快照 generatingConvId 到 localStorage(:28-31)。**d2 多窗口扩展点**:label 改为 `ai-detached-${convId}` 支持每会话独立窗口。 --- ## 2. B 形态方案(决策 b:拆 generating/stop_flag per-conv) ### 2.1 数据结构 **新增 `PerConvState`**(放 mod.rs,AiSession 内): ```rust /// 单个对话的运行态(generating/stop_flag/notify/iteration/agent_language)。 /// 多会话并发后,每个 conv 一份,HashMap 挂在 AiSession。 /// /// 不含 messages/pending_approvals —— /// - messages: 见 §2.3 决策(messages 最终也 per-conv,否则 §1.3 陈旧 loop 退出无替代判据) /// - pending_approvals: 已带 conversation_id,继续走单层 HashMap + retain 过滤(mod.rs:181-185 注释自陈) pub struct PerConvState { pub generating: bool, pub stop_flag: Arc, pub notify: Arc, pub iteration_used: usize, pub agent_language: Option, /// 该 conv 的 messages 历史(§2.3:messages per-conv 化后挪入此) pub messages: ContextManager, /// 懒创建时间戳(随 active_conv 走) pub created_at: Option, } ``` **AiSession 改造**(mod.rs:166): ```rust pub struct AiSession { // ── 应用级(保持单例) ── pub active_provider_id: Option, pub active_conversation_id: Option, // 路由键:当前展示的 conv(切走即变,不影响后台 loop) // ── 会话级(per-conv) ── pub pending_approvals: HashMap, // 已带 conversation_id,保持单层 pub per_conv: HashMap, // ── L0 握手/A 路线兼容 ── // 旧字段 generating/stop_flag/notify/agent_language/messages/iteration_used 移入 PerConvState } ``` ### 2.2 访问封装(收敛锁边界) **关键:避免 41 处 `session.lock()` 各自写 HashMap 索引**,提供访问器: ```rust impl AiSession { /// 取某 conv 的 PerConvState(不存在则惰性创建) pub fn conv(&mut self, conv_id: &str) -> &mut PerConvState { ... } /// 只读快照(供 IPC 查询,不创建) pub fn conv_read(&self, conv_id: &str) -> Option<&PerConvState> { ... } /// 兼容旧调用:取 active_conversation_id 对应 conv(替换单例字段直读) pub fn active_conv(&mut self) -> Option<&mut PerConvState> { self.active_conversation_id.as_ref().and_then(|id| self.per_conv.get_mut(id)) } } ``` **调用点迁移**:41 处 `session.lock().await.xxx` → `session.lock().await.conv(&conv_id)?.xxx`,**锁粒度不变(仍是单一 AiSession Mutex)**,只是索引多一层。这是**最小侵入**的关键——不拆 N 把锁(否则锁顺序/死锁风险骤增),per-conv 仅是 HashMap 索引隔离。 ### 2.3 messages 是否 per-conv(决策 b 的关键细化) **决策 b 原文**:「仅拆 generating/stop_flag per-conv,messages/pending 已可按 conv reload」。 **核验结论**:**messages 必须同 per-conv**,否则无法落地决策 e「不退出各自跑完」: - 单例 messages 下,旧 loop(已切走的 conv A)继续跑会 push 到**当前 active conv B 的 messages**(单例一份)——这正是 §1.3 B-260615-11 退出逻辑防的污染。 - 若 messages 保持单例,旧 loop 退出逻辑**必须保留**(决策 e 落空)。 - 若 messages per-conv,旧 loop `session.conv(&conv_a).messages.push(...)` 写自己的 conv,**不污染 B**,退出逻辑可删(决策 e 成立)。 **故本设计修正决策 b 为**:「拆 generating/stop_flag/notify/iteration/agent_language/**messages** per-conv,仅 pending_approvals 保持单层 HashMap(已带 conversation_id)」。**侵入面增量极小**(messages 本就是 ContextManager 自包含结构,挪 HashMap 即可),但解锁了决策 e。**此偏离决策 b 原文的细化,须用户确认**(列入风险 R-1)。 ### 2.4 锁粒度选型(单一 Mutex vs per-conv 细锁) **推荐:维持单一 `Arc>`**: - per-conv 细锁(HashMap>)理论并发更好,但: - 死锁风险:loop 内多次取锁 + 审批 IPC 取锁,锁顺序难保证 - pending_approvals 是跨 conv 单层 HashMap,若它单锁而 per_conv 各自锁,两个锁域交叉 - 现有 41 处 lock() 全要改取锁语义,侵入大 - 单一 Mutex 下,per-conv 仅是数据隔离(写自己的 conv 不影响他人),**锁竞争点仍在**(并发 N 个 loop 抢一把锁),但: - loop 内锁持有时间极短(push 一条消息 / 查 stop_flag 都是 O(1)),真实竞争窗口小 - stream_llm 期间**不持锁**(agentic.rs:225-230 build messages 后释放锁,stream 内无锁) - 改动量最小,风险最低 **结论**:数据 per-conv,锁单例。后续若 profiling 显示锁竞争,再细化到 per-conv Mutex(渐进路径)。 --- ## 3. 并发限流(决策 c:复用 llm_concurrency.global 改会话级) ### 3.1 global Semaphore 语义重定义 **当前**(state.rs:100/221):permits=3,限**全局 LLM 调用并发**(主循环 + 标题 + 提炼 + 所有对话共用)。 **B 阶段新语义**:permits=**并发会话数上限**(默认 3),每会话 loop 入口 acquire 1 permit,持有整个 loop 生命周期(含工具执行/审批等待)。 - 含义:同一时刻最多 3 个对话并发跑 loop,第 4 个排队。 - 收益:token 暴增护栏(决策 c 原意)。 - 实现:`run_agentic_loop` 入口 `let _conv_permit = llm_concurrency.acquire_global().await;`,permit 绑 guard Drop 释放。 ### 3.2 per_conv Semaphore 改 HashMap **当前**(state.rs:101):单一 Semaphore permits=2,限单对话内并发(主循环+标题+提炼)。 **B 阶段**: ```rust per_conv: Arc>>>, ``` - 每对话内限流不变(permits=2,主循环 + 标题 + 提炼),但按 conv_id 各自一份。 - `acquire_per_conv(conv_id)`:lock HashMap → 若无则建(permits=2)→ clone Arc → 释放 lock → acquire_owned。 - **conv 退出清理**:loop 结束 + 无 pending 审批时,remove 该 conv 的 Semaphore 条目(防 HashMap 无限增长)。 ### 3.3 F-260616-12 retry 持 permit 依赖(关键风险点) **现状**(agentic.rs:247-248): ```rust let _global_permit = llm_concurrency.acquire_global().await; let _per_conv_permit = llm_concurrency.acquire_per_conv().await; // 重试循环(:259-353)期间持有两 permit 不释放 // stream 后(:414-415)才 drop ``` 注释(agentic.rs:246):「重试期间持有 permit 不释放(防新请求挤占)」。 **多会话后的问题**: - global permit **新语义是会话级**(§3.1,每会话占 1 整 loop)。重试期持 global=**整个会话占着会话槽**,但重试只是该会话内部行为,**不应阻塞其他会话入槽**。 - per_conv permit 是该 conv 内部限流,重试期持有合理(防自己挤占标题/提炼)。 **B 阶段处理方向**(对齐 todo:86 「倾向重试不持 permit 或仅持 per_conv」): - **global permit 移出重试持有**:重试循环内**释放 global**(回到会话级占槽语义——入 loop 时 acquire 1 global 整 loop 持有,重试不影响他对话)。**注意**:若 global 新语义是「会话数上限」(§3.1),重试不释放 global 也无碍(本就是会话级占槽),问题降级。**真正要改的是 per_conv 重试持有**(见下)。 - **per_conv permit 重试期释放**:重试不持 per_conv,退避 sleep + 重试请求每次重新 acquire per_conv(让该 conv 的标题/提炼有机会跑)。但当前架构无标题/提炼并发跑(都在 loop 退出后 spawn),故 per_conv 退化为单 permit,持有与否无差异。 - **结论**:F-260616-12 在 B 阶段的实际影响**仅限 global 语义切换时核验**——新 global=会话级后,重试持 global=占会话槽(语义自洽,他对话第 4 个排队正常),**非 bug,是预期行为**。per_conv 重试持有无实际影响(无并发消费方)。**F-260616-12 可降级为「B 阶段验证 global 新语义后关闭」**,无需独立改动。列入风险 R-2。 --- ## 4. loop 内校验改造(agentic.rs:177-211 改造点) ### 4.1 B-260615-11 陈旧 loop 退出逻辑删除 **改造前**(agentic.rs:197-211): ```rust let mut session = session_arc.lock().await; if session.active_conversation_id.as_deref() != Some(conv_id.as_str()) { return; // ← 删除 } session.iteration_used = iteration + 1; ``` **改造后**(决策 e 真并发): ```rust { let mut session = session_arc.lock().await; // B 阶段:不再校验 active_conversation_id(切换不杀 loop)。 // per-conv state 按 conv_id 索引,旧 loop 写自己的 conv 不污染他人。 // 校验改为:conv 是否仍存在(被删则退出) if !session.per_conv.contains_key(&conv_id) { tracing::warn!("[ai] conv {} 已删除,旧 loop 退出", conv_id); return; } session.conv(&conv_id).iteration_used = iteration + 1; } ``` 同样改造 agentic.rs:380-388(MidStream 保文后 push)/427-434(stream 后 push):三处 push 前校验**全部改为 conv 存在性**而非 active 一致性。 ### 4.2 stop_flag/notify/messages 取用改 per-conv 索引 **改造前**(agentic.rs:160-163): ```rust let (stop_flag, notify) = { let session = session_arc.lock().await; (session.stop_flag.clone(), session.notify.clone()) }; ``` **改造后**: ```rust let (stop_flag, notify) = { let session = session_arc.lock().await; let conv = session.per_conv.get(&conv_id).expect("loop 启动前 conv 已建"); (conv.stop_flag.clone(), conv.notify.clone()) }; ``` **build_for_request 改 per-conv messages**(agentic.rs:224-230): ```rust let messages = { let session = session_arc.lock().await; let conv = session.per_conv.get(&conv_id).expect("..."); let (history_msgs, _trimmed) = conv.messages.build_for_request(sys_tokens); // ... }; ``` ### 4.3 GeneratingGuard 改 per-conv 复位 **改造前**(agentic.rs:59-94):guard 持 `Arc>`,Drop 时 `session.lock().await.generating = false`(全局)。 **改造后**:guard 持 `conv_id: String`,`reset()` / Drop 时 `session.conv(&conv_id).generating = false`(只复位该 conv)。guard struct 加 `conv_id` 字段。 ### 4.4 try_continue_agent_loop 改 per-conv 续跑 **改造前**(agentic.rs:578-650):读全局 generating + pending 决定续跑。 **改造后**: ```rust pub(crate) async fn try_continue_agent_loop(app, state, conv_id: &str, start_iteration: usize) { let (is_generating, has_pending) = { let session = state.ai_session.lock().await; let conv = match session.per_conv.get(conv_id) { Some(c) => c, None => return, // conv 已删 }; (conv.generating, session.pending_approvals.values().any(|a| a.conversation_id.as_deref() == Some(conv_id))) }; // ... 续跑 spawn run_agentic_loop 传 conv_id } ``` `ai_approve`(commands.rs:228-)调用处传 `approval.conversation_id`(:281 已有)。 --- ## 5. 切换不退出旧 loop 各自跑完(决策 e 改造点) ### 5.1 ai_conversation_switch 移除 readonly 分支 **改造前**(commands.rs:935-941):生成中 readonly 切换,不改 active。 **改造后**: ```rust let mut session = state.ai_session.lock().await; session.active_conversation_id = Some(conversation_id.clone()); // 直接切,不动 per-conv // 旧 conv 的 per-conv state 保留(后台 loop 继续跑),新 conv 从 DB reload messages 到其 per-conv let conv_state = session.per_conv.entry(conversation_id.clone()) .or_insert_with(|| PerConvState::from_messages(messages)); // 惰性建/已存在则保留 session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id)); // 删 readonly 分支 ``` ### 5.2 ai_conversation_create 移除强制结束旧 loop **改造前**(commands.rs:848-864):生成中 `generating=false` + `stop_flag=true` 杀旧 loop。 **改造后**: ```rust let mut session = state.ai_session.lock().await; // B 阶段:新建会话不杀旧 loop(决策 e)。新会话建独立 per-conv state。 let id = new_id(); session.active_conversation_id = Some(id.clone()); session.per_conv.insert(id.clone(), PerConvState::new()); // 全新 state session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&id)); // 删 if session.generating { 强制结束 } 块 ``` ### 5.3 ai_chat_send 互斥改 per-conv **改造前**(commands.rs:144-152):`if session.generating {Err}` 全局互斥。 **改造后**: ```rust let mut session = state.ai_session.lock().await; let conv_state = session.conv(&conv_id); // 惰性建 if conv_state.generating { return Err("该对话正在生成中".to_string()); // 仅该 conv 互斥,他对话不受影响 } conv_state.generating = true; conv_state.stop_flag.store(false, Ordering::SeqCst); conv_state.agent_language = language.clone(); conv_state.iteration_used = 0; conv_state.messages.push(ChatMessage::user(&user_content)); // active_conversation_id 仍设(路由键),但不影响其他 conv 的 loop ``` ### 5.4 ai_chat_force_send / ai_chat_stop / ai_continue_loop / ai_stop_loop 改 per-conv 所有这些命令**加 conv_id 参数**(前端传 activeConversationId),操作仅针对该 conv 的 per-conv state: - `ai_is_generating(conv_id)` → `conv_read(conv_id).generating` - `ai_chat_stop(conv_id)` → 置该 conv 的 stop_flag + notify - `ai_chat_force_send(conv_id, ...)` → 复位该 conv 的 generating + 重发 - `ai_continue_loop(conv_id)` / `ai_stop_loop(conv_id)` → 操作该 conv **IPC 签名变更**:需更新 commands.rs 的 `#[tauri::command]` 签名 + lib.rs:120 invoke_handler + 前端 api/ai.ts wrapper。 --- ## 6. 事件路由 conversation_id 全覆盖核验 > 后端 emit 点全部 grep 核验。前端 useAiEvents.ts:125 已按 conversation_id 路由。 **AiChatEvent 变体**(mod.rs:88-141)conversation_id 字段核验: | 变体 | 行 | conversation_id | 核验 | |---|---|---|---| | AiTextDelta | :90 | `Some(conv_id)` | ✅ stream_recv 传 | | AiToolCallStarted | :93 | `Some` | ✅ | | AiToolCallCompleted | :96 | `Some` | ✅ | | AiApprovalRequired | :101 | `Some` | ✅ audit.rs:604 process_tool_calls 传 | | AiApprovalResult | :103 | `Some` | ✅ commands.rs:260/312 | | AiCompleted | :115 | `Some` | ✅ agentic.rs 多处 + commands.rs:517/544 | | AiError | :124 | `Some` | ✅ agentic.rs:143 | | AiAgentRound | :127 | `Some` | ✅ agentic.rs:217 | | AiHeartbeat | :129 | `Some` | ✅ stream_recv | | AiMaxRoundsReached | :135 | `Some` | ✅ agentic.rs:519 | | AiStreamRetry | :140 | `Some` | ✅ agentic.rs:346 | **结论**:所有 emit 点**已带 conversation_id**,前端 useAiEvents.ts:125 `convId = event.conversation_id` + :133 `isCurrent = !convId || convId === state.activeConversationId` 已就绪。**事件路由层零改动**,这是 B 阶段 d1 落地的前置保障。 **唯一新增 emit 点核验**:loop 内 push 失败/per-conv 复位的新 emit 须带 conv_id(§4 改造时逐一核对)。 --- ## 7. 多窗口 UI(d1 侧栏 + d2 独立窗口) ### 7.1 d1 侧栏切换 + 后台并行(先做) **现状基础**: - useAiConversations.ts:56「允许生成中切换:后台继续生成,事件按 conversation_id 路由不污染当前视图」已就绪 - useAiEvents.ts:142-144 `generatingConvId` 跟踪当前生成 conv(切走不清零) - 侧栏会话列表 useAiConversations.ts:17 loadConversations 已就绪 **d1 改动点**: 1. **侧栏会话项显示生成态**:读 `state.conversations[].id` 与 `state.generatingConvId` 比对,匹配则显流式指示器(改动:`Sidebar.vue` 会话项 + 新 computed `generatingConvSet`) 2. **多 conv 后台并行态**:state 加 `generatingConvs: Set`(替代单值 generatingConvId),useAiEvents.ts:142-144 改 push 到 Set,Completed/Error 时 remove 3. **切走不清零**:useAiConversations.ts:38-44 newConversation 清 queue/generatingConvId 改为只清当前 conv 的(不杀他 conv) 4. **AiCompleted 触发 drainQueue**:useAiSend.ts:227 当前队列是单队列,B 阶段可保持(用户在 A 排队,A 完成续发),或改 per-conv 队列(后续优化) **d1 不需要**:多窗口、状态同步、localStorage 跨窗口。**d1 是纯前端 + 后端 §2-§5 改造的最小 UI 呈现层**。 ### 7.2 d2 独立 Tauri 窗口(后做) **现状基础**(useAiWindow.ts): - `WebviewWindow('ai-detached', ...)`(:42)单窗口 label - detach 时快照 generatingConvId → localStorage `df-ai-gen`(:28-31) - resumeInDetached(:87-119)核验 `ai_is_generating` 后恢复生成态 - 独立 webview 独立 store(useAiWindow.ts:32-34 注释) **d2 改动点**: 1. **label 改 per-conv**:`ai-detached-${convId}`,支持每会话独立窗口(`getByLabel` 查重) 2. **detachPanel 加 conv_id 参数**:从指定 conv detach(而非当前 active) 3. **窗口间状态同步**:多窗口各自 webview 各自 store,需: - 后端事件广播:Tauri `emit` 默认所有 webview 收(useAiEvents 在每窗口各自 listen,按 conversation_id 过滤)——**天然支持**,无需改 - 显式状态同步(如某窗口改 provider):用 Tauri `emit` 自定义事件 + 各窗口 listen(useAiWindow.ts 已有 _unlistenMove/Resize 模式可仿) 4. **localStorage 快照**:从单 `df-ai-gen` 改 `df-ai-gen-${convId}` 多 key 5. **窗口管理 UI**:侧栏每会话加「在新窗口打开」按钮(右键菜单或图标) **d2 风险**:多窗口 + 多 webview 内存占用;窗口生命周期管理(关窗是否停 loop,决策 e 下不停)。 --- ## 8. 实施步骤(分批,每批独立可验证) > 每批标注**主改文件**+**锁/回归面**。建议批间 cargo + vue-tsc 自验 + 人工验收单会话回归。 ### 批 1:PerConvState 数据结构 + 访问器(无行为变更,纯重构) - **主改**:`mod.rs`(新增 PerConvState struct + AiSession 字段迁移 + conv()/conv_read() 访问器) - **锁**:无运行时行为变化(旧字段读写在批 2 迁移),仅编译期 - **验证**:cargo build 通过 + 单测(访问器惰性建/已存在/conv 删除) - **回归面**:全 AI IPC 编译(41 处 lock 暂不动,通过兼容层 `active_conv()` 转发到 per_conv[active_conversation_id]) ### 批 2:41 处 lock 调用点迁移到 conv() 索引 - **主改**:`commands.rs`(41 处)、`agentic.rs`(8 处)、`prompt.rs`/`audit.rs`/`knowledge_inject.rs`(各 1 处) - **锁**:仍单 Mutex,索引多一层 - **验证**:cargo build + **单会话全功能回归**(send/regenerate/edit/approve/stop/continue/switch/create/delete)行为零变化 - **回归面**:全部 AI 功能(高风险批,须逐命令人工验收) ### 批 3:run_agentic_loop per-conv 改造(§4) - **主改**:`agentic.rs`(GeneratingGuard 加 conv_id + stop_flag/notify/messages 取用改 per-conv + §4.1 陈旧 loop 退出改 conv 存在性 + §4.4 try_continue 改 per-conv) - **锁**:loop 内不改锁,改取用索引 - **验证**:单会话回归 + **双会话并发验收**(开 conv A 跑 → 切 conv B 发 → A 后台跑完不污染 B) - **回归面**:loop 全路径(send/regenerate/approve/continue/stop/max) - **依赖**:批 2 完成 ### 批 4:commands.rs 切换/新建/停止改 per-conv(§5) - **主改**:`commands.rs`(ai_conversation_create 删强制结束块 / ai_conversation_switch 删 readonly / ai_chat_send 互斥改 per-conv / ai_chat_stop/force/continue/stop_loop 加 conv_id 参数) - **IPC 签名变更**:lib.rs invoke_handler + 前端 api/ai.ts wrapper + useAiSend.ts/useAiConversations.ts 调用处 - **验证**:双会话并发(切走不杀旧 loop,各自跑完)+ 单会话回归 - **回归面**:全部 IPC 签名(前端多处调用) - **依赖**:批 3 完成 ### 批 5:llm_concurrency 改造 + F-260616-12 处理(§3) - **主改**:`state.rs`(LlmConcurrency global 语义=会话级 + per_conv 改 HashMap)、`agentic.rs`(loop 入口 acquire global 整 loop 持有 + 重试期 global 释放语义核验) - **锁**:Semaphore 改造 - **验证**:3 会话并发(第 4 排队)+ 重试期不阻塞他对话(F-260616-12 验证) - **回归面**:并发限流配置(Settings 热改路径 set_global/set_per_conv 适配 HashMap) - **依赖**:批 4 完成 ### 批 6:d1 前端侧栏 + 后台并行(§7.1) - **主改**:`stores/ai.ts`(generatingConvs: Set)、`useAiEvents.ts`(generatingConvId → generatingConvs)、`Sidebar.vue`(会话项生成态指示器)、`useAiConversations.ts`(切走不清零) - **验证**:双会话并发 UI(侧栏见两 conv 流式指示器,切走后台继续) - **回归面**:侧栏渲染 + 事件路由 - **依赖**:批 4 完成(后端 per-conv 就绪) ### 批 7:d2 独立 Tauri 窗口(§7.2) - **主改**:`useAiWindow.ts`(label per-conv + detachPanel 加 conv_id + localStorage 多 key + 窗口间同步)、`Sidebar.vue`(新窗口打开按钮) - **验证**:每会话独立窗口 + 多窗口并发 + 窗口间状态同步 - **回归面**:窗口生命周期 + 内存 - **依赖**:批 6 完成 ### 批 8:启动恢复 + L0 握手适配 - **主改**:`audit.rs`(restore_pending_approvals 按 conversation_id 分组重建到对应 per_conv)、`lib.rs`(L0 握手清所有 per_conv 的 generating,非全局单值) - **验证**:重启恢复多 conv pending 审批 + HMR 清多 conv 残留 - **回归面**:启动恢复链路 - **依赖**:批 2 完成(可与批 3-7 并行) --- ## 9. 风险清单 ### R-1:决策 b 细化偏离(messages 必须同 per-conv)— **须用户确认** - **原决策 b**:「仅拆 generating/stop_flag per-conv,messages/pending 已可按 conv reload」 - **核验结论**:**messages 必须同 per-conv**,否则 §1.3 B-260615-11 陈旧 loop 退出逻辑无替代判据(单例 messages 下旧 loop 必污染新 active conv),决策 e「不退出各自跑完」落空。 - **侵入面增量**:极小(messages 本就是 ContextManager 自包含,挪 HashMap),但偏离原文须确认。 - **缓解**:pending_approvals 保持单层 HashMap(已带 conversation_id,符合决策 b 原意),仅 messages per-conv 化。 ### R-2:锁粒度——单 Mutex 多 conv 抢锁 - **现状**:41 处 `session.lock()` 共一把 Mutex。 - **B 阶段**:per-conv 仅数据隔离(HashMap 索引),锁仍单例。N 个并发 loop 抢一把锁。 - **评估**:loop 内锁持有时间极短(push/查 flag 均 O(1)),stream 期间不持锁,真实竞争窗口小。**风险可控**。 - **缓解**:若 profiling 显示竞争,再细化 per-conv Mutex(渐进,不阻塞 B 落地)。 ### R-3:事件路由——已就绪但新增 emit 须核对 - **现状**:11 个 AiChatEvent 变体全部已带 conversation_id(useAiEvents.ts:125 已路由)。 - **B 阶段**:零改动,但 §4-§5 改造新增 emit 点须逐一核对带 conv_id。 - **缓解**:批 3/4 改造时 grep emit 点 checklist。 ### R-4:状态同步——前端 generatingConvId 单值 → Set - **现状**:state.generatingConvId 单值(stores/ai.ts:51)。 - **B 阶段 d1**:改 Set(多 conv 并行生成)。useAiEvents.ts:142-144 写入逻辑 + 各清零点(Completed/Error/:136/:273/:317)全改。 - **风险**:漏改清零点致生成态残留。 - **缓解**:批 6 集中改 + 双会话并发 UI 验收。 ### R-5:回归面——全 AI 功能(高风险批在批 2/批 4) - **批 2**(41 处 lock 迁移):全 AI IPC 编译 + 单会话全功能回归。 - **批 4**(IPC 签名变更 conv_id 参数):前端多处调用 + 后端全部命令。 - **缓解**:批间 cargo + vue-tsc + 人工验收单会话回归(逐命令:send/regenerate/edit/approve/stop/continue/switch/create/delete/rename/archive)。批 2 是最高风险批,建议单独 commit + 充分验收。 ### R-6:F-260616-12 retry 持 permit——global 语义切换后核验 - **现状**:重试期持 global+per_conv permit(agentic.rs:247-248)。 - **B 阶段**:global 改「会话级」语义(§3.1)后,重试持 global=占会话槽(语义自洽,第 4 会话排队正常),**非 bug**。per_conv 重试持有无实际影响(无并发消费方)。 - **结论**:F-260616-12 **降级为批 5 验证项**(global 新语义下行为符合预期即关闭),无需独立改动。**若用户认为重试期占会话槽不合理**,则在批 5 重试循环内释放 global(每次重试重新 acquire),但会引入「重试期会话槽空转被他对话抢占」新问题——不推荐。 ### R-7:启动恢复 + L0 握手多 conv 适配 - **restore_pending_approvals**(audit.rs:254):当前重建到全局 pending_approvals(单层 HashMap),B 阶段保持(已带 conversation_id),但须确保各 conv 的 per_conv state 在启动时建(惰性 or 全量)。 - **L0 握手**(lib.rs:42-63):当前清全局 generating + pending。B 阶段改清所有 per_conv 的 generating(防 HMR 后多 conv 残留)。 - **缓解**:批 8 集中处理。 ### R-8:iteration_used 跨审批续跑在多会话下的正确性 - **现状**(F-260616-11):iteration_used 累计,ai_approve 读 session.iteration_used 续跑。 - **B 阶段**:iteration_used 挪入 PerConvState(§2.1),ai_approve 读 `conv(approval.conversation_id).iteration_used`。 - **风险**:ai_approve commands.rs:272/325 两处读须改 per-conv 索引(批 2 一并迁移)。 - **缓解**:批 2 checklist 含此两处。 ### R-9:PendingApproval.conversation_id 一致性 - **现状**:process_tool_calls(audit.rs:598)写 `Some(conv_id.to_string())`,restore_pending_approvals(audit.rs:276)从审计表读 `rec.conversation_id`。 - **B 阶段**:保持单层 pending_approvals + 按 conversation_id 过滤(ai_pending_tool_calls commands.rs:347 已 O(n) 过滤)。 - **风险**:若某审批的 conversation_id 为 None(历史数据/边界),retain/filter 漏判。 - **缓解**:批 2 核验所有 insert 点均带 Some(conv_id);None 视为「无主审批」归类到 active conv 或单独清理。 --- ## 10. 与已决架构债的关系 - **memory `aichat-arch-extensibility`**:「AiSession 单例未动」是登记的架构债,本设计正式清偿。 - **mod.rs:214 注释**:「当前 AiSession 是全局单例(F-09 B 多会话架构落地时改 per-conv)」——预留债标记,本设计落地。 - **state.rs:94-97 注释**:per_conv「未来若支持多对话并发,需改为 HashMap」——本设计 §3.2 落地。 - **不冲突**:本设计与 F-260616-11(iteration 累计,已 per-conv 化预留)、F-260616-07(流式重试,F-260616-12 依赖项 §3.3 处理)、generating 状态机加固(RAII guard,§4.3 改 per-conv)均兼容。 --- ## 11. 验收清单(每批完成后) - [ ] 批 1:cargo build + 访问器单测 - [ ] 批 2:cargo + vue-tsc 0err + 单会话全功能回归(send/regenerate/edit/approve/stop/continue/switch/create/delete/rename/archive 逐命令) - [ ] 批 3:双会话并发(开 A 跑 → 切 B 发 → A 后台跑完不污染 B)+ 单会话回归 - [ ] 批 4:双会话并发(切走不杀旧 loop,各自跑完)+ 全 IPC 签名前端调用核对 - [ ] 批 5:3 会话并发(第 4 排队)+ 重试期不阻塞他对话(F-260616-12) - [ ] 批 6:双会话并发 UI(侧栏两 conv 流式指示器,切走后台继续) - [ ] 批 7:每会话独立窗口 + 多窗口并发 + 窗口间状态同步 - [ ] 批 8:重启恢复多 conv pending 审批 + HMR 清多 conv 残留 --- **设计完。本文件为设计稿,不含代码改动。实施时按批 1→8 顺序,每批独立可验证。**