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().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 阶段必须改造的核心点:
// 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:97resumeInDetached改传 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>。
globalSemaphore(state.rs:100)构造时 permits=3(state.rs:221LlmConcurrency::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.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>)理论并发更好,但:
- 死锁风险: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).generatingai_chat_stop(conv_id)→ 置该 conv 的 stop_flag + notifyai_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 改动点:
- 侧栏会话项显示生成态:读
state.conversations[].id与state.generatingConvId比对,匹配则显流式指示器(改动:Sidebar.vue会话项 + 新 computedgeneratingConvSet) - 多 conv 后台并行态:state 加
generatingConvs: Set<string>(替代单值 generatingConvId),useAiEvents.ts:142-144 改 push 到 Set,Completed/Error 时 remove - 切走不清零:useAiConversations.ts:38-44 newConversation 清 queue/generatingConvId 改为只清当前 conv 的(不杀他 conv)
- 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 改动点:
- label 改 per-conv:
ai-detached-${convId},支持每会话独立窗口(getByLabel查重) - detachPanel 加 conv_id 参数:从指定 conv detach(而非当前 active)
- 窗口间状态同步:多窗口各自 webview 各自 store,需:
- 后端事件广播:Tauri
emit默认所有 webview 收(useAiEvents 在每窗口各自 listen,按 conversation_id 过滤)——天然支持,无需改 - 显式状态同步(如某窗口改 provider):用 Tauri
emit自定义事件 + 各窗口 listen(useAiWindow.ts 已有 _unlistenMove/Resize 模式可仿)
- 后端事件广播:Tauri
- localStorage 快照:从单
df-ai-gen改df-ai-gen-${convId}多 key - 窗口管理 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 顺序,每批独立可验证。