Files
DevFlow/docs/02-架构设计/F-09-多会话并发架构设计-2026-06-19.md
绝尘 d817386418 新增: F-09多会话并发架构B阶段设计文档 + UX-15审查闭环
- F-09 设计文档(docs/02-架构设计/F-09-多会话并发架构设计-2026-06-19.md)
  现状核验+设计选项+实施路径(阶段2批1-8)+风险R1-9+验收清单
- 核验: A路线补漏已落地(阶段1跳过), AiSession 12字段
- 待决策: b-1 messages per-conv(技术必然,偏离决策b原文待拍板) + c-1 global=3+Settings + e-1 save原路径(主代定)
- CR-260619-02 UX-15第一批 审查回填 PASS
2026-06-19 01:15:36 +08:00

32 KiB

F-260616-09 AiSession 多会话并发架构 — B 阶段设计(草案)

状态:设计草案(2026-06-19) | 范围:只读分析 + 设计文档,不改源码 关联:F-09B-多会话并发设计-2026-06-16.md(旧版,行号已过期,本文为 06-19 更新核验版) 决策基线(2026-06-16 已决·待归档):

  • a:A 隔离修复已做(06-19 核验:A 路线补漏已落地) + B 立项 🔥 高优先尽快实施
  • b:形态 — 仅拆 generating/stop_flag per-conv,messages/pending 按 conv reload(⚠️ 06-19 核验修正:messages 必须同 per-conv,见 §2.1.1)
  • c:并发上限 — 复用 llm_concurrency.global 改「并发会话数」语义
  • d:UI — d1 单面板侧栏切换 + d2 独立 Tauri 窗口都做(已决先 d1 后 d2)
  • e:旧 loop 切换处理 — 不退出各自跑完(真并发,⚠️ 依赖 b 修正)

审查防污染铁律:本文 file:line 均为 2026-06-19 独立 grep/read 核验源码当前形态,不沿用 06-16 旧文档结论


0. 与 06-16 旧文档的差异(为什么重写)

06-16 旧文档结构完整但行号已全部过期(代码改动较多)。06-19 重新核验关键差异:

项目 06-16 文档说法 06-19 核验当前形态
AiSession 字段 mod.rs:166-216(10 字段) mod.rs:282-350(12 字段,新增 notifymodel_overridesession_trust)
单例锚点 state.rs:165 state.rs:227(pub ai_session: Arc<Mutex<AiSession>>)
init state.rs:216 state.rs:278 AiSession::new()
ai_chat_send 互斥 commands.rs:144-152 commands.rs:78-88(签名已改,conv_id 是参数)
ai_conversation_create 漏清 旧文标"未补" 已补 stop_flag(line 1404)+ agent_language(line 1405)+ model_override(line 1407)+ session_trust(line 1409)(F-260616-09 A 路线已落地)
前端 newConversation 漏清 旧文标"未补" 已补 queue/generatingConvId/agentRound/searchQuery(useAiConversations.ts:80-83)
B-260615-11 退出点 agentic.rs:197-211 等 agentic.rs:486-500(每轮)/ 786-793(MidStream)/ 837-844(push 前)
notify 字段 旧文未独立列出 mod.rs:309-319 独立字段(B-260615-14 即时停止唤醒,stream_recv select! 监听)
iteration_used 语义 "累计计数" mod.rs:320-331 双语义(审批续跑累计 / 达 max 续跑重计 / 新消息 0),已预留"per-conv"注释
前端 events 路由 useAiEvents.ts:125-140 useAiEvents.ts:125-144(同款,isCurrent 路由就绪)

核心结论:06-16 设计骨架(b/c/d/e 决策)依然成立,本文更新行号 + 补 06-16 后的演进(notify/model_override/session_trust 字段、SW-260618-02 finalize_pending_placeholders、A 路线补漏已落),并明确 ⚠️ 待裁决点。


1. 现状梳理(独立核验)

1.1 单例结构与字段

单例锚点:

// src/state.rs:227
pub ai_session: Arc<Mutex<AiSession>>,
// src/state.rs:278(init)
ai_session: Arc::new(Mutex::new(AiSession::new())),

整个应用一份 AiSession,所有 IPC 命令竞争同一把 tokio::Mutex

AiSession 字段(src/commands/ai/mod.rs:282-350,共 12 字段):

字段 类型 多会话语义 B 阶段处理
messages 284 ContextManager 会话级(每对话一份历史) ⚠️ 必须拆 per-conv(见 §2.1.1)
active_provider_id 286 Option<String> 应用级(provider 全局共享) 保持单例
active_conversation_id 288 Option<String> 应用级路由键(当前展示的 conv) 保持单例(切走即变,不影响后台 loop)
active_conv_created_at 290 Option<String> 会话级(懒创建时间戳) 入 PerConvState
pending_approvals 302 HashMap<tool_call_id, PendingApproval> 已带 conversation_id(mod.rs:412) 保持单层 + retain 过滤
generating 304 bool 会话级(单例=全局互斥) 核心拆点 入 PerConvState
agent_language 306 Option<String> 会话级 入 PerConvState
stop_flag 308 Arc<AtomicBool> 会话级 核心拆点 入 PerConvState
notify 319 Arc<tokio::sync::Notify> 会话级(B-260615-14 即时唤醒) 入 PerConvState(随 stop_flag 配对)
iteration_used 331 usize 会话级(mod.rs:320-331 注释已标"F-09 B 改 per-conv") 入 PerConvState
model_override 344 Option<String> 会话级(F-01 阶段6 主对话指定模型) 入 PerConvState
session_trust 349 HashSet<TrustKey> 会话级(AE-2025-04,不跨对话) 入 PerConvState

关键观察:mod.rs:320-331 注释自身已写「单例一份:当前 AiSession 是全局单例(F-09 B 多会话架构落地时改 per-conv)」——本设计是早已预留的债。

1.2 切换软隔离(readonly)实现

ai_conversation_switch(src/commands/ai/commands.rs:1454-1522):

  • 生成中(session.generating 为 true,commands.rs:1474)→ 直接 return Ok(...{ readonly: true })(commands.rs:1475-1481):只返回目标对话的 messages 给前端展示,不改 session 状态
  • 非生成中(commands.rs:1482-):正常切 active_conversation_id + messages.restore_from_messages + retain pending_approvals + 清 model_override/session_trust。

这是单例下的不得已妥协:active_conversation_id 是 loop 写消息的路由键(B-260615-11 校验点),切走即会让旧 loop 退出;故生成中只能 readonly,无法真切换。

B 阶段改造:删除 readonly 分支,切走直接改 active_conversation_id(应用级路由键),旧 conv 的 per-conv state 保留,后台 loop 继续跑(决策 e)。

1.3 B-260615-11 旧 loop 退出逻辑(单例下防污染)

三处 push 前一致性校验(全部读单例 active_conversation_id):

文件:行 触发点 当前行为
agentic.rs:486-500 每轮 loop 开始 if session.active_conversation_id != conv_id { return } + iteration_used = iteration + 1
agentic.rs:786-793 MidStream 保文 push 前 同款校验 + return
agentic.rs:837-844 stream 后正常 push 前 同款校验 + return

当前语义:loop 持快照 conv_id(入参),每轮查单例 active_conversation_id,不一致即 return 退出 loop。

决策 e 真并发下:旧 loop 跑的是自己的 conv,若 messages/stop_flag 已 per-conv,旧 loop 写自己的 conv 不污染他人,该退出校验反成阻碍——B 阶段须改为「conv 是否仍存在」判据(被删则退出)。

1.4 llm_concurrency per_conv 当前语义(已退化为单对话内并发)

state.rs:84-193 LlmConcurrency 结构核验:

// state.rs:106-113
pub struct LlmConcurrency {
    global: Arc<Mutex<Arc<Semaphore>>>,        // state.rs:108
    per_conv: Arc<Mutex<Arc<Semaphore>>>,      // state.rs:109 — 应用级单信号量(非 HashMap)
    per_provider: Arc<Mutex<HashMap<String, Arc<Semaphore>>>>, // state.rs:112(F-260614-04c)
}

state.rs:94-98 注释自陈(关键证据):

per_conv 当前是应用级单一信号量(非 per-conv map)。因 AiSession 为单例 + generating 互斥,同一时刻仅一个对话的 loop 在跑,per_conv 退化为"单对话内并发"(主循环 stream_llm + 标题生成 + 知识提炼)。未来若支持多对话并发,需改为 HashMap<conv_id, Semaphore>

初始化:state.rs:283 LlmConcurrency::new(3, 2) —— global=3 / per_conv=2。

acquire 路径(state.rs:125-134):acquire_global()acquire_per_conv() 均是 lock 内层 Arc + clone + acquire_owned。

loop 内 permit 持有(agentic.rs:659-660):

let _global_permit = llm_concurrency.acquire_global().await;
let _per_conv_permit = llm_concurrency.acquire_per_conv().await;

重试期间(agentic.rs:207-303 stream_one_provider 内)持有两 permit 不释放,stream 后(agentic.rs:824-825)显式 drop。F-260616-12 依赖项:重试期持 permit 阻塞其他会话入槽。

决策 c 改造方向:global 语义从「LLM 调用并发上限」改为「并发会话数上限」(每 loop 入口 acquire 1 整 loop 持有);per_conv 改 HashMap<conv_id, Semaphore>

1.5 残留 bug 核验(A 阶段补漏状态)

后端 ai_conversation_create 漏清字段(src/commands/ai/commands.rs:1347-1412):

  • 06-16 文档标"漏清 agent_language/stop_flag" —— 06-19 核验已补:
    • line 1404 session.stop_flag.store(false, Ordering::SeqCst)
    • line 1405 session.agent_language = None
    • line 1407 session.model_override = None
    • line 1409 session.session_trust.clear()
  • A 路线补漏注释 line 1401-1409 明确:「F-260616-09(A 路线):补漏清字段维持单例软隔离」
  • 后端 A 路线补漏已完成,B 阶段此处仅剩「删生成中强制结束旧 loop 块」(commands.rs:1360-1379)与决策 e 的冲突(见 §5.2)

前端 useAiConversations.ts:newConversation 漏清字段(src/composables/ai/useAiConversations.ts:69-86):

  • 06-16 文档标"漏清 queue/generatingConvId/agentRound/searchQuery" —— 06-19 核验已补:
    • line 80 state.queue = []
    • line 81 state.generatingConvId = null
    • line 82 state.agentRound = 0
    • line 83 state.searchQuery = ''
  • 注释 line 77-79 明确:「F-260616-09(A 路线):补漏清字段维持单例软隔离」
  • 前端 A 路线补漏已完成

结论:A 路线补漏全部落地,阶段 1(A 隔离残留 bug 修复)实际已无残留 bug,B 阶段可直接进入阶段 2(多会话并发核心改造)。

1.6 前端事件路由(已就绪)

useAiEvents.ts:handleEvent(src/composables/ai/useAiEvents.ts:124-366):

// line 125
const convId = event.conversation_id
// line 133
const isCurrent = !convId || convId === state.activeConversationId
// line 134-140
if (!isCurrent) {
  if (event.type === 'AiCompleted' || event.type === 'AiError') {
    state.generatingConvId = null
    void loadConversations()
  }
  return  // 非当前会话事件不污染当前视图
}

事件按 conversation_id 路由就绪:非当前会话事件只刷侧栏不污染视图。这是 B 阶段 d1(侧栏切换 + 后台并行)的前置保障——零改动可用

AiChatEvent 11 变体 conversation_id 全覆盖(mod.rs:92-157):每个变体都有 conversation_id: Option<String> 字段,后端 emit 全部传 Some(conv_id)(agentic.rs 多处 emit 均核验:Some(conv_id.clone()))。

1.7 前端 ai_is_generating 单值耦合

commands.rs:ai_is_generating(src/commands/ai/commands.rs:156-159):

pub async fn ai_is_generating(state: State<'_, AppState>) -> Result<bool, String> {
    let session = state.ai_session.lock().await;
    Ok(session.generating)  // 返回全局单 bool
}

消费点:

  • 前端 useAiSend.ts:314-321 sendMessage 发送前查后端真值(backendGenerating = await invoke('ai_is_generating'))
  • 前端 useAiWindow.ts:121 restoreGeneratingState 核对后端真值(分离窗口接管生成态)

B 阶段必须打破:改 ai_is_generating(conv_id) -> bool,前端两处调用传 conv_id。否则多会话下,查 A 的生成态会读到 B 的 generating 单值,误判阻塞。

1.8 detached 窗口基础(useAiWindow.ts)

WebviewWindow('ai-detached', ...)(src/composables/ai/useAiWindow.ts:43-52):单 label 单窗口。

  • WebviewWindow.getByLabel('ai-detached')(useAiWindow.ts:23):复用已存在窗口
  • detach 时快照 df-ai-gen / df-ai-text 到 localStorage(useAiWindow.ts:30-31)
  • resumeInDetached(useAiWindow.ts:151-156):核验 ai_is_generating 后恢复
  • 注释 useAiWindow.ts:33-34:「主窗口 state 与分离窗口 state 独立(各自 webview 独立 JS context)」

d2 多窗口扩展点:label 改 ai-detached-${convId} 支持每会话独立窗口。


2. 设计选项 + 推荐

2.1 b 形态:HashMap 整体 vs 拆字段 per-conv

2.1.1 ⚠️ 决策点 b-1:messages 是否 per-conv(偏离决策 b 原文,须用户裁决)

决策 b 原文:「仅拆 generating/stop_flag per-conv,messages/pending 按 conv reload」。

06-19 核验结论:messages 必须同 per-conv,否则决策 e「不退出各自跑完」落空。

证据链:

  1. 单例 messages 下,旧 loop(已切走的 conv A)继续跑会 session.messages.push(...)(agentic.rs:858/799/863)——push 到单例 messages(此时已被新 active conv B 的 restore_from_messages 覆盖过)。
  2. 这正是 §1.3 B-260615-11 三处退出校验防的污染:loop 写自己的 conv 但单例 messages 被新 conv 覆盖了 → 写错地方。
  3. 若 messages 保持单例,旧 loop 退出校验必须保留(否则污染);但决策 e 要求切换不退出,矛盾。
  4. 若 messages per-conv(session.conv(&conv_a).messages.push(...)),旧 loop 写自己的 conv 不污染 B,退出校验可改为「conv 存在性」判据,决策 e 成立。

侵入面增量评估:极小。messages 本就是 ContextManager 自包含结构(mod.rs:284),挪进 HashMap 即可;restore_from_messages 路径(switch 时从 DB reload)在 per-conv 下变为「建/已存在则跳过」(决策 e 下后台 conv 的 messages 不应被 reload 覆盖)。

推荐:修正决策 b 为「拆 generating/stop_flag/notify/iteration_used/agent_language/model_override/session_trust/messages 全 per-conv,仅 pending_approvals 保持单层 HashMap(已带 conversation_id 符合决策 b 原意)」此偏离决策 b 原文,列入待决策.md 须用户拍板

2.1.2 b 形态数据结构推荐

// src/commands/ai/mod.rs 新增
pub struct PerConvState {
    pub messages: ContextManager,
    pub generating: bool,
    pub stop_flag: Arc<AtomicBool>,
    pub notify: Arc<tokio::sync::Notify>,
    pub iteration_used: usize,
    pub agent_language: Option<String>,
    pub model_override: Option<String>,
    pub session_trust: HashSet<TrustKey>,
    pub created_at: Option<String>,  // 懒创建时间戳(随 active_conv 走)
}

pub struct AiSession {
    // ── 应用级(保持单例)──
    pub active_provider_id: Option<String>,
    pub active_conversation_id: Option<String>,  // 路由键:当前展示的 conv
    // ── 会话级(per-conv)──
    pub pending_approvals: HashMap<String, PendingApproval>,  // 单层(已带 conversation_id)
    pub per_conv: HashMap<String, PerConvState>,
}

访问器收敛(避免 41 处 lock 各自写 HashMap 索引):

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> { ... }
}

锁粒度推荐:维持单一 Arc<Mutex<AiSession>>(不拆 per-conv 细锁):

  • loop 内锁持有时间极短(push 一条 / 查 stop_flag 均 O(1)),真实竞争窗口小
  • stream_llm 期间不持锁(agentic.rs:631-637 build messages 后释放锁,stream 内无锁)
  • per-conv 细锁理论并发更好,但死锁风险高(锁顺序难保证 + pending_approvals 单层 HashMap 锁域交叉)
  • 后续若 profiling 显示锁竞争,再细化到 per-conv Mutex(渐进路径)

2.2 c 并发上限:复用 llm_concurrency.global

当前 global:permits=3(state.rs:283 LlmConcurrency::new(3, 2)),限全局 LLM 调用并发

B 阶段新语义:permits = 并发会话数上限(默认 3)。每会话 loop 入口 acquire_global() 拿 1 permit,持有整个 loop 生命周期(含工具执行/审批等待/重试)。

  • 含义:同一时刻最多 3 个对话并发跑 loop,第 4 个排队(acquire_global await 阻塞)。
  • 收益:token 暴增护栏(决策 c 原意)。
  • 实现:run_agentic_loop 入口 let _conv_permit = llm_concurrency.acquire_global().await;,permit 绑 guard Drop 释放。

per_conv Semaphore 改 HashMap:

per_conv: Arc<Mutex<HashMap<String, Arc<Semaphore>>>>,

每对话内限流不变(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 无限增长)。

⚠️ 决策点 c-1:global permits=3 是否合理?多会话并发下 3 个会话同时跑 LLM,token 成本是单会话的 ~3 倍。是否需调低默认(如 2)或暴露 Settings 配置?建议保持默认 3,Settings 加「并发会话数上限」数字配置项,列入待决策.md。

2.3 d UI:d1 侧栏 + d2 独立窗口(已决先 d1 后 d2)

d1 单面板侧栏切换 + 后台并行(先做):

  • 现状基础已就绪:useAiEvents.ts:133-140 事件按 conversation_id 路由不污染当前视图
  • 改动小:侧栏会话项显示生成态指示器 + state 加 generatingConvs: Set<string> 替代单值 generatingConvId
  • 不需要多窗口、状态同步、localStorage 跨窗口

d2 每会话独立 Tauri 窗口(后做):

  • 现状基础:useAiWindow.ts WebviewWindow('ai-detached') 单 label + getByLabel 复用
  • 改动:label 改 ai-detached-${convId} + detachPanel 加 conv_id 参数 + localStorage 多 key + 窗口间状态同步

实施顺序推荐:d1 先(纯前端 + 后端 §2-§5 改造的最小 UI 呈现层)→ 验证后台并行成立 → d2 后(独立窗口扩展,风险更高:多窗口内存、窗口生命周期管理)。

理由:d1 先做可让后端 per-conv 改造有最小验证界面(双会话并发验收),d2 风险点多(多 webview 内存 + 跨窗口同步)放后面。

2.4 e 旧 loop 切换处理:不退出各自跑完(真并发)

决策 e 原文:切换不退出旧 loop,各自跑完(真并发)。

前置依赖:必须 b-1 决策(messages per-conv)成立,否则单例 messages 下旧 loop 写 push 必污染新 active conv(见 §2.1.1)。

改造点(见 §4.1):agentic.rs:486-500/786-793/837-844 三处退出校验改为「conv 是否仍存在」判据(被删则退出),而非 active_conversation_id != conv_id

⚠️ 决策点 e-1:旧 loop 跑完后的 save_conversation 是否仍走原路径?原路径(agentic.rs:474/807/874/928/958)save 传 conv_id,与 active 无关,零改动可用(save_conversation 签名接 conv_id,见 conversation.rs)。推荐保持原路径,列入待决策.md 备案。


3. 实施路径(分阶段,每阶段文件 + 风险)

阶段 1:A 隔离残留 bug 修复 — 实际已完成,无工作

核验结论(§1.5):A 路线补漏全部落地:

  • 后端 ai_conversation_create 漏清 stop_flag/agent_language/model_override/session_trust — 已补(commands.rs:1404-1409)
  • 前端 newConversation 漏清 queue/generatingConvId/agentRound/searchQuery — 已补(useAiConversations.ts:80-83)

阶段 1 实际无残留 bug,B 阶段直接进入阶段 2。列为待决策.md 备案项:阶段 1 完成确认。

阶段 2:B 多会话并发核心 — B 阶段主体

主改文件:

  • src/commands/ai/mod.rs:新增 PerConvState struct + AiSession 字段迁移 + conv()/conv_read() 访问器
  • src/commands/ai/commands.rs:41+ 处 session.lock() 调用点迁移到 conv(&conv_id) 索引(分小批 commit)
  • src/commands/ai/agentic.rs:GeneratingGuard 加 conv_id 字段 + stop_flag/notify/messages 取用改 per-conv + §4 三处退出校验改 conv 存在性 + try_continue_agent_loop 改 per-conv 续跑
  • src/commands/ai/audit.rs:restore_pending_approvals 启动重建路径适配
  • src/commands/ai/conversation.rs:save_conversation 签名接 conv_id(已是,零改动)
  • src/state.rs:LlmConcurrency global 语义改会话级 + per_conv 改 HashMap

关键改造(详 §4):

  1. PerConvState 数据结构 + 访问器(批 1,纯重构,无行为变化)
  2. 41 处 lock 调用点迁移到 conv() 索引(批 2,高风险批,单会话全功能回归)
  3. run_agentic_loop per-conv 改造(批 3,GeneratingGuard + 三处退出校验 + try_continue)
  4. commands.rs 切换/新建/停止/发送改 per-conv(批 4,IPC 签名变更)
  5. llm_concurrency 改造 + F-260616-12 处理(批 5)

风险:

  • R-1(b-1 决策偏离,messages 必须同 per-conv)——须用户确认
  • R-2(单 Mutex 多 conv 抢锁,loop 内锁持有短,可控)
  • R-3(事件路由已就绪,新增 emit 须核对 conv_id)
  • R-5(回归面广,批 2/4 高风险,逐命令人工验收)
  • R-6(F-260616-12 retry 持 permit,global 语义切换后核验)
  • R-8(iteration_used 跨审批续跑在多会话下正确性,挪入 PerConvState)
  • R-9(PendingApproval.conversation_id 一致性,None 视为无主审批归类)

验收:双会话并发(开 A 跑 → 切 B 发 → A 后台跑完不污染 B)+ 单会话全功能回归。

阶段 3:UI d1 侧栏切换 + 后台并行

主改文件:

  • src/stores/ai.ts:新增 generatingConvs: Set<string> 替代单值 generatingConvId(line 66)
  • src/composables/ai/useAiEvents.ts:line 142-144 写入逻辑改 Set + 各清零点(line 136/273/317/342)全改
  • src/composables/ai/useAiConversations.ts:switch/newConversation 切走不清零他 conv 的生成态
  • 前端侧栏组件(Sidebar.vue / 会话列表项):显示生成态指示器(读 generatingConvs.has(conv.id))
  • src/composables/ai/useAiSend.ts:line 314-321 ai_is_generating 改传 conv_id

风险:

  • R-4(前端 generatingConvId 单值 → Set,漏改清零点致残留)

验收:双会话并发 UI(侧栏见两 conv 流式指示器,切走后台继续)。

阶段 4:UI d2 独立 Tauri 窗口

主改文件:

  • src/composables/ai/useAiWindow.ts:
    • detachPanel(convId) 加 conv_id 参数
    • WebviewWindow label 改 ai-detached-${convId}(line 43)
    • getByLabel 查重改按 convId 查(line 23)
    • localStorage 多 key(df-ai-gen-${convId} / df-ai-text-${convId})
    • 窗口间状态同步(Tauri emit 自定义事件 + 各窗口 listen)
  • 侧栏组件:每会话加「在新窗口打开」按钮(右键菜单或图标)
  • src/composables/ai/useAiSend.ts / useAiConversations.ts:窗口内操作路由到正确 conv_id

风险:

  • 多窗口 + 多 webview 内存占用(每窗口一份 Vue app + store)
  • 窗口生命周期管理(关窗是否停 loop?决策 e 下不停,但须确认 UI 提示)
  • 跨窗口 provider 变更同步(自定义事件桥接)

验收:每会话独立窗口 + 多窗口并发 + 窗口间状态同步。


4. 关键改造点(代码级,实施时参照)

4.1 B-260615-11 陈旧 loop 退出逻辑改 conv 存在性

改造前(agentic.rs:486-500):

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;
}
session.iteration_used = iteration + 1;

改造后(决策 e 真并发):

{
    let mut session = session_arc.lock().await;
    if !session.per_conv.contains_key(&conv_id) {
        tracing::warn!(conv_id = %conv_id, "[ai] conv 已删除,旧 loop 退出");
        return;
    }
    session.conv(&conv_id).iteration_used = iteration + 1;
}

同样改造 agentic.rs:786-793(MidStream 保文后 push)/837-844(stream 后 push):三处 push 前校验全部改为 conv 存在性而非 active 一致性。

4.2 stop_flag/notify/messages 取用改 per-conv 索引

改造前(agentic.rs:446-449):

let (stop_flag, notify) = {
    let session = session_arc.lock().await;
    (session.stop_flag.clone(), session.notify.clone())
};

改造后:

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:631-637):

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:73-108):guard 持 Arc<Mutex<AiSession>>,Drop 时 session.lock().await.generating = false(全局)。

改造后:guard 加 conv_id: String 字段,reset() / Drop 时 session.conv(&conv_id).generating = false(只复位该 conv)。

4.4 try_continue_agent_loop 改 per-conv 续跑

改造前(agentic.rs:992-1112):读全局 generating + pending 决定续跑,conv_id 取自 active_conversation_id 或 pending_approvals。

改造后:加 conv_id: &str 参数,读 conv_read(conv_id).generating + pending_approvals.values().any(|a| a.conversation_id == Some(conv_id)) 决定续跑,spawn run_agentic_loop 传同一 conv_id。

调用点:ai_approve(commands.rs 330/411)调用处传 approval.conversation_id(已有,见 commands.rs:484/548 校验)。

4.5 ai_conversation_switch 删 readonly 分支

改造前(commands.rs:1474-1481):生成中 readonly 切换。

改造后:

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 到其 per-conv(已存在则跳过,防覆盖后台 conv 的内存 messages)
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 分支

4.6 ai_conversation_create 删强制结束旧 loop 块

改造前(commands.rs:1360-1379):生成中 generating=false + stop_flag=true 杀旧 loop + emit AiCompleted。

改造后:

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 { 强制结束 } 块

注意:旧块前还有 SW-260618-02 finalize_pending_placeholders(commands.rs:1365/1398)——保留其语义但改为针对旧 conv 的占位终态化(若旧 conv 的 messages 即将被新建覆盖,占位须终态化;per-conv 后旧 conv 的 messages 保留,占位无须终态化,可直接删)。

4.7 ai_chat_send / ai_is_generating / ai_chat_stop / force / continue / stop_loop 改 per-conv

所有命令加 conv_id 参数(前端传 activeConversationId 或目标 conv),操作仅针对该 conv 的 per-conv state:

  • ai_is_generating(conv_id)conv_read(conv_id).map(|c| c.generating).unwrap_or(false)
  • ai_chat_send(conv_id, ...) → 互斥仅查该 conv 的 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 签名变更:更新 #[tauri::command] 签名 + lib.rs invoke_handler + 前端 api/ai.ts wrapper + useAiSend.ts/useAiConversations.ts 调用处。


5. 风险点 + 回归测试点

5.1 风险清单

编号 风险 评估 缓解
R-1 ⚠️ 决策 b 偏离:messages 必须同 per-conv 高(若不偏离决策 e 落空) 列入待决策.md,须用户拍板
R-2 单 Mutex 多 conv 抢锁 中(loop 内锁持有短) profiling 后再细化 per-conv Mutex
R-3 事件路由新增 emit 漏 conv_id 低(11 变体已全覆盖) 批 3/4 改造时 grep emit checklist
R-4 前端 generatingConvId 单值 → Set 漏改清零点 中(4 处清零点) 批 6 集中改 + 双会话 UI 验收
R-5 回归面广(批 2/4 高风险) 批间 cargo + vue-tsc + 逐命令人工验收
R-6 F-260616-12 retry 持 permit 低(global 改会话级后语义自洽) 批 5 验证 global 新语义下行为符合预期即关闭
R-7 启动恢复 + L0 握手多 conv 适配 中(restore_pending_approvals + lib.rs L0) 批 8 集中处理
R-8 iteration_used 跨审批续跑在多会话下正确性 中(挪入 PerConvState) 批 2 checklist 含 ai_approve 两处读(commands.rs:330/411)
R-9 PendingApproval.conversation_id 一致性 低(insert 点均带 Some(conv_id)) 批 2 核验所有 insert 点,None 视为无主审批归类

5.2 回归测试点

单会话全功能回归(批 2/4 后必做,逐命令):

  • send / regenerate / edit_last / approve / stop / continue_loop / stop_loop / switch / create / delete / rename / archive / set_pinned

双会话并发验收(批 3 后):

  • 开 conv A 跑长任务(工具调用) → 切 conv B 发消息 → A 后台跑完不污染 B 的 messages/pending
  • A 跑审批等待 → 切 B 发 → A 审批仍可处理(approve 后 A 续跑)
  • A 跑达 max_iterations → 切 B 发 → A 仍可 continue_loop / stop_loop

3 会话并发验收(批 5 后):

  • 同时跑 3 个会话(第 4 个排队)+ 重试期不阻塞他对话(F-260616-12)
  • token 暴增护栏:3 会话并发 token ≈ 3 倍单会话

d1 UI 验收(批 6):

  • 侧栏见两 conv 流式指示器(generatingConvs.has)
  • 切走后台继续,A 完成后指示器消失

d2 UI 验收(批 7):

  • 每会话独立窗口(ai-detached-${convId})+ 多窗口并发 + 窗口间 provider 变更同步

启动恢复验收(批 8):

  • 重启恢复多 conv pending 审批(各归各的 per_conv)
  • HMR 清多 conv 残留 generating(L0 握手遍历 per_conv)

关键回归点:

  • 切换不丢上下文:conv A 跑一半切走再切回,messages 完整(per-conv 保留)
  • 并发不串话:conv A 的 AiTextDelta 不写入 conv B 的视图(事件 conversation_id 路由)
  • 事件路由 conversation_id:所有 emit 带 conv_id(useAiEvents.ts:125 已路由)
  • token 暴增护栏:global permits=3 限并发会话数

6. 与已决架构债的关系

  • memory aichat-arch-extensibility:「AiSession 单例未动」是登记的架构债,本设计正式清偿。
  • mod.rs:320-331 注释:「当前 AiSession 是全局单例(F-09 B 多会话架构落地时改 per-conv)」——预留债标记,本设计落地。
  • state.rs:94-98 注释:per_conv「未来若支持多对话并发,需改为 HashMap<conv_id, Semaphore>」——本设计 §2.2 落地。
  • 不冲突:本设计与 F-260616-11(iteration 累计,已 per-conv 化预留)、F-260616-07(流式重试,F-260616-12 依赖项 §2.2 处理)、generating 状态机加固(RAII guard,§4.3 改 per-conv)、B-260615-14(notify 即时唤醒,随 stop_flag 入 PerConvState)均兼容。

7. 待裁决决策点清单(供主代裁决 / 待决策.md)

编号 决策点 推荐 影响范围
⚠️ b-1 messages 是否 per-conv(偏离决策 b 原文) 必须 per-conv(否则决策 e 落空) 阶段 2 批 1-3
⚠️ c-1 global permits=3 默认值是否合理 / 暴露 Settings 保持 3 + Settings 加「并发会话数上限」配置项 阶段 2 批 5
⚠️ e-1 旧 loop 跑完 save_conversation 是否走原路径 保持原路径(save 接 conv_id 零改动) 阶段 2 批 3 备案
(备案) 阶段 1(A 隔离残留 bug)实际无残留,确认跳过 确认跳过 阶段 1
(备案) d2 关窗是否停 loop(决策 e 下不停) 不停,UI 提示「窗口关闭后后台继续」 阶段 4
(备案) d2 多窗口内存上限(是否限制最多 N 个独立窗口) 建议限 3-5 个,超限提示 阶段 4

8. 验收清单(每批完成后)

  • 阶段 1:(无工作,A 路线补漏已落地,确认即可)
  • 阶段 2 批 1:cargo build + PerConvState 访问器单测(惰性建/已存在/conv 删除)
  • 阶段 2 批 2:cargo + vue-tsc 0err + 单会话全功能回归(逐命令)
  • 阶段 2 批 3:双会话并发(开 A 跑 → 切 B 发 → A 后台跑完不污染 B)+ 单会话回归
  • 阶段 2 批 4:双会话并发(切走不杀旧 loop,各自跑完)+ 全 IPC 签名前端调用核对
  • 阶段 2 批 5:3 会话并发(第 4 排队)+ 重试期不阻塞他对话(F-260616-12)
  • 阶段 2 批 8:重启恢复多 conv pending 审批 + HMR 清多 conv 残留
  • 阶段 3:双会话并发 UI(侧栏两 conv 流式指示器,切走后台继续)
  • 阶段 4:每会话独立窗口 + 多窗口并发 + 窗口间状态同步

设计完。本文件为设计草案,不含代码改动。实施时按阶段 2(批 1→8)→ 阶段 3 → 阶段 4 顺序,每批独立可验证。

核心待裁决:⚠️ b-1(messages per-conv 偏离决策 b 原文)是 B 阶段能否落地的关键,须用户拍板后方可进入阶段 2 批 1。