# 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<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().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<Mutex<AiSession>>` 持整个单例 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<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 内):

```rust
/// 单个对话的运行态(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):

```rust
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 索引**,提供访问器:

```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<Mutex<AiSession>>`**:
- per-conv 细锁(HashMap<conv_id, Mutex<PerConvState>>)理论并发更好,但:
  - 死锁风险: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 阶段**:
```rust
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):
```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<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 决定续跑。

**改造后**:
```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<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-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<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 顺序,每批独立可验证。**
