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

> **状态**:设计草案(2026-06-19) | **范围**:只读分析 + 设计文档,**不改源码**
> **关联**:[F-09B-多会话并发设计-2026-06-16.md](./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 字段,新增 `notify`、`model_override`、`session_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 单例结构与字段

**单例锚点**:

```rust
// 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 结构核验**:

```rust
// 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):

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

```typescript
// 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):

```rust
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 形态数据结构推荐

```rust
// 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 索引)**:

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

**锁粒度推荐**:维持单一 `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**:

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

**⚠️ 决策点 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):

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

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

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

```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: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 切换。

**改造后**:

```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 到其 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。

**改造后**:

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

**注意**:旧块前还有 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。
