Files
DevFlow/docs/02-架构设计/F-09B-多会话并发设计-2026-06-16.md

37 KiB

F-260616-09 B 阶段 — AiSession 单例→多会话并发架构设计

状态:设计稿(2026-06-16) | 关联:docs/待决策.md F-260616-09 / todo F-260616-12 / 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):

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

整个应用一份 AiSession,被 Arc<Mutex<>> 包裹,所有 IPC 命令竞争同一把锁。

AiSession 字段(mod.rs:166-216,共 10 字段):

字段 类型 多会话语义
messages 168 ContextManager 会话级(每对话一份历史),当前单例:切换走 restore_from_messages 覆盖
active_provider_id 170 Option<String> 应用级(provider 全局共享),保持单例
active_conversation_id 172 Option<String> 会话级(当前活跃 conv 路由键),单例=只一个 active
active_conv_created_at 174 Option<String> 会话级(懒创建时间戳,随 active_conv)
pending_approvals 186 HashMap<tool_call_id, PendingApproval> 会话级(已带 conversation_id 字段 audit.rs:598/276),单例下靠 retain 过滤
generating 188 bool 会话级(每对话独立流式态),单例=全局互斥
agent_language 190 Option<String> 会话级(每对话独立语言),单例下切换须清空
stop_flag 192 Arc<AtomicBool> 会话级(每对话独立停止信号),单例=全局唯一
notify 203 Arc<tokio::sync::Notify> 会话级(随 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().await41 处(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<Mutex<AiSession>> 持整个单例 Arc,loop 内全程竞争全局锁。

B-260615-11 陈旧 loop 退出逻辑(agentic.rs:194-211,427-434,380-388)——多会话 B 阶段必须改造的核心点:

// 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<TrackedMessage> + token 缓存,无全局共享状态。

  • push/clear/restore_from_messages(context.rs:176/188/414)均是自包含操作。
  • B 形态决策 b 的关键支撑:messages 当前之所以单例,只是因为 AiSession 单例;一旦 generating/stop_flag 拆 per-conv,messages 也可随之 per-conv(HashMap<conv_id, ContextManager>),侵入面与拆 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<Mutex<Arc<Semaphore>>>,非 HashMap),state.rs:94-97 注释自陈:

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

  • global Semaphore(state.rs:100)构造时 permits=3(state.rs:221 LlmConcurrency::new(3, 2))
  • 决策 c:B 阶段 global 改「并发会话数上限」语义——原本限 LLM 调用并发,改限并发会话数(每会话占 1 permit 入口)。
  • per_conv 改 HashMap<conv_id, Semaphore>,每对话内限流(主循环 + 标题 + 提炼)。
  • 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 内):

/// 单个对话的运行态(generating/stop_flag/notify/iteration/agent_language)。
/// 多会话并发后,每个 conv 一份,HashMap<conv_id, PerConvState> 挂在 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<AtomicBool>,
    pub notify: Arc<tokio::sync::Notify>,
    pub iteration_used: usize,
    pub agent_language: Option<String>,
    /// 该 conv 的 messages 历史(§2.3:messages per-conv 化后挪入此)
    pub messages: ContextManager,
    /// 懒创建时间戳(随 active_conv 走)
    pub created_at: Option<String>,
}

AiSession 改造(mod.rs:166):

pub struct AiSession {
    // ── 应用级(保持单例) ──
    pub active_provider_id: Option<String>,
    pub active_conversation_id: Option<String>,  // 路由键:当前展示的 conv(切走即变,不影响后台 loop)
    // ── 会话级(per-conv) ──
    pub pending_approvals: HashMap<String, PendingApproval>,  // 已带 conversation_id,保持单层
    pub per_conv: HashMap<String, PerConvState>,
    // ── L0 握手/A 路线兼容 ──
    // 旧字段 generating/stop_flag/notify/agent_language/messages/iteration_used 移入 PerConvState
}

2.2 访问封装(收敛锁边界)

关键:避免 41 处 session.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> { ... }

    /// 兼容旧调用:取 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.xxxsession.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<Mutex<AiSession>>:

  • per-conv 细锁(HashMap<conv_id, Mutex>)理论并发更好,但:
    • 死锁风险: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<conv_id, Semaphore>

当前(state.rs:101):单一 Semaphore permits=2,限单对话内并发(主循环+标题+提炼)。

B 阶段:

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 无限增长)。

3.3 F-260616-12 retry 持 permit 依赖(关键风险点)

现状(agentic.rs:247-248):

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

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 真并发):

{
    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):

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:224-230):

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<Mutex<AiSession>>,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 决定续跑。

改造后:

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。

改造后:

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。

改造后:

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} 全局互斥。

改造后:

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[].idstate.generatingConvId 比对,匹配则显流式指示器(改动:Sidebar.vue 会话项 + 新 computed generatingConvSet)
  2. 多 conv 后台并行态:state 加 generatingConvs: Set<string>(替代单值 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-gendf-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<conv_id, Semaphore>」——本设计 §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 顺序,每批独立可验证。