From b2d44f85e942d210985da0a5ead8c6f8557897fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sun, 4 Oct 2026 16:05:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20=E6=97=A0=E4=BA=BA?= =?UTF-8?q?=E5=80=BC=E5=AE=88P0=E4=BB=BB=E5=8A=A1=E8=BE=B9=E7=95=8C?= =?UTF-8?q?=E5=85=A8=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 任务级授权边界判定与审批旁路单点插桩 - boundary_json 随会话落库(V45 可逆迁移) - 六路非正常终局统一安全停靠与托盘通知 - 每五轮进度事件与会话头部进度条 - 前端边界配置卡侧栏徽标及 UX 校验补齐 --- .../df-storage/src/crud/conversation_repo.rs | 36 +- crates/df-storage/src/migrations.rs | 65 ++- crates/df-storage/src/models.rs | 3 + src-tauri/src/commands/ai/agentic/mod.rs | 235 +++++++- src-tauri/src/commands/ai/audit/mod.rs | 328 ++++++++++- src-tauri/src/commands/ai/audit/path_auth.rs | 73 ++- src-tauri/src/commands/ai/audit/record.rs | 46 +- src-tauri/src/commands/ai/boundary.rs | 256 +++++++++ .../src/commands/ai/commands/conversation.rs | 229 ++++++++ src-tauri/src/commands/ai/conversation.rs | 86 ++- src-tauri/src/commands/ai/mod.rs | 189 ++++++- src-tauri/src/lib.rs | 5 + src-tauri/src/state.rs | 5 +- src-tauri/src/state/allowed_dirs.rs | 2 +- src/api/ai.ts | 24 +- src/api/types.ts | 37 ++ src/components/ai/ConversationSidebar.vue | 33 ++ src/components/ai/TaskBoundaryCard.vue | 530 ++++++++++++++++++ src/components/ai/TaskParkedCard.vue | 170 ++++++ src/components/ai/TopBar.vue | 81 ++- src/composables/ai/useAiConversations.ts | 95 +++- src/composables/ai/useAiEvents.ts | 90 ++- src/composables/ai/useAiLifecycleEvents.ts | 47 +- src/composables/ai/useAiPendingState.ts | 28 +- src/composables/ai/useTaskBoundary.ts | 61 ++ src/i18n/en/ai.ts | 79 +++ src/i18n/en/aiChat.ts | 3 + src/i18n/zh-CN/ai.ts | 76 +++ src/i18n/zh-CN/aiChat.ts | 3 + 29 files changed, 2773 insertions(+), 142 deletions(-) create mode 100644 src-tauri/src/commands/ai/boundary.rs create mode 100644 src/components/ai/TaskBoundaryCard.vue create mode 100644 src/components/ai/TaskParkedCard.vue create mode 100644 src/composables/ai/useTaskBoundary.ts diff --git a/crates/df-storage/src/crud/conversation_repo.rs b/crates/df-storage/src/crud/conversation_repo.rs index 5efa8e8..bd67ad1 100644 --- a/crates/df-storage/src/crud/conversation_repo.rs +++ b/crates/df-storage/src/crud/conversation_repo.rs @@ -108,6 +108,8 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result |row| ai_conversation_from_row(row), insert => |conn, rec| { conn.execute( - "INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", + "INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin, boundary_json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)", params![ rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, if rec.pinned { 1i32 } else { 0i32 }, rec.prompt_tokens, rec.completion_tokens, - rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at, rec.origin + rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at, rec.origin, + rec.boundary_json ], ) }, update => |conn, rec| { conn.execute( - "UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12 WHERE id = ?13", + "UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13 WHERE id = ?14", params![ rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, if rec.pinned { 1i32 } else { 0i32 }, rec.prompt_tokens, rec.completion_tokens, - rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.id + rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.boundary_json, rec.id ], ) } @@ -783,6 +786,29 @@ impl AiConversationRepo { .map_err(storage_err)? } + /// 无人值守任务边界快照写入(V45 boundary_json 列)。None=清除(回退逐次审批)。 + /// + /// 仅改 boundary_json,不动 updated_at(纯元数据标记,对齐 set_archived/set_pinned)。 + /// 返回 affected>0:对话尚未落库(懒创建,首条消息前)时影响 0 行——调用方以内存 + /// per_conv.boundary 为真相源,首插 save 会随 INSERT 带上,无需报错。 + pub async fn set_boundary_json(&self, id: &str, boundary_json: Option<&str>) -> Result { + let conn = self.conn.clone(); + let id = id.to_owned(); + let value = boundary_json.map(|s| s.to_owned()); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let affected = guard + .execute( + "UPDATE ai_conversations SET boundary_json = ?1 WHERE id = ?2", + params![value, id], + ) + .map_err(storage_err)?; + Ok(affected > 0) + }) + .await + .map_err(storage_err)? + } + /// G1.3: 删除对话 + 其全部 ai_messages 子行(单事务原子)。 /// /// 背景:原宏生成 `delete` 只删 ai_conversations 主行,而 ai_messages 表无外键级联 diff --git a/crates/df-storage/src/migrations.rs b/crates/df-storage/src/migrations.rs index 6d796a7..0e409c1 100644 --- a/crates/df-storage/src/migrations.rs +++ b/crates/df-storage/src/migrations.rs @@ -51,7 +51,9 @@ pub fn run(conn: &Connection) -> Result<()> { // 纯数据 UPDATE 不经过 promote 函数,不触发嵌入生成)。 // V44 = 子代理轨迹来源标记(ai_conversations.origin TEXT,spawn_agent 出口整轨迹落库, // 桌面侧栏/miniapp 列表按 origin 过滤子会话)。 - let steps: [(i32, fn(&Connection) -> Result<()>); 44] = [ + // V45 = 无人值守任务边界(ai_conversations.boundary_json TEXT,TaskBoundary JSON 快照, + // 发起时一次声明存续期生效,随会话同亡)。 + let steps: [(i32, fn(&Connection) -> Result<()>); 45] = [ (1, migrate_v1), (2, migrate_v2), (3, migrate_v3), @@ -96,6 +98,7 @@ pub fn run(conn: &Connection) -> Result<()> { (42, migrate_v42), (43, migrate_v43), (44, migrate_v44), + (45, migrate_v45), ]; for (version, migrate_fn) in steps { @@ -1334,6 +1337,23 @@ fn migrate_v44(conn: &Connection) -> Result<()> { Ok(()) } +/// V45: 无人值守任务边界 — ai_conversations 加 boundary_json TEXT 列。 +/// +/// TaskBoundary JSON 快照(风险上限/目录白名单/禁破坏性/预算),发起时一次声明、 +/// 任务存续期生效、随会话同亡(无独立表,设计拍板点 2: 任务与会话 1:1,独立表过度设计)。 +/// NULL=未声明(现状逐次审批,渐进增强零倒退)。 +/// - **幂等**:column_exists 守卫(同 V42/V44 模式),崩溃重跑/全链两遍不炸; +/// - **可逆**:`ALTER TABLE ai_conversations DROP COLUMN boundary_json`(SQLite 3.35+)。 +fn migrate_v45(conn: &Connection) -> Result<()> { + if !column_exists(conn, "ai_conversations", "boundary_json") { + conn.execute("ALTER TABLE ai_conversations ADD COLUMN boundary_json TEXT", [])?; + tracing::info!("v45: ai_conversations 加 boundary_json 列(无人值守任务边界)"); + } + conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [45])?; + tracing::info!("迁移 v45 完成: ai_conversations 加 boundary_json 列"); + Ok(()) +} + /// V21 建表 SQL — 消息拆分存储 ai_messages 表 /// /// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。 @@ -2338,7 +2358,7 @@ mod tests { "tasks.module_id 列缺失(V41 加)" ); - // 3. schema_version 应推进到 43(全量迁移成功落版本号) + // 3. schema_version 应推进到 45(全量迁移成功落版本号) let max_version: i64 = conn .query_row( "SELECT COALESCE(MAX(version), 0) FROM schema_version", @@ -2347,8 +2367,8 @@ mod tests { ) .expect("查 schema_version 应成功"); assert_eq!( - max_version, 44, - "全量迁移后 schema_version 应为 44(实际 {}),说明某条 migrate_vN 链路断在中间", + max_version, 45, + "全量迁移后 schema_version 应为 45(实际 {}),说明某条 migrate_vN 链路断在中间", max_version ); @@ -2425,7 +2445,7 @@ mod tests { |r| r.get(0), ) .unwrap(); - assert_eq!(max_v, 44, "首轮应推进到 44"); + assert_eq!(max_v, 45, "首轮应推进到 45"); // 清空版本表强制全链第二遍(每步 execute 第二次) conn.execute("DELETE FROM schema_version", []).unwrap(); @@ -2437,7 +2457,7 @@ mod tests { |r| r.get(0), ) .unwrap(); - assert_eq!(max_v2, 44, "重跑后应重新推进到 44"); + assert_eq!(max_v2, 45, "重跑后应重新推进到 45"); } // ============================================================ @@ -2510,4 +2530,37 @@ mod tests { .unwrap(); assert_eq!(v, 41); } + + /// V45 幂等:最小库补建 boundary_json 列;重跑(含手动回退版本号模拟崩溃断点)不炸、 + /// 不报 duplicate column、版本号 INSERT OR IGNORE 防 PK 冲突。 + #[test] + fn v45_boundary_json_idempotent() { + let conn = setup_legacy_db(); + migrate_v45(&conn).expect("v45 首次迁移应成功"); + assert!(column_exists(&conn, "ai_conversations", "boundary_json"), "应有 boundary_json 列"); + let v: i64 = conn + .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) + .unwrap(); + assert_eq!(v, 45, "应写入版本号 45"); + + // 模拟崩溃断点:版本号回退(45 未落)但列已建 → 重跑走 column_exists 短路 + conn.execute("DELETE FROM schema_version WHERE version = 45", []).unwrap(); + migrate_v45(&conn).expect("列已存在时重跑应幂等跳过 ALTER 不报错"); + let v2: i64 = conn + .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) + .unwrap(); + assert_eq!(v2, 45, "重跑后版本号恢复 45"); + + // 列可写可读(NULL 语义=未声明边界) + conn.execute( + "INSERT INTO ai_conversations (id, messages, created_at, updated_at, boundary_json) \ + VALUES ('c1', '[]', '0', '0', '{\"risk_ceiling\":\"medium\"}')", + [], + ) + .unwrap(); + let got: Option = conn + .query_row("SELECT boundary_json FROM ai_conversations WHERE id = 'c1'", [], |r| r.get(0)) + .unwrap(); + assert!(got.is_some(), "boundary_json 应可写可读"); + } } diff --git a/crates/df-storage/src/models.rs b/crates/df-storage/src/models.rs index 1972135..0a97cd6 100644 --- a/crates/df-storage/src/models.rs +++ b/crates/df-storage/src/models.rs @@ -374,6 +374,9 @@ pub struct AiConversationRecord { /// 会话来源标记(V44):None=主会话;Some("sub_agent")=子代理轨迹(spawn_agent 出口落库), /// 桌面侧栏/miniapp 列表按此过滤。不可变列(UPDATE 不写)。 pub origin: Option, + /// 无人值守任务边界快照(V45):TaskBoundary JSON;None=未声明(现状逐次审批)。 + /// 生命周期=会话存续(发起时一次声明,随会话同亡),save 随行写/switch 读还原。 + pub boundary_json: Option, } /// AI 工具执行审计记录 diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index efd9656..04268e4 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -86,6 +86,18 @@ pub const DEFAULT_MAX_AGENT_RETRIES: usize = 3; /// N=3 对齐 DEFAULT_MAX_AGENT_RETRIES 量级,3 轮连续饱和才判持续过载,防抖动误降级。 pub const PROVIDER_SATURATION_DEMOTE_THRESHOLD: usize = 3; +/// 无人值守进度可观测(设计 §4.3):主 loop 每 N 轮 emit AiTaskProgress 一次。 +/// 5=设计拍板点 4 建议 A;每轮 emit 噪音大,不做则牺牲静默期可观测。实测后可调。 +pub const PROGRESS_EVERY_N_ROUNDS: usize = 5; + +/// 跨轮任务级重入(深查报告 P1-4):单轮流前重试共享 30s 挂钟预算,一次 >30s 的网络故障 +/// (WiFi 切换/VPN 重连/上游限流窗口)即整任务失败。本层加「轮间」耐受力:轮失败不立即 +/// 终局,指数退避后从当前轮重入(上下文已落库,每轮 save 天然支持;iteration 不推进, +/// 不烧 max_rounds 预算)。连续重入 ROUND_REENTRY_MAX 次,退避 1min/5min/15min, +/// 仍失败转停靠(fatal)。单轮内 30s 预算不动(防死循环),只加轮间容忍。 +pub const ROUND_REENTRY_MAX: usize = 3; +pub const ROUND_REENTRY_BACKOFF_SECS: [u64; ROUND_REENTRY_MAX] = [60, 300, 900]; + /// 改进3 B 常量开关 KEYWORD_FALLBACK_ENABLED 已随压缩逻辑迁至 context_lifecycle.rs /// (在该模块内部为私有 const,文档见 context_lifecycle.rs 顶部)。 /// @@ -154,10 +166,15 @@ pub const MAX_GOALS: usize = 5; /// system_prompt 零变化,退回改动前行为(单点回退,不影响 augmentation 的 / 联想)。 pub const SKILL_LIST_INJECT_ENABLED: bool = true; -/// 技能清单注入最大条数(默认 20)。 +/// 技能清单注入最大条数(默认 120)。 /// /// 防本机大量技能全量进 system_prompt 占 sys_tokens 预算;取前 N 条(name 去重序),截断描述。 -pub const SKILL_LIST_MAX_ITEMS: usize = 20; +/// 自动触发(方案3):本机实扫 38 home skills + 1 command + 72 plugins ≈ 111 个技能, +/// 原上限 20 会把 90+ 个技能对 LLM 完全隐藏(publish-moyu-uat 之后全部不可见), +/// "任务命中技能 → AI 自主 read_skill"的自动触发链路因清单不可见而断裂。 +/// 清单单条为「名称+80字描述」索引卡片(约 30 token/条),120 条 ≈ 3.6K token, +/// 且注入位于 system 稳定段(跨轮缓存命中,只付一次成本),120 上限兼顾覆盖与预算。 +pub const SKILL_LIST_MAX_ITEMS: usize = 120; /// 技能清单单条描述截断长度(默认 80 字符)。 /// @@ -1039,6 +1056,10 @@ pub(crate) async fn run_agentic_loop( // 仅 per-provider 层非阻塞降级,不影响 global/per_conv 阻塞限流。 let mut provider_saturation: HashMap = HashMap::new(); + // 跨轮任务级重入(ROUND_REENTRY_MAX):连续轮失败计数,Continue 成功即归零 + // (只认连续失败,偶发一轮网络故障不消耗后续耐受力)。 + let mut consecutive_round_failures: usize = 0; + // 0 = 不限:effective_max=usize::MAX,for 到不了上界,靠 stop_flag/收敛/审批退出(下方达上限暂停分支不触发) let effective_max = if max_iterations == 0 { usize::MAX } else { max_iterations }; @@ -1053,6 +1074,20 @@ pub(crate) async fn run_agentic_loop( // 依赖 panic=unwind(tokio 默认),panic=abort 构建下 catch_unwind 不生效(尽力兜底)。 let outcome = unwind_to_result(async { for iteration in start_iteration..effective_max { + // ── 进度可观测段(设计 §4.3):每 N 轮 emit AiTaskProgress ── + // round 1 起算对齐 AiAgentRound 口径;max_rounds=0=不限轮次(前端按无上限处理)。 + // owner 守卫:被接管的旧 loop 不发(防串会话进度)。事件即逝不落库,终局不补发。 + if iteration % PROGRESS_EVERY_N_ROUNDS == 0 + && loop_epoch_arc.load(Ordering::SeqCst) == my_epoch + { + emit_ai_chat_event(&app_handle, AiChatEvent::AiTaskProgress { + conversation_id: Some(conv_id.clone()), + round: (iteration + 1) as u32, + max_rounds: max_iterations as u32, + tokens_used: tokens.total(), + }); + } + // ── 轮次准备段:入口 stop/stale 收尾闸门 → 主题标记 → conv 一致性(iteration_used/epoch/心跳) // → 轮通知 → 自动压缩 → 请求消息构建(build_for_request + namespace 展开 + 易变尾段 // + tool_result 摘要 + 占位断言)→ estimated_prompt + agentic_req 重建 ── @@ -1070,29 +1105,81 @@ pub(crate) async fn run_agentic_loop( PreparedRound::Exit => return, }; - // ── 流式消费段:单轮流式调用(candidate 链 + MidStream 保文)── + // ── 流式消费段:单轮流式调用(candidate 链 + MidStream 保文 + 跨轮重入)── // 实现见 stream_relay::stream_round。RoundOutcome 四态:Continue 携输出继续; // StaleAfterStream(流后 epoch 变,guard 已 disarm)/ MidStreamSaved(保文已完整收尾)/ - // Fatal(候选 Fatal 或全耗尽,guard.reset + emit + save 已完成)→ 本轮终止 return。 - let (full_text, tool_calls_acc, round_usage, round_is_estimated) = match stream_round( - &session_arc, &db, &conv_id, - &messages, &tool_defs, &app_handle, - &stop_flag, ¬ify, - max_retries, &model_override, &agentic_req, estimated_prompt, - &mut last_round_estimated, &mut last_reasoning_content, - &mut resolved_model, &mut provider_config, - &mut provider_saturation, - &mut tokens, &mut saved_token_snapshot, &mut guard, - &candidates, &llm_concurrency, - &pinned_goals_snapshot, - &loop_epoch_arc, my_epoch, - ).await { - RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated } => { - (full_text, tool_calls, round_usage, round_is_estimated) + // Fatal(候选 Fatal 或全耗尽,guard.reset + emit + save 已完成)。 + // 'stream_round 重入层(ROUND_REENTRY_MAX):Fatal 不再立即终局,指数退避后从 + // 当前轮重入(stream_relay 每 per-permit 重取重入获全新 30s 预算 + 全新候选链)。 + let (full_text, tool_calls_acc, round_usage, round_is_estimated, round_truncated) = 'stream_round: loop { + match stream_round( + &session_arc, &db, &conv_id, + &messages, &tool_defs, &app_handle, + &stop_flag, ¬ify, + max_retries, &model_override, &agentic_req, estimated_prompt, + &mut last_round_estimated, &mut last_reasoning_content, + &mut resolved_model, &mut provider_config, + &mut provider_saturation, + &mut tokens, &mut saved_token_snapshot, &mut guard, + &candidates, &llm_concurrency, + &pinned_goals_snapshot, + &loop_epoch_arc, my_epoch, + ).await { + RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated, truncated_by_max_tokens } => { + // 本轮成功:连续失败计数归零(重入只认连续失败)。 + consecutive_round_failures = 0; + break (full_text, tool_calls, round_usage, round_is_estimated, truncated_by_max_tokens); + } + RoundOutcome::StaleAfterStream => return, + RoundOutcome::MidStreamSaved => { + // 安全停靠(设计 §四):MidStream 原裸 return=整任务静默终止(保文 + // AiCompleted(incomplete) 无停靠语义,托盘零感知)。对齐 Fatal 路径补 + // AiTaskParked + NeedAction 通知。teardown/save/guard 复位已由 + // stream_relay 保文路径完成(stop_reason="network"),此处不重复 save。 + crate::commands::ai::emit_task_parked( + &app_handle, &conv_id, + "响应流中途断开,已接收的部分内容已保存;任务安全停靠,重发消息可继续", + "midstream", + ); + return; + } + RoundOutcome::Fatal { error } => { + // P2-4:error 已由 stream_round emit 错误气泡,此处仅留诊断日志 + // (真实原因不再只进子会话事件面,排查有据可查)。 + consecutive_round_failures += 1; + if consecutive_round_failures > ROUND_REENTRY_MAX { + // 连续重入预算耗尽:转停靠(网络故障已超出轮间退避可覆盖范围)。 + tracing::warn!( + conv_id = %conv_id, + failures = consecutive_round_failures, + error = %error, + "[ai] 连续 {} 轮失败,重入预算耗尽,任务停靠", + consecutive_round_failures, + ); + crate::commands::ai::emit_task_parked( + &app_handle, &conv_id, + &format!("模型调用连续 {} 轮失败(重入退避后仍未恢复),任务安全停靠:{}", consecutive_round_failures, error), + "fatal", + ); + return; + } + let delay = ROUND_REENTRY_BACKOFF_SECS[consecutive_round_failures - 1]; + tracing::warn!( + conv_id = %conv_id, + attempt = consecutive_round_failures, + max_attempts = ROUND_REENTRY_MAX, + delay_secs = delay, + error = %error, + "[ai] 本轮失败,{}s 后从当前轮重入({}/{}): {}", + delay, consecutive_round_failures, ROUND_REENTRY_MAX, error, + ); + tokio::time::sleep(std::time::Duration::from_secs(delay)).await; + // 退避后不显式查 stop/epoch:stream_round 内部闸门已兜 + // (stop_flag→停止路径由 finalize 收尾、epoch 后校验→StaleAfterStream), + // 重入即被正确接管,无需重复判定。 + continue 'stream_round; + } } - RoundOutcome::StaleAfterStream => return, - RoundOutcome::MidStreamSaved => return, - RoundOutcome::Fatal => return, }; // global/per_conv permit 已上移 loop 入口整 loop 持有, // stream 后不再立即释放(会话级并发语义:工具执行期间也占槽)。per-provider permit @@ -1110,6 +1197,7 @@ pub(crate) async fn run_agentic_loop( &full_text, &tool_calls_acc, &round_usage, round_is_estimated, &last_reasoning_content, &mut goal_verify_rounds, + round_truncated, ).await { // RunTools → 继续工具调用段(工具调用数由该段内部取,与原时序一致) RoundEpilogue::RunTools => {} @@ -1182,6 +1270,14 @@ pub(crate) async fn run_agentic_loop( &loop_epoch_arc, my_epoch, false, ).await; + // 安全停靠(设计 §四):预算(轮次)耗尽=五路非正常终局之一。teardown 已完成 + // save + generating 复位 + AiCompleted(incomplete)+ NeedAction 通知(5s 节流 + // 与停靠通知同组合并不会刷屏),此处补结构化 AiTaskParked 供前端操作卡消费。 + crate::commands::ai::emit_task_parked( + &app_handle, &conv_id, + &format!("已达最大轮次({}),任务安全停靠:未完成事项已随会话保存,点继续或重发消息可追加授权续跑", max_iterations), + "max_rounds", + ); return; } @@ -1661,6 +1757,8 @@ async fn finalize_round_output( round_is_estimated: bool, last_reasoning_content: &Option, goal_verify_rounds: &mut u32, + // 本轮流因 provider max_tokens 上限截断(P2-1),收敛时据实提示不冒充完整 + round_truncated_by_max_tokens: bool, ) -> RoundEpilogue { // 累加本轮 token:用 provider 真实 usage。GLM 等流式不报 completion_tokens 时为 0,如实反映(不预估)。 // 分项 token(2026-08-02):cache/reasoning 透传 round。三真相源收口:prompt 估算兜底 + @@ -1773,6 +1871,20 @@ async fn finalize_round_output( ); } // 正常收敛(loop 主体置 converged 后 break) + // P2-1 主会话侧最小修复:收敛文本若被 max_tokens 腰斩,追加系统提示消息 + // (对齐 MidStream 保文的系统提示机制),用户/下轮请求能看见截断而非当完整回复。 + if round_truncated_by_max_tokens { + let mut session = session_arc.lock().await; + if session.per_conv.contains_key(conv_id) { + let conv = session.conv(conv_id); + let mut notice = ChatMessage::system( + "⚠ 回复因 max_tokens 上限被截断,以上内容可能不完整;可要求模型继续输出。", + ); + notice.model = Some(resolved_model.to_string()); + conv.messages.push(notice); + } + tracing::warn!(conv_id = %conv_id, "[ai] 收敛回复因 max_tokens 截断,已追加系统提示"); + } return RoundEpilogue::Converged; } } @@ -1817,7 +1929,7 @@ async fn process_round_tools( // AC-2 ①:记录本轮工具调用数(供超限警告判定;process_tool_calls 会 move 走 tool_calls_acc)。 let round_tool_call_count = tool_calls_acc.len(); // 处理工具调用(Low 自动执行 / Medium+High 待审批;主对话恒 Normal 策略,子循环走 SubAgent) - let pending_count = process_tool_calls(session_arc, tool_calls_acc, tools_arc, db, app_handle, conv_id, ToolExecPolicy::Normal).await; + let pending_count = process_tool_calls(session_arc, tool_calls_acc, tools_arc, db, app_handle, conv_id, ToolExecPolicy::Normal, None).await; // DIRAUTH 审批链已闭环。原 eprintln 诊断降级为 tracing::debug, // 避免污染 stderr(用户可见),保留排障能力(RUST_LOG=debug 可见)。 tracing::debug!( @@ -1864,6 +1976,13 @@ async fn process_round_tools( return ToolsOutcome::Exit; } emit_circuit_breaker_tripped(app_handle, conv_id, max_count, &sample_key); + // 安全停靠(设计 §四):熔断即终局,补发 AiTaskParked(原状只 emit 求助卡, + // 托盘场景无独立事件;guard.reset 已在上方完成,现场已 save 于前一轮)。 + crate::commands::ai::emit_task_parked( + app_handle, conv_id, + &format!("连续同类失败 {} 次已熔断,任务安全停靠;已完成进度已保存,调整思路后重发消息可继续", max_count), + "circuit_breaker", + ); return ToolsOutcome::Exit; } } @@ -1881,6 +2000,12 @@ async fn process_round_tools( *loop_warned = new_loop_warned; if tripped { guard.reset().await; + // 安全停靠(设计 §四):探索熔断从「等用户」改「存档+通知」,语义对齐五路统一出口。 + crate::commands::ai::emit_task_parked( + app_handle, conv_id, + "探索熔断(连续无进展的重复探索),任务安全停靠;已完成进度已保存,补充新方向后重发消息可继续", + "stall_breaker", + ); return ToolsOutcome::Exit; } } @@ -2309,13 +2434,47 @@ async fn insert_tool_call_over_limit_warning( .await; } +// ── classify_permanent_provider_error: 可判定的永久性 provider 错误识别(词法,仅此一处) ── +// 命中 → ProviderConfig(前端隐藏「重试」改显「去设置」CTA,重试必再败不再循环); +// 未命中维持 Network(现状口径,可重试)。只认高置信特征,宁漏勿误(误判会把可恢复错误 +// 锁死成「不可重试」)。输入是 fmt_diag 聚合诊断([provider] 场景(status): body), +// 关键词对 provider body 原文匹配,大小写不敏感。 +fn classify_permanent_provider_error(error: &str) -> bool { + let lower = error.to_lowercase(); + // 模型不存在: "model is not found" / "The model `x` does not exist" / 中文「模型不存在」 + let model_missing = lower.contains("model") + && (lower.contains("not found") + || lower.contains("not_found") + || lower.contains("does not exist") + || lower.contains("不存在")); + // 令牌无权访问模型: 「该令牌无权访问模型」/ no permission / access denied + let no_access = lower.contains("无权") + || lower.contains("无权限") + || lower.contains("no permission") + || lower.contains("access denied") + || lower.contains("permission denied"); + // 参数拒绝类 400: sensenova "field Temperature invalid" 实锤 / OpenAI invalid_request_error + let param_reject = lower.contains("invalid_request_error") + || ((lower.contains("invalid") || lower.contains("非法") || lower.contains("无效")) + && (lower.contains("temperature") + || lower.contains("parameter") + || lower.contains("field") + || lower.contains("request"))); + model_missing || no_access || param_reject +} + // ── emit_fatal_error: 致命错误统一 emit AiError(扁平抽自原 Fatal/Exhausted 双分支重复代码) ── -// 双写经 emit_ai_chat_event。error_type=Network 对齐原 Fatal 分支(鉴权/4xx归类); -// 两端统一 Network(原 publish 端漏传 None 属漂移,2026-10-01 顺带修)。 +// 双写经 emit_ai_chat_event。error_type: 可判定的永久性 provider 错误(模型不存在/令牌无权/ +// 参数拒绝类 400)归 ProviderConfig,其余维持 Network 现状(2026-10-01 两端统一时的口径)。 pub(crate) async fn emit_fatal_error(app_handle: &AppHandle, conv_id: &str, error: &str) { + let error_type = if classify_permanent_provider_error(error) { + ErrorType::ProviderConfig + } else { + ErrorType::Network + }; emit_ai_chat_event(app_handle, AiChatEvent::AiError { error: error.to_string(), - error_type: Some(ErrorType::Network), + error_type: Some(error_type), conversation_id: Some(conv_id.to_string()), }); // P1 bg 化:失败系统通知(条件发送,失败静默)。正文截断防长堆栈撑爆系统气泡。 @@ -2584,8 +2743,9 @@ async fn build_runtime_prompt( .collect(); if !lines.is_empty() { system_prompt = format!( - "{}\n\n## 可用技能\n以下为本机 Claude 技能/命令(名称: 描述)。用户请求命中某技能用途时,提示用户输入 /技能名 触发,或引导其按需使用:\n{}", + "{}\n\n## 可用技能\n以下为本机 Claude 技能/命令(名称: 描述,已去重,上限 {} 条)。**自动触发规则**:用户请求(含普通任务指令)与某技能用途匹配时,你应先调 read_skill 工具读取该技能规程全文,按其流程直接执行,无需等待用户显式输入 /技能名;仅当技能涉及高风险操作或不确定用户意图时,才提示用户输入 /技能名 确认。未列出的技能也可用 read_skill 按名查询:\n{}", system_prompt, + SKILL_LIST_MAX_ITEMS, lines.join("\n") ); tracing::debug!( @@ -2974,3 +3134,24 @@ mod panic_guard_tests { assert!(dropped.load(std::sync::atomic::Ordering::SeqCst), "unwind 时 RAII Drop 应执行"); } } + +// 跨轮任务级重入(深查 P1-4)退避调度契约:重入循环按 `BACKOFF_SECS[failures - 1]` 取值, +// 表长必须覆盖 ROUND_REENTRY_MAX(越界 panic 面)且单调递增(指数退避语义)。 +#[cfg(test)] +mod round_reentry_tests { + use super::{ROUND_REENTRY_BACKOFF_SECS, ROUND_REENTRY_MAX}; + + #[test] + fn backoff_schedule_covers_max_attempts_and_ascending() { + assert_eq!( + ROUND_REENTRY_BACKOFF_SECS.len(), + ROUND_REENTRY_MAX, + "退避表长须覆盖重入上限(取值索引 failures-1 不越界)", + ); + for w in ROUND_REENTRY_BACKOFF_SECS.windows(2) { + assert!(w[0] < w[1], "退避须单调递增(指数退避语义): {:?}", w); + } + // 深查处方锚定:1min/5min/15min,覆盖 WiFi 切换/VPN 重连/上游限流窗口量级 + assert_eq!(ROUND_REENTRY_BACKOFF_SECS, [60, 300, 900]); + } +} diff --git a/src-tauri/src/commands/ai/audit/mod.rs b/src-tauri/src/commands/ai/audit/mod.rs index 18145e3..772faff 100644 --- a/src-tauri/src/commands/ai/audit/mod.rs +++ b/src-tauri/src/commands/ai/audit/mod.rs @@ -31,7 +31,7 @@ mod diff; // 第六批从本文件抽离,行为零变更。pub(super) use 供 tests 子模块引用 + process_tool_calls 裸名调用。 // 模块提为 pub(crate):state 层 allowed_dirs 的 is_authorized 第三道门需直接读放行标志,私有模块路径段不可穿越 pub(crate) mod path_auth; -pub(super) use path_auth::{check_file_tool_auth, FileToolAuthOutcome, PATH_AUTH_BYPASS_KEY}; +pub(super) use path_auth::{check_file_tool_auth, FileToolAuthOutcome, PathBypass, PATH_AUTH_BYPASS_KEY}; // 黑名单放行判据:tool_registry 的 validate_path 无 bypass 参数,经此跨模块读进程级标志 pub(super) use path_auth::is_blacklist_bypassed; // 测试专用:改写标志验证 bypass 放行语义 + 串行闸,防依赖标志结果的测试并行互踩 @@ -78,7 +78,7 @@ pub(crate) use finalize::audit_finalize; // cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。 // 第三批从本文件抽离,行为零变更。 mod cache; -pub(super) use cache::{cache_hit_warning, detect_listing_bypass, find_cached_readonly_result, insert_listing_bypass_warning, pending_placeholder_for, readonly_cache_args_key}; +pub(super) use cache::{cache_hit_warning, detect_listing_bypass, find_cached_readonly_result, insert_listing_bypass_warning, mark_readonly_cache_stale, pending_placeholder_for, readonly_cache_args_key}; // data_change(audit/data_change.rs):AR-11 数据变更联动刷新。 // 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见 @@ -166,8 +166,69 @@ async fn deny_sub_agent_tool( "rejected", risk_level, Some(msg), Some("sub_agent_denied"), current_message_id).await; } +/// 任务边界拒绝单点(无人值守,设计 §三):结构化失败 tool_result + emit Completed + 审计留痕。 +/// 不 insert pending_approvals、不 emit AiApprovalRequired/AiDirAuthRequired——边界会话 +/// 零新挂起(P1-1 治本面:挂起等输入是无人值守的主要敌人),LLM 从 envelope 的 +/// denial_layer + 行动提示自纠(换路径/拆低风险步骤/写入报告未尽事项),fail-closed +/// 同构子代理拒绝协议。decided_by 与 denial_layer 同值留痕(boundary-dirs/boundary-risk, +/// 设计 §3.2:事后全量追溯)。 +async fn deny_boundary_tool( + session_arc: &Arc>, + conv_id: &str, + draft: &ToolCallDraft, + tools_arc: &Arc, + audit_repo: &AiToolExecutionRepo, + layer: &str, + reason: &str, + current_message_id: Option<&str>, + app_handle: &AppHandle, +) { + // 各拒绝层的可改尝试方向(行动提示,让 LLM 下一步有路可走) + let hint = match layer { + "boundary-dirs" => "该路径不在本任务目录白名单内,改用白名单内路径完成等价操作,或在报告未尽事项中说明", + _ => "该工具风险超出本任务风险上限,拆解为上限内的低风险步骤,或在报告未尽事项中说明由用户后续处理", + }; + let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); + let msg = serde_json::json!({ + "status": "rejected", + "reason": "boundary_exceeded", + "tool_name": draft.name, + "risk_level": risk_level, + "denial_layer": layer, + "message": format!("任务边界外操作已拒绝(无人值守模式零打断):{}。{}", reason, hint) + }).to_string(); + // 短 lock 段:push tool_result(纯写,无 await) + { + let __lock_t = std::time::Instant::now(); + let mut session = session_arc.lock().await; + session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &msg)); + log_slow_lock(__lock_t, "process_tool_calls:deny_boundary"); + } + let ev = AiChatEvent::AiToolCallCompleted { + id: draft.id.clone(), + result: serde_json::Value::String(msg.clone()), + conversation_id: Some(conv_id.to_string()), + }; + emit_ai_chat_event(app_handle, ev); + audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, + "rejected", risk_level, Some(msg), Some(layer), current_message_id).await; +} + +/// P2-1 执行期白名单判定(纯函数可单测):SubAgent 策略下工具是否被白名单裁掉。 +/// spawn 入口的白名单钳制只裁工具定义面(LLM 可见 tools 列表),拦不住幻觉调用面外工具; +/// 此判定让裁剪在执行期同样生效。allowed=None(主会话调用方)恒放行,行为与历史版本一致。 +fn sub_agent_tool_clamped(allowed: Option<&[String]>, tool_name: &str) -> bool { + match allowed { + None => false, + Some(list) => !list.iter().any(|n| n == tool_name), + } +} + /// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控 /// 返回待审批的工具数量(0 = 全部自动执行完成) +/// +/// `allowed`(P2-1):SubAgent 策略的执行期工具白名单(spawn 入口钳制产物)。 +/// Low 风险工具不在名单同样就地拒绝(layer=whitelist-clamp);主会话调用方传 None,语义不变。 /// 工具执行 + 心跳保活:execute 期间每 30s emit AiHeartbeat(对齐 stream_recv.rs /// stream_llm select! 心跳语义),execute 完 abort 心跳 task。 /// @@ -481,6 +542,33 @@ async fn cancel_expired_approval( .await; // 5. 落库会话状态变更(占位消息替换后需持久化,防重启后丢失) super::conversation::save_conversation(&state.ai_session, &state.db, conv_id, None, None, true).await; + // 6. P1-1 修:清 generating 幽灵态。挂起时主 loop 已 return 且 guard.disarm() + // (保持 Generating 等审批恢复),超时取消后无人复位 → conv_state 恒 Generating, + // 会话永久卡生成态(消息发不进,can_accept_request=false)。迁回 Idle + emit 状态事件, + // 写法对齐 ai_chat_stop 审批分支(transition 非法仅 warn 不阻断停靠)。 + if let Err(e) = state + .conv_states + .transition(conv_id, crate::commands::ai::agentic::conv_state::ConvState::Idle) + { + tracing::warn!( + conv_id = %conv_id, + error = %e, + "[ai] 审批超时取消 conv_states→Idle 迁移非法(不阻断停靠事件)" + ); + } + // 7. 安全停靠(设计 §四):审批超时取消属非正常终局,补发 AiTaskParked + NeedAction + // 通知。原状只取消不复跑不留痕——用户(尤其托盘场景)对任务已死毫不知情。 + // 不自动续跑(设计拍板点 5:恢复须可人工确认);恢复入口=既有重发/ai_chat_force_send。 + let (park_reason, park_kind) = approval_timeout_park_parts(timeout_minutes); + super::emit_task_parked(app, conv_id, &park_reason, park_kind); +} + +/// 审批超时停靠文案构造(纯函数,单测锚点;stop_kind 复用 AiTaskParked 词表)。 +pub(crate) fn approval_timeout_park_parts(timeout_minutes: u64) -> (String, &'static str) { + ( + format!("工具审批超时({}分钟)自动取消,任务已安全停靠:被拒操作已回执给 AI,重发消息或补充指令即可继续", timeout_minutes), + "approval_timeout", + ) } pub(crate) async fn process_tool_calls( @@ -491,6 +579,7 @@ pub(crate) async fn process_tool_calls( app_handle: &AppHandle, conv_id: &str, policy: ToolExecPolicy, + allowed: Option<&[String]>, ) -> usize { // 策略快照(SubAgent:须人工介入的路径全部就地拒绝,见 ToolExecPolicy 文档) let is_sub_agent = matches!(policy, ToolExecPolicy::SubAgent); @@ -598,6 +687,18 @@ pub(crate) async fn process_tool_calls( .await .unwrap_or_else(|| "low".to_string()); + // ── 无人值守任务边界快照(设计 §三判定链输入)── + // per_conv.boundary 即会话级单真相源,None=未声明:下方两处插桩分支全部跳过, + // auto_exec_mode/trust/审批/路径授权全链路现状零变更(渐进增强零倒退)。 + // 边界优先于全局三档:有边界会话上限内零打断,超上限结构化拒绝(不挂起不弹卡)。 + let boundary: Option = { + let __lock_t = std::time::Instant::now(); + let session = session_arc.lock().await; + let b = session.conv_read(conv_id).and_then(|c| c.boundary.clone()); + log_slow_lock(__lock_t, "process_tool_calls:boundary_snapshot"); + b + }; + // ── 文件工具路径授权预校验 ── // 在 RiskLevel 分类前,对文件工具(read/write/list/patch/info/append/delete/rename/search) // 逐条预校验路径授权(persistent + 会话 session_allowed_dirs + 黑名单): @@ -623,6 +724,14 @@ pub(crate) async fn process_tool_calls( // 同步进程级黑名单放行标志:validate_path(tool_registry)无 bypass 参数,靠此标志放行黑名单; // 每轮工具执行前刷新,与下方 check_file_tool_auth 的入参 bypass 同源同值,两处拦截点语义一致。 path_auth::set_blacklist_bypass(path_auth_bypass); + // P3-4 bypass 按策略分流:主会话=All(体验优先,黑名单一并放行,产品拍板); + // SubAgent=KeepBlacklist——bypass 只豁免白名单外挂起,黑名单保持硬拒。子代理无审批 + // 通道也无人监督,黑名单是安全线,不得被体验开关解除。关=Off 两策略行为一致。 + let path_bypass = if path_auth_bypass { + if is_sub_agent { PathBypass::KeepBlacklist } else { PathBypass::All } + } else { + PathBypass::Off + }; // 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环) let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new(); let mut path_denied: Vec<(ToolCallDraft, String)> = Vec::new(); @@ -630,11 +739,13 @@ pub(crate) async fn process_tool_calls( // 第三元=denial_layer:needs-auth(待授权)与 path-blacklist(系统黑名单)分开标注—— // 黑名单主会话也放不了行,混标会误导子 LLM 走授权通道。 let mut sub_agent_path_denied: Vec<(ToolCallDraft, String, &'static str)> = Vec::new(); + // 任务边界会话:白名单外待授权目录的拒绝收集(结构化拒绝,不挂起,见下方处理) + let mut boundary_dir_denied: Vec<(ToolCallDraft, String)> = Vec::new(); let mut authorized_drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = Vec::new(); for (idx, draft, args) in drafts { // 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空), // handler 闭包 read lock 与此处预校验读同一字段,两端授权判定一致。 - match check_file_tool_auth(&draft.name, &args, &allowed_guard, path_auth_bypass) { + match check_file_tool_auth(&draft.name, &args, &allowed_guard, path_bypass) { FileToolAuthOutcome::Authorized => authorized_drafts.push((idx, draft, args)), FileToolAuthOutcome::NeedsAuth(req) => { // SubAgent 策略:子代理无授权弹窗通道,挂起即永久停摆 → 就地拒绝。 @@ -642,6 +753,21 @@ pub(crate) async fn process_tool_calls( sub_agent_path_denied.push((draft, "路径未授权且子代理无授权弹窗通道".to_string(), "needs-auth")); continue; } + // 任务边界优先(无人值守,设计 §3.1②):req.dirs 是三档白名单外的待授权 + // 父目录集合,边界白名单全覆盖 → 直接放行(免弹 AiDirAuthRequired); + // 未全覆盖 → 结构化拒绝(boundary-dirs),不挂起——边界会话零新挂起。 + if let Some(b) = &boundary { + if crate::commands::ai::boundary::covers_dirs(b, &req.dirs) { + authorized_drafts.push((idx, draft, args)); + } else { + let uncovered: Vec = req.dirs.iter() + .filter(|d| !crate::commands::ai::boundary::dir_covered(&b.dir_allowlist, d)) + .map(|d| d.to_string_lossy().to_string()) + .collect(); + boundary_dir_denied.push((draft, format!("待授权目录 {} 不在任务白名单内", uncovered.join(", ")))); + } + continue; + } // all 档全自动(用户拍板):路径授权弹窗也不打断,与 RiskLevel 门同语义。 // 黑名单 Denied 不受影响——系统敏感目录是绝对安全线,全接管也不放行。 // 放行的 draft 走 authorized_drafts → RiskLevel 分类 → all 放行 → 审计留痕闭环。 @@ -702,6 +828,15 @@ pub(crate) async fn process_tool_calls( ).await; } + // 任务边界会话:白名单外路径结构化拒绝(boundary-dirs),零挂起零弹卡。 + // LLM 拿 envelope 换路径或写入报告未尽事项;审计 decided_by=boundary-dirs 留痕。 + for (draft, reason) in boundary_dir_denied { + deny_boundary_tool( + session_arc, conv_id, &draft, tools_arc, &audit_repo, + "boundary-dirs", &reason, current_message_id, app_handle, + ).await; + } + // Phase B: NeedsAuth 路径 → 挂起 loop(insert pending + emit AiDirAuthRequired + push 占位 tool_result)。 // 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。 // pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。 @@ -811,6 +946,12 @@ pub(crate) async fn process_tool_calls( // trust_hits 收集:AE-04 会话信任命中(同会话已批准同类操作),真实执行但移到锁外 spawn // (对齐 Low risk 不持锁模式)。命中时此处只 emit toast + 收集,不 .await execute。 let mut trust_hits: Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)> = Vec::new(); + // boundary_auto 收集(无人值守,设计 §3.1③):边界会话上限内非破坏性工具,归入独立 + // 并行执行向量(join_all,与 trust_hits 同款执行+审计链)。不并入 low_risk 向量的原因: + // low_risk 回填的 decided_by 按风险等级映射(auto/auto_takeover_*),边界放行的来源留痕 + // 须为 boundary_auto(审计可追溯授权来自任务边界而非全局档),且不动共享路径签名 + // (无边界会话零倒退优先)。 + let mut boundary_auto: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::new(); for (_, draft, args) in drafts { let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); // SubAgent 策略:Medium/High 就地拒绝(无审批通道,挂起即子循环永久停摆)。 @@ -825,6 +966,39 @@ pub(crate) async fn process_tool_calls( ).await; continue; } + // P2-1 执行期白名单:Low 风险工具不在 spawn 白名单同样就地拒绝(定义面裁剪 + // 拦不住幻觉调用)。layer 恒 whitelist-clamp——是名单问题非风险问题,行动提示 + // 指向改用名单内工具。主会话 allowed=None 判定恒 false,零行为变更。 + if is_sub_agent && sub_agent_tool_clamped(allowed, &draft.name) { + deny_sub_agent_tool( + session_arc, conv_id, &draft, tools_arc, &audit_repo, + "whitelist-clamp", "工具不在 spawn 白名单内", current_message_id, app_handle, + ).await; + continue; + } + // 任务边界判定(无人值守,设计 §3.1③④;SubAgent 策略不受影响维持 fail-closed: + // 子代理 per_conv 新建恒无边界,此处再 gate 一道,防误设边界放宽子代理——拍板点 6): + // 上限内 ∧ 非破坏性命令 → boundary_auto 并行执行向量(零审批零挂起); + // 超上限(含 High、破坏性命令)→ 结构化拒绝(boundary-risk),不 insert pending。 + // 黑名单恒拒不在此层——Denied 分支在 path_auth 预校验先行硬拒,边界永不参与。 + if !is_sub_agent { + if let Some(b) = &boundary { + if crate::commands::ai::boundary::risk_allowed(b, &draft.name, &args, risk_level) { + boundary_auto.push((draft, args, risk_level)); + } else { + let reason = if risk_level == RiskLevel::High { + "工具风险 High 超出任务风险上限".to_string() + } else { + "工具风险超出任务风险上限".to_string() + }; + deny_boundary_tool( + session_arc, conv_id, &draft, tools_arc, &audit_repo, + "boundary-risk", &reason, current_message_id, app_handle, + ).await; + } + continue; + } + } // AC-2 ③:run_command 目录列举绕行检测(最保守版,只警告不硬拒)。 // 实证 09e7abfa:list_directory 后 LLM 用 run_command Get-ChildItem 再探同目录, // 绕过 ② 的目录级去重。检测到绕行 → insert 一条 system 警告(非静默),命令照常 @@ -893,6 +1067,7 @@ pub(crate) async fn process_tool_calls( } })).await; + // 串行回填 tool_result + 审计(短 lock push + 锁外 audit) for (draft, risk_level, outcome) in results { let (status, content) = match outcome { @@ -914,6 +1089,12 @@ pub(crate) async fn process_tool_calls( content.clone() }; session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &msg_content)); + // 写后失效:成功完成的非只读工具改变了世界状态,推进缓存失效下界 + // (floor 之前的历史只读结果不可信,find_cached_readonly_result 过滤)。仅 + // completed 才失效——failed 未改世界(命令失败即无副作用面),不误伤。 + if status == "completed" { + mark_readonly_cache_stale(&mut session.conv(conv_id), &draft.name); + } } // 锁外:审计(trust 放行仍记一条,decided_by=auto_trust),留痕可追溯 audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some("auto_trust"), current_message_id).await; @@ -1067,6 +1248,12 @@ pub(crate) async fn process_tool_calls( content.clone() }; session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &msg_content)); + // 写后失效:成功完成的非只读工具改变了世界状态,推进缓存失效下界 + // (floor 之前的历史只读结果不可信,find_cached_readonly_result 过滤)。仅 + // completed 才失效——failed 未改世界(命令失败即无副作用面),不误伤。 + if status == "completed" { + mark_readonly_cache_stale(&mut session.conv(conv_id), &draft.name); + } } // F-#97 审计留痕:low_risk 向量中 risk_level != Low 即 mode 放行(Med 仅 medium/all、High 仅 all 才进)。 // decided_by 区分接管来源,审计表可追溯 all 模式执行了多少高危命令(治 securityReview blocker: @@ -1135,32 +1322,98 @@ pub(crate) async fn process_tool_calls( session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &warned_content)); } dupe_audit_records.push(build_audit_record( - conv_id, &draft.id, &draft.name, &draft.args, "completed", risk_level, - Some(warned_content), Some("cache_hit"), current_message_id, + conv_id, &draft.id, &draft.name, &draft.args, + "completed", risk_level, Some(warned_content), Some("cache_hit"), + current_message_id, )); - tracing::info!( - conv_id = %conv_id, - tool = %draft.name, - tc_id = %draft.id, - hit_count = new_count, - "[ai] 只读工具轮内去重命中(第 {} 次同参,本轮首个已执行),跳过重复真执行", - new_count - ); } if !dupe_audit_records.is_empty() { - let dup_len = dupe_audit_records.len(); + let batch_len = dupe_audit_records.len(); if let Err(e) = audit_repo.insert_batch(dupe_audit_records).await { tracing::error!( conv_id = %conv_id, - batch = dup_len, - "[ai] insert_batch 轮内去重审计批量写失败(已回滚,共 {} 条): {}", - dup_len, + batch = batch_len, + "[ai] insert_batch(轮内重复)批量写审计记录失败(已回滚,共 {} 条): {}", + batch_len, e ); } } } + // boundary_auto 并行执行(无人值守,设计 §3.1③):与 low_risk 同构——execute + emit 在 + // 闭包内(锁外),push/audit 串行回填;区别仅审计 decided_by=boundary_auto + // (授权来自任务边界,事后全量可追溯),emit_data_changed 同步联动(数据变更类)。 + // execute/emit 全在闭包内不持锁,push tool_result / audit 在 join_all 后串行回填。 + if !boundary_auto.is_empty() { + tracing::info!( + conv_id = %conv_id, + count = boundary_auto.len(), + "[ai] 任务边界放行 {} 个工具进入并行执行(decided_by=boundary_auto)", + boundary_auto.len() + ); + let results: Vec<(ToolCallDraft, RiskLevel, Result)> = + futures::future::join_all(boundary_auto.into_iter().map(|(draft, args, risk_level)| { + let tools = tools_arc.clone(); + let app_clone = app_handle.clone(); + let conv_clone = conv_id.to_string(); + async move { + match execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone, &draft.id).await { + Ok(val) => { + // L3 emit 双写:边界放行工具执行成功 emit Completed 双路发布。 + let ev = AiChatEvent::AiToolCallCompleted { + id: draft.id.clone(), + result: val.clone(), + conversation_id: Some(conv_clone), + }; + emit_ai_chat_event(&app_clone, ev); + emit_data_changed(&app_clone, &draft.name); + (draft, risk_level, Ok(val.to_string())) + } + Err(e) => { + // AR-6 同款:失败包进 tool_result 让 LLM 自纠,不 emit AiError 不断轮。 + let err_msg = format!("工具 {} 执行失败: {}", draft.name, e); + let ev = AiChatEvent::AiToolCallCompleted { + id: draft.id.clone(), + result: serde_json::Value::String(err_msg.clone()), + conversation_id: Some(conv_clone), + }; + emit_ai_chat_event(&app_clone, ev); + (draft, risk_level, Err(err_msg)) + } + } + } + })).await; + + for (draft, risk_level, outcome) in results { + let (status, content) = match outcome { + Ok(c) => ("completed", c), + Err(c) => ("failed", c), + }; + // 短 lock 段:namespace T2 + push tool_result(纯写,无 await) + { + let mut session = session_arc.lock().await; + let msg_content = if status == "failed" { + serde_json::json!({"status":"failed","error":&content}).to_string() + } else if df_ai::namespace_store::should_use_namespace(&content, &draft.name) { + session.namespace_store.store(&draft.name, &content) + } else { + content.clone() + }; + session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &msg_content)); + // 写后失效:边界放行的非只读工具成功同 Low 段推进 floor(口径一致)。 + if status == "completed" { + mark_readonly_cache_stale(&mut session.conv(conv_id), &draft.name); + } + } + // 锁外审计:risk 真实等级透传(F-#97 防失真),decided_by=boundary_auto + // 标注授权来自任务边界(无人值守事后可全量追溯)。 + audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, + status, risk_level, Some(content), Some("boundary_auto"), current_message_id).await; + } + } + + // 单表 pending_approvals 统计 pending 总数(path/risk 合一,kind 区分)。 // 短 lock 段:读 pending_approvals 统计(纯读,无 await) let (path_count, risk_count, pending_approvals_len) = { @@ -1195,7 +1448,7 @@ mod tests { let allowed = crate::state::AllowedDirs::default(); let args = serde_json::json!({ "pattern": "foo", "path": "C:/some/unauthorized/dir" }); - let outcome = check_file_tool_auth("grep", &args, &allowed, false); + let outcome = check_file_tool_auth("grep", &args, &allowed, PathBypass::Off); match outcome { FileToolAuthOutcome::NeedsAuth(_) => { /* 期望:grep 触发授权申请 */ } FileToolAuthOutcome::Authorized => panic!("grep 未授权应返 NeedsAuth(申请授权),实际 Authorized"), @@ -1203,7 +1456,7 @@ mod tests { } // 对照:search_files 同样未授权但被硬拒(Denied),锁定两工具差异 - let search_outcome = check_file_tool_auth("search_files", &args, &allowed, false); + let search_outcome = check_file_tool_auth("search_files", &args, &allowed, PathBypass::Off); match search_outcome { FileToolAuthOutcome::Denied(_) => { /* 期望:search_files 盲拒 */ } FileToolAuthOutcome::NeedsAuth(_) => panic!("search_files 未授权应返 Denied(盲拒),实际 NeedsAuth"), @@ -1219,7 +1472,7 @@ mod tests { let allowed = crate::state::AllowedDirs::default_with_root(); // .ssh 命中系统黑名单(is_in_system_blacklist) let args = serde_json::json!({ "pattern": "foo", "path": "/home/user/.ssh/config" }); - let outcome = check_file_tool_auth("grep", &args, &allowed, false); + let outcome = check_file_tool_auth("grep", &args, &allowed, PathBypass::Off); match outcome { FileToolAuthOutcome::Denied(_) => { /* 期望:黑名单硬拒 */ } FileToolAuthOutcome::NeedsAuth(_) => panic!("grep 黑名单路径应返 Denied,实际 NeedsAuth"), @@ -1234,4 +1487,39 @@ mod tests { let paths = extract_file_tool_paths("grep", &args); assert_eq!(paths, vec!["src/main.rs".to_string()], "grep 应取 path 参数(忽略 glob 等其他参数)"); } + + /// P2-1 执行期白名单判定真值表:SubAgent 名单外工具(含已注册 Low)被裁,名单内放行, + /// 主会话 None 恒放行(历史行为不变)。与 spawn 入口 clamp_tool_whitelist 组成两道闸; + /// 判定命中后的拒绝落地走既有 deny_sub_agent_tool 单点(layer=whitelist-clamp, + /// envelope 结构与被拒收集由 sub_agent.rs 测试锁定),此处锁判定本身。 + #[test] + fn sub_agent_tool_clamped_truth_table() { + let allowed = vec!["read_file".to_string(), "grep".to_string()]; + assert!(!sub_agent_tool_clamped(Some(&allowed), "read_file"), "名单内放行"); + assert!( + sub_agent_tool_clamped(Some(&allowed), "list_tasks"), + "名单外已注册 Low 工具同样被裁(定义面裁剪拦不住的幻觉调用残面)" + ); + assert!( + sub_agent_tool_clamped(Some(&allowed), "spawn_agent"), + "spawn_agent 恒被入口钳制剔除,执行期也不得放行(禁递归)" + ); + assert!(!sub_agent_tool_clamped(None, "run_command"), "主会话 None 恒放行"); + assert!( + sub_agent_tool_clamped(Some(&[]), "read_file"), + "空白名单全拒(白名单为空的子代理无可执行工具)" + ); + } + + /// 审批超时停靠文案(stop_kind=approval_timeout):锁定 P1-1 修复的停靠出口字段, + /// cancel_expired_approval 到点路径消费(发 AiTaskParked + NeedAction 通知)。 + #[test] + fn approval_timeout_park_parts_contract() { + let (reason, kind) = approval_timeout_park_parts(15); + assert_eq!(kind, "approval_timeout", "stop_kind 词表(前端契约)"); + assert!(reason.contains("15"), "文案携带超时分钟数"); + assert!(reason.contains("停靠"), "文案含停靠语义(待办清单=stop_reason)"); + let (reason_0, _) = approval_timeout_park_parts(0); + assert!(reason_0.contains("0"), "任意分钟数可格式化(0=禁用超时不会到点,防御)"); + } } diff --git a/src-tauri/src/commands/ai/audit/path_auth.rs b/src-tauri/src/commands/ai/audit/path_auth.rs index 5cbc9b0..d70c840 100644 --- a/src-tauri/src/commands/ai/audit/path_auth.rs +++ b/src-tauri/src/commands/ai/audit/path_auth.rs @@ -59,22 +59,38 @@ pub(crate) enum FileToolAuthOutcome { Denied(String), } +/// 「信任所有目录」总开关的放行面(P3-4 按策略分流)。 +/// +/// - `Off`:开关关闭(默认),白名单外挂起授权 + 黑名单硬拒,行为与历史版本一致; +/// - `All`:主会话全放行(体验优先,黑名单一并放行,产品拍板); +/// - `KeepBlacklist`:子代理策略——bypass 只豁免白名单外挂起(含 search_files 盲拒), +/// 黑名单保持硬拒。子代理无审批通道也无人监督,黑名单是安全线,不可被体验开关解除。 +/// Copy:每条工具调用循环内按值传入 check_file_tool_auth,单元枚举按位复制免手动 clone。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PathBypass { + Off, + All, + KeepBlacklist, +} + /// 对单条文件工具调用做路径授权预校验,返回是否需挂起/拒绝/放行。 /// -/// - 路径任一命中黑名单且 bypass 关:`Denied(reason)` 硬拒(工具返 Err tool_result,不挂起) +/// - 路径任一命中黑名单且 bypass 非 `All`:`Denied(reason)` 硬拒(工具返 Err tool_result,不挂起) /// - 路径任一未命中白名单(persistent + session_dirs)且非黑名单 → `NeedsAuth`: /// 附带 PathAuthRequest(规范化父目录 + 原始路径列表),供挂起弹窗 /// - 全部已授权 → `Authorized`:走原 Low/Med/High 流程 -/// - `bypass=true`(「信任所有目录」总开关):未授权目录不再产生 NeedsAuth,黑名单 Denied -/// 也一并放行(产品拍板:开关语义为完全放行,含敏感目录,体验优先,search_files 盲拒 -/// 随 NeedsAuth 消失一并解除)。bypass 由调用方 process_tool_calls 读 KV 后传入; -/// validate_path 无此参数才走全局标志 PATH_AUTH_BYPASS_ACTIVE(与入参同源等价, -/// audit 层每轮刷新保证两处拦截点一致)。本函数同步签名,不自取异步 settings。 +/// - `bypass=All`(「信任所有目录」总开关,主会话):未授权目录不再产生 NeedsAuth,黑名单 +/// Denied 也一并放行(产品拍板:开关语义为完全放行,含敏感目录,体验优先,search_files 盲拒 +/// 随 NeedsAuth 消失一并解除)。`bypass=KeepBlacklist`(子代理):只豁免 NeedsAuth, +/// 黑名单硬拒保持。bypass 由调用方 process_tool_calls 读 KV 后按策略分流传入; +/// validate_path 无此参数才走全局标志 PATH_AUTH_BYPASS_ACTIVE(主会话语义,与 All 同源等价, +/// audit 层每轮刷新保证两处拦截点一致;子代理文件工具在预校验层已硬拒,handler 级标志不生效)。 +/// 本函数同步签名,不自取异步 settings。 pub(crate) fn check_file_tool_auth( tool_name: &str, args: &serde_json::Value, allowed: &crate::state::AllowedDirs, - bypass: bool, + bypass: PathBypass, ) -> FileToolAuthOutcome { use crate::state::{check_path_authorization, PathAuthDecision}; let paths = extract_file_tool_paths(tool_name, args); @@ -88,16 +104,17 @@ pub(crate) fn check_file_tool_auth( for p in &paths { match check_path_authorization(p, allowed) { PathAuthDecision::Denied { reason } => { - // 总开关开启时黑名单一并放行(产品拍板:体验优先,用户知情选择);关闭时硬拒不变。 + // 总开关=All 时黑名单一并放行(产品拍板:体验优先,用户知情选择,仅主会话); + // 关闭与 KeepBlacklist(子代理)时硬拒不变——黑名单是安全线。 // 此处用入参 bypass;validate_path 无参才走全局标志,两者同源等价(audit 每轮刷同一 KV)。 - if bypass { + if matches!(bypass, PathBypass::All) { continue; } return FileToolAuthOutcome::Denied(reason); } PathAuthDecision::NeedsAuthorization { dir } => { - // 总开关开启:未授权目录直接放行(黑名单的放行在上一分支处理) - if bypass { + // 总开关开启(All 或 KeepBlacklist):未授权目录直接放行(黑名单的放行仅在 All 分支) + if !matches!(bypass, PathBypass::Off) { continue; } if !pending_dirs.contains(&dir) { @@ -136,17 +153,43 @@ mod tests { let allowed = crate::state::AllowedDirs::default(); let args = serde_json::json!({ "path": "C:/some/unauthorized/dir" }); // 关(基线,行为不变):NeedsAuth - match check_file_tool_auth("read_file", &args, &allowed, false) { + match check_file_tool_auth("read_file", &args, &allowed, PathBypass::Off) { FileToolAuthOutcome::NeedsAuth(_) => {} _ => panic!("bypass 关时未授权路径应仍返 NeedsAuth"), } // 开:直接 Authorized,不挂起 - match check_file_tool_auth("read_file", &args, &allowed, true) { + match check_file_tool_auth("read_file", &args, &allowed, PathBypass::All) { FileToolAuthOutcome::Authorized => {} _ => panic!("bypass 开时未授权路径应直接 Authorized"), } } + /// P3-4 SubAgent 分流:KeepBlacklist 只豁免白名单外挂起(不再弹授权),黑名单保持硬拒 + /// ——子代理无审批通道也无人监督,黑名单安全线不得被体验开关解除。 + #[test] + fn test_bypass_keep_blacklist_passes_needs_auth_but_denies_blacklist() { + let _guard = BLACKLIST_BYPASS_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let allowed = crate::state::AllowedDirs::default_with_root(); + // 白名单外普通路径:KeepBlacklist 直接放行(不挂起授权) + let args = serde_json::json!({ "path": "C:/some/unauthorized/dir" }); + assert!( + matches!( + check_file_tool_auth("read_file", &args, &allowed, PathBypass::KeepBlacklist), + FileToolAuthOutcome::Authorized + ), + "KeepBlacklist 应豁免白名单外挂起" + ); + // 黑名单路径:仍硬拒(与 All 的语义分界) + let secret = serde_json::json!({ "path": "/home/user/.ssh/config" }); + assert!( + matches!( + check_file_tool_auth("read_file", &secret, &allowed, PathBypass::KeepBlacklist), + FileToolAuthOutcome::Denied(_) + ), + "KeepBlacklist 下黑名单应保持硬拒" + ); + } + /// bypass 开:黑名单也放行(语义反转:总开关=完全放行,含敏感目录)。 /// 进程级标志须持串行闸并在断言前复位(先取结果再复位再断言,防 panic 泄漏标志污染并行测试)。 #[test] @@ -157,7 +200,7 @@ mod tests { // .ssh 命中系统黑名单(is_in_system_blacklist) let args = serde_json::json!({ "path": "/home/user/.ssh/config" }); let outcome_allowed = matches!( - check_file_tool_auth("read_file", &args, &allowed, true), + check_file_tool_auth("read_file", &args, &allowed, PathBypass::All), FileToolAuthOutcome::Authorized ); set_blacklist_bypass(false); @@ -171,7 +214,7 @@ mod tests { let _guard = BLACKLIST_BYPASS_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let allowed = crate::state::AllowedDirs::default_with_root(); let args = serde_json::json!({ "path": "/home/user/.ssh/config" }); - match check_file_tool_auth("read_file", &args, &allowed, false) { + match check_file_tool_auth("read_file", &args, &allowed, PathBypass::Off) { FileToolAuthOutcome::Denied(_) => {} _ => panic!("bypass 关时黑名单路径应 Denied(行为不变)"), } diff --git a/src-tauri/src/commands/ai/audit/record.rs b/src-tauri/src/commands/ai/audit/record.rs index 6a9ea44..ff8d336 100644 --- a/src-tauri/src/commands/ai/audit/record.rs +++ b/src-tauri/src/commands/ai/audit/record.rs @@ -296,15 +296,23 @@ fn match_task_audit_row(tool_name: &str, result: Option<&str>, task_id: &str) -> /// 故走专用 SELECT(与 state.rs 清理孤儿 pending 消息同款 app 层直查先例);SQL 里的工具名单 /// 是 TASK_CONV_TOOLS 的硬拷贝,单测覆盖三个名字防漂移。 /// +/// P2-3 task_id 预筛下沉 SQL:`result LIKE '%"id":""%'` 只作预筛(缩小扫描窗口, +/// 参数化绑定防注入),精确判等仍在 Rust 侧 match_task_audit_row(解析 JSON 取顶层 id 字段 +/// 判等,「禁裸 LIKE 判等」不破)。预筛只会多放进相关行(嵌套 id/巧合同形文本),由 Rust +/// 侧剔除;不会漏放——audit result 均由 serde_json 紧凑序列化落库(无空格),顶层 id 字段 +/// 必然以 `"id":""` 形态出现。若无预筛,LIMIT 窗口取的是全局最近 1000 行,高流量下 +/// 目标任务的关联行会被无关行挤出窗外(关联静默消失);预筛后窗口=含该任务 id 形文本的 +/// 最近 1000 行,早期关联仍可命中(有单测锁定)。 +/// /// 过滤:① 命中行按会话去重(行按 requested_at 倒序,首见=最近一次);② 子代理会话 /// (origin='sub_agent',V44)剔除,v1 只列主会话;③ 会话已不在库的 id 一并剔除 /// (switchConversation 对不存在 id 会走"新建兜底",跳过去是空会话,不如不列)。 -/// LIMIT 1000 扫描上界:抽屉每次打开都查,封顶防超大审计表全表拖慢;取最近 1000 行, -/// 更早的关联对"跳回最近上下文"场景无意义。 +/// LIMIT 1000 扫描上界:抽屉每次打开都查,封顶防超大审计表全表拖慢。 pub(crate) async fn find_task_conversations( db: &Database, task_id: &str, ) -> Result, String> { + let pattern = format!("%\"id\":\"{}\"%", task_id); let conn = db.conn(); let rows: Vec<(String, String, Option, String)> = tokio::task::spawn_blocking(move || -> Result, String)>, String> { @@ -315,11 +323,12 @@ pub(crate) async fn find_task_conversations( "SELECT conversation_id, tool_name, result, requested_at FROM ai_tool_executions \ WHERE tool_name IN ('create_task','update_task','advance_task') \ AND conversation_id IS NOT NULL \ + AND result LIKE ?1 \ ORDER BY CAST(requested_at AS INTEGER) DESC LIMIT 1000", ) .map_err(|e| e.to_string())?; let mapped = stmt - .query_map([], |row| { + .query_map([&pattern], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, @@ -590,6 +599,7 @@ mod tests { created_at: "1".to_string(), updated_at: "1".to_string(), origin: origin.map(|s| s.to_string()), + boundary_json: None, }) .await .expect("测试会话插入应成功"); @@ -637,6 +647,36 @@ mod tests { assert_eq!(out[0].requested_at, (t - 10).to_string()); } + /// P2-3 窗口外早期关联仍可命中:目标任务行之后灌 1000+ 条更新的无关任务工具行, + /// 旧的「全局最近 1000 条」窗口会把早期关联挤出(静默消失);task_id 预筛下沉后, + /// 窗口按预筛结果截取,早期关联仍须命中(精确判等仍在 Rust 侧,误命中不发生)。 + #[tokio::test] + async fn find_task_conversations_early_hit_survives_window_flood() { + let db = Database::open_in_memory().await.expect("in-memory db 初始化失败"); + let repo = AiToolExecutionRepo::new(&db); + let t_early = 1_000_000_000_000i64; + + insert_conversation(&db, "conv-early", Some("早期会话"), None).await; + insert_conv_audit(&repo, "conv-early", "create_task", + Some(r#"{"id":"t-early","title":"E"}"#.into()), t_early).await; + // 灌 1100 条更新的无关任务工具行(其他任务 id),把全表推过 1000 行窗口 + let t_flood = t_early + 1_000_000; + for i in 0..1100i64 { + insert_conv_audit(&repo, "conv-noise", "update_task", + Some(format!(r#"{{"id":"noise-{}"}}"#, i)), t_flood + i).await; + } + + let out = find_task_conversations(&db, "t-early").await.unwrap(); + assert_eq!(out.len(), 1, "早期关联不得被 1000 行窗口挤出"); + assert_eq!(out[0].conversation_id, "conv-early"); + assert_eq!(out[0].title.as_deref(), Some("早期会话")); + // 预筛不放无关行进来:noise 任务查不到任何关联 + assert!( + find_task_conversations(&db, "noise-1").await.unwrap().is_empty(), + "noise 会话不在库,即使 id 前缀巧合也不得列出" + ); + } + /// S4 纯函数判定表:名单外工具 / 非 JSON / 缺 id / id 不等 均不命中,精确判等才命中。 #[test] fn match_task_audit_row_truth_table() { diff --git a/src-tauri/src/commands/ai/boundary.rs b/src-tauri/src/commands/ai/boundary.rs new file mode 100644 index 0000000..95499b0 --- /dev/null +++ b/src-tauri/src/commands/ai/boundary.rs @@ -0,0 +1,256 @@ +//! 无人值守长任务 — 任务级授权边界(TaskBoundary) +//! +//! 设计: docs/02-架构设计/专项设计/无人值守长任务-设计-2026-10-04.md §二/§三。 +//! 拍板点 1: risk_ceiling 封顶 Medium,High 归 L3 完全信任档不经此通道。 +//! 生命周期 = 会话存续: 发起时一次声明,随会话同亡(落库 ai_conversations.boundary_json V45)。 +//! 无边界会话 = 现状逐次审批零倒退;子代理策略(SubAgent)不受影响维持 fail-closed(拍板点 6)。 +//! +//! 判定函数全部纯函数(无 session/AppHandle 依赖),单测锁定真值表; +//! 插桩点在 audit/process_tool_calls(会话边界快照 → path_auth NeedsAuth 分支 + +//! 风险分类循环两处消费)。 + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use df_ai::ai_tools::RiskLevel; + +use crate::commands::ai::is_destructive_command; + +/// 任务预算(复用子代理三熔断量纲: 轮次/token/挂钟)。None=不限(继承现网常量)。 +/// +/// P0 仅随行落库 + IPC 读写,主 loop 预算闸待后续批接线(设计拍板点 8: 预算可中途调)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaskBudget { + pub max_rounds: u32, + pub max_tokens: u64, + pub wall_secs: u64, +} + +fn default_risk_ceiling() -> RiskLevel { + RiskLevel::Medium +} + +fn default_forbid_destructive() -> bool { + true +} + +/// 任务级授权边界(挂会话: per_conv.boundary 内存真相源 + boundary_json 落库随行)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaskBoundary { + /// 风险上限(Low|Medium)。set 入口经 [`TaskBoundary::normalized`] 把 High 钳到 Medium + /// (拍板点 1: 无人看命令串的 High 风险不可控,确需 High 归 L3 完全信任档)。 + #[serde(default = "default_risk_ceiling")] + pub risk_ceiling: RiskLevel, + /// 任务目录白名单(目录级 starts_with 覆盖;判定时双侧 best-effort canonicalize 对齐形态, + /// 与 AllowedDirs::is_authorized 同款归一口径)。存原始字符串: 白名单目录可能尚不存在 + /// (write_file 新建),规范化失败回退词法形态尽力匹配。 + #[serde(default)] + pub dir_allowlist: Vec, + /// 破坏性命令禁令(恒 true,字段留作显式声明供 UI 展示)。 + /// 判定**不读本字段**——直接调 is_destructive_command,防手改 JSON 置 false 绕过 + /// (安全判定不信任输入)。 + #[serde(default = "default_forbid_destructive")] + pub forbid_destructive: bool, + /// 任务预算,None=不限。 + #[serde(default)] + pub budget: Option, +} + +impl TaskBoundary { + /// set 入口规范化: risk_ceiling=High 钳到 Medium(拍板点 1,边界声明不开放 High 档)。 + pub fn normalized(mut self) -> Self { + if self.risk_ceiling == RiskLevel::High { + tracing::warn!("[boundary] risk_ceiling=High 不开放(拍板点 1),已钳到 Medium"); + self.risk_ceiling = RiskLevel::Medium; + } + self + } +} + +fn risk_rank(r: RiskLevel) -> u8 { + match r { + RiskLevel::Low => 0, + RiskLevel::Medium => 1, + RiskLevel::High => 2, + } +} + +/// 风险是否在边界上限内(High 恒超 Medium 上限;ceiling 已在 set 时钳到 Medium, +/// 此函数对 High ceiling 不做特判——正常入口不会出现,留给出处可信的内存态)。 +pub fn risk_within_ceiling(boundary: &TaskBoundary, risk: RiskLevel) -> bool { + risk_rank(risk) <= risk_rank(boundary.risk_ceiling) +} + +/// 路径是否被任务白名单覆盖(目录级 starts_with)。 +/// +/// 双侧 best-effort canonicalize + strip_verbatim 对齐形态(白名单存词法串/路径可能 +/// 不存在/candidate 可能带 \\?\ 前缀),归一口径与 AllowedDirs::is_authorized 一致。 +pub fn dir_covered(allowlist: &[String], path: &Path) -> bool { + use crate::state::{best_effort_canonicalize, strip_verbatim}; + let cand = strip_verbatim(best_effort_canonicalize(path)); + allowlist.iter().any(|d| { + let dir_norm = strip_verbatim(best_effort_canonicalize(Path::new(d))); + cand.starts_with(&dir_norm) + }) +} + +/// NeedsAuth 待授权目录集合是否**全部**被任务白名单覆盖(全覆盖才放行,部分覆盖=拒; +/// 部分放行会把未覆盖目录也带过闸,违背 fail-closed)。 +pub fn covers_dirs(boundary: &TaskBoundary, dirs: &[PathBuf]) -> bool { + !dirs.is_empty() && dirs.iter().all(|d| dir_covered(&boundary.dir_allowlist, d)) +} + +/// 边界对单条工具调用**风险面**的放行判定(路径维度在 path_auth NeedsAuth 分支单点判定, +/// 不在本函数——两者判定时机不同,见 process_tool_calls 插桩注释)。 +/// +/// 条件: 风险 ≤ 上限 ∧ 非破坏性命令。run_command 注册为 High 本就恒超 Medium 上限, +/// 破坏性闸是纵深防御(任务书要求 is_destructive_command 永不 auto,与 session_trust +/// 放行门控同源同闸)。 +pub fn risk_allowed( + boundary: &TaskBoundary, + tool_name: &str, + args: &serde_json::Value, + risk: RiskLevel, +) -> bool { + if !risk_within_ceiling(boundary, risk) { + return false; + } + if tool_name == "run_command" { + if let Some(cmd) = args.get("command").and_then(|v| v.as_str()) { + if is_destructive_command(cmd) { + return false; + } + } + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + fn boundary(ceiling: RiskLevel, dirs: &[&str]) -> TaskBoundary { + TaskBoundary { + risk_ceiling: ceiling, + dir_allowlist: dirs.iter().map(|s| s.to_string()).collect(), + forbid_destructive: true, + budget: None, + } + } + + /// serde roundtrip: 全字段 ↔ JSON 双向一致(落库 boundary_json 的序列化契约)。 + #[test] + fn serde_roundtrip_full_fields() { + let b = TaskBoundary { + risk_ceiling: RiskLevel::Medium, + dir_allowlist: vec!["E:/wk-lab/devflow".to_string(), "C:/temp".to_string()], + forbid_destructive: true, + budget: Some(TaskBudget { max_rounds: 50, max_tokens: 200_000, wall_secs: 900 }), + }; + let json = serde_json::to_string(&b).unwrap(); + let back: TaskBoundary = serde_json::from_str(&json).unwrap(); + assert_eq!(back, b, "全字段 roundtrip 应一致"); + } + + /// serde 缺省字段容错: 前端精简 JSON(缺 forbid_destructive/budget/risk_ceiling/ + /// dir_allowlist)反序列化落到安全默认(Medium/空名单/true/None),不报错。 + #[test] + fn serde_missing_fields_fall_back_to_safe_defaults() { + let b: TaskBoundary = serde_json::from_str("{}").unwrap(); + assert_eq!(b.risk_ceiling, RiskLevel::Medium, "缺省 ceiling=Medium(任务书拍板)"); + assert!(b.dir_allowlist.is_empty()); + assert!(b.forbid_destructive, "缺省禁破坏性命令"); + assert!(b.budget.is_none(), "缺省不限预算"); + } + + /// 边界内 Medium 放行 / 边界外仍拒: Medium 上限下 Low/Medium 过闸,High 恒拒; + /// Low 上限下 Medium 也拒(上限收紧生效)。 + #[test] + fn risk_ceiling_truth_table() { + let b = boundary(RiskLevel::Medium, &[]); + let args = serde_json::json!({}); + assert!(risk_allowed(&b, "write_file", &args, RiskLevel::Low), "Low ≤ Medium 放行"); + assert!(risk_allowed(&b, "write_file", &args, RiskLevel::Medium), "Medium = 上限 放行(边界内 Medium 放行)"); + assert!(!risk_allowed(&b, "run_command", &args, RiskLevel::High), "High 超上限恒拒(边界外仍拒)"); + let low = boundary(RiskLevel::Low, &[]); + assert!(!risk_allowed(&low, "write_file", &args, RiskLevel::Medium), "Medium 超 Low 上限拒"); + } + + /// 目录覆盖判定: 白名单内放行 / 白名单外拒 / 部分覆盖拒(全 covered 才放行)。 + #[test] + fn covers_dirs_all_or_nothing() { + let b = boundary(RiskLevel::Medium, &["E:/wk-lab/devflow"]); + assert!(covers_dirs(&b, &[PathBuf::from("E:/wk-lab/devflow/src/main.rs")]), "白名单内覆盖"); + assert!(covers_dirs(&b, &[PathBuf::from("E:/wk-lab/devflow/src"), PathBuf::from("E:/wk-lab/devflow/crates")]), "多个路径全在内"); + assert!(!covers_dirs(&b, &[PathBuf::from("E:/other/x")]), "白名单外拒(边界外仍拒)"); + assert!( + !covers_dirs(&b, &[PathBuf::from("E:/wk-lab/devflow/src"), PathBuf::from("E:/other/x")]), + "部分覆盖=拒(全 covered 才放行,fail-closed)" + ); + assert!(!covers_dirs(&b, &[]), "空目录集不判覆盖(NeedsAuth 必带目录,空=异常数据保守拒)"); + } + + /// 破坏性命令恒拒: 即使风险面在名义上限内(测试构造 High ceiling 模拟"上限失守"), + /// is_destructive_command 命中仍拒——纵深防御闸独立于风险等级。 + #[test] + fn destructive_command_never_auto() { + let high = boundary(RiskLevel::High, &[]); + let rm = serde_json::json!({ "command": "rm -rf /" }); + assert!( + !risk_allowed(&high, "run_command", &rm, RiskLevel::High), + "破坏性命令即使 High 上限也恒拒(纵深防御)" + ); + let med = boundary(RiskLevel::Medium, &[]); + assert!( + !risk_allowed(&med, "run_command", &rm, RiskLevel::High), + "Medium 上限下 run_command 双闸全拒" + ); + // 非破坏性命令只受风险等级约束(首 token 精配不误伤 delta/delve) + let cargo = serde_json::json!({ "command": "cargo check --workspace" }); + assert!( + !risk_allowed(&med, "run_command", &cargo, RiskLevel::High), + "Medium 上限下非破坏 High 命令仍超上限拒" + ); + } + + /// set 入口规范化: High ceiling 钳到 Medium(拍板点 1),其余字段原样保留。 + #[test] + fn normalized_clamps_high_ceiling_to_medium() { + let clamped = boundary(RiskLevel::High, &["E:/x"]).normalized(); + assert_eq!(clamped.risk_ceiling, RiskLevel::Medium, "High 钳到 Medium"); + assert_eq!(clamped.dir_allowlist, vec!["E:/x".to_string()], "其余字段不动"); + assert_eq!(boundary(RiskLevel::Low, &[]).normalized().risk_ceiling, RiskLevel::Low, "Low 不变"); + } + + /// 黑名单恒拒与边界无关: .ssh 路径在 check_file_tool_auth 先行 Denied, + /// 边界层永不参与 Denied 分支(即使 allowlist 看似覆盖其父目录)。 + /// 插桩结构保证: Denied 在 NeedsAuth 分支之前分流,boundary 判定只接 NeedsAuth。 + #[test] + fn blacklist_denied_before_boundary_consulted() { + let _guard = crate::commands::ai::audit::path_auth::BLACKLIST_BYPASS_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let allowed = crate::state::AllowedDirs::default_with_root(); + // allowlist 故意覆盖 /home/user:若边界误参与黑名单分支,这里会误判"覆盖" + let b = boundary(RiskLevel::Medium, &["/home/user"]); + let secret = serde_json::json!({ "path": "/home/user/.ssh/config" }); + assert!( + matches!( + crate::commands::ai::audit::path_auth::check_file_tool_auth( + "read_file", &secret, &allowed, crate::commands::ai::audit::path_auth::PathBypass::Off, + ), + crate::commands::ai::audit::path_auth::FileToolAuthOutcome::Denied(_) + ), + "黑名单路径恒 Denied(不进 NeedsAuth,边界判定无机会放行)" + ); + } + + /// 无边界零变化: PerConvState 默认 boundary=None,process_tool_calls 边界快照为 None + /// 时全部分支跳过(auto_exec_mode/trust/审批/路径授权全链路现状)。 + #[test] + fn per_conv_default_boundary_is_none() { + let conv = crate::commands::ai::PerConvState::new(); + assert!(conv.boundary.is_none(), "新会话默认无边界(无边界=现状逐次审批零倒退)"); + } +} diff --git a/src-tauri/src/commands/ai/commands/conversation.rs b/src-tauri/src/commands/ai/commands/conversation.rs index 034a64c..9f79c4c 100644 --- a/src-tauri/src/commands/ai/commands/conversation.rs +++ b/src-tauri/src/commands/ai/commands/conversation.rs @@ -400,6 +400,21 @@ pub async fn ai_conversation_switch( }; conv.pinned_goals = pinned_goals; } + // 无人值守任务边界随行读载(V45 boundary_json):DB 快照还原内存真相源。 + // 仅未在生成分支执行(already_live 时内存即真相,不覆盖)。解析失败按无边界降级 + // (fail-closed:无边界=逐次审批,绝不因坏数据放大授权)。 + conv.boundary = record.boundary_json.as_deref() + .filter(|s| !s.is_empty()) + .and_then(|s| match serde_json::from_str::(s) { + Ok(b) => Some(b), + Err(e) => { + tracing::warn!( + "切换对话 {} 解析 boundary_json 失败(按无边界处理): {}", + conversation_id, e + ); + None + } + }); // 首次切入才复位会话级临时态(信任/语言/模型覆盖保留,防切回重复审批);停止信号与迭代计数始终复位 if !conv_was_present { conv.model_override = None; @@ -640,6 +655,80 @@ pub async fn ai_conversation_peek( })) } +// ============================================================ +// 子代理轨迹列表(S3 完成卡备料:父会话 → 其子代理轨迹精简元数据,只读) +// ============================================================ + +/// 子代理轨迹精简条目(前端完成卡/轨迹树回补用;零 schema 变更,父子关系由 id 约定推导)。 +#[derive(Debug, Clone, serde::Serialize)] +pub struct SubAgentRef { + pub conversation_id: String, + /// 轨迹标题(懒创建未命名时 None,前端回退显 agent_id) + pub title: Option, + /// 创建时间(毫秒字符串,同 ai_conversations.created_at 口径) + pub created_at: String, +} + +/// 按父会话 id 反查其派生的子代理轨迹(供 IPC 与单测共用,不依赖 AppState)。 +/// +/// 子代理轨迹落库 origin='sub_agent'(V44);父关系无独立列,靠 child_id 约定 +/// `{parent}::agent-{n}`(agentic/sub_agent.rs next_child_id)推导:SQL LIKE 粗筛 +/// (参数化绑定)+ Rust 侧 starts_with 精确校验——LIKE 的 _/% 通配语义不作判等依据, +/// 与 record.rs「禁裸 LIKE 判等」同口径。app 层直查先例:find_task_conversations。 +/// 按 created_at 升序返回(轨迹时间线序)。 +pub(crate) async fn find_sub_agent_conversations( + db: &df_storage::db::Database, + parent_conv_id: &str, +) -> Result, String> { + let prefix = format!("{}::agent-", parent_conv_id); + let like = format!("{}%", prefix); + let conn = db.conn(); + let guard_prefix = prefix.clone(); + let rows: Vec<(String, Option, String)> = + tokio::task::spawn_blocking(move || -> Result, String)>, String> { + let guard = conn.blocking_lock(); + let mut stmt = guard + .prepare( + "SELECT id, title, created_at FROM ai_conversations \ + WHERE origin = 'sub_agent' AND id LIKE ?1 \ + ORDER BY CAST(created_at AS INTEGER) ASC", + ) + .map_err(|e| e.to_string())?; + let mapped = stmt + .query_map([&like], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, String>(2)?, + )) + }) + .map_err(|e| e.to_string())?; + let mut out = Vec::new(); + for r in mapped { + out.push(r.map_err(|e| e.to_string())?); + } + Ok(out) + }) + .await + .map_err(|e| format!("find_sub_agent_conversations join 失败: {}", e))??; + + // starts_with 精确校验:父 id 含 LIKE 通配字符时粗筛会误命中他父条目,以约定前缀为准剔除 + Ok(rows + .into_iter() + .filter(|(id, _, _)| id.starts_with(&guard_prefix)) + .map(|(conversation_id, title, created_at)| SubAgentRef { conversation_id, title, created_at }) + .collect()) +} + +/// 按父会话反查子代理轨迹精简元数据(前端完成卡回补数据源,只读无副作用)。 +#[tauri::command] +pub async fn ai_sub_agent_list( + state: State<'_, AppState>, + conversation_id: String, +) -> Result, String> { + find_sub_agent_conversations(&state.db, &conversation_id).await +} + /// 删除对话 #[tauri::command] pub async fn ai_conversation_delete( @@ -687,8 +776,10 @@ pub async fn ai_conversation_delete( // spawn_agent 级联取消:父会话删除,其派生的子代理同步置停(子循环停止检查点 // 读 own stop_flag 即退出;父 per_conv 条目随下方 remove 消失是兜底判据)。 // 短临界区纯内存操作(无 await),不延长 session 持锁。 + // Delete 形态:同锁内对 child 补 deleted_convs,出口 alive 判定放弃落库,防孤儿轨迹复活。 crate::commands::ai::agentic::sub_agent::cascade_stop_children_locked( &mut session, &state.sub_agent_registry, &conversation_id, + crate::commands::ai::agentic::sub_agent::CascadeStopForm::Delete, ); // 删除 conv 时移除其 per_conv 条目(loop 检测 conv 不存在即退出)。 session.per_conv.remove(&conversation_id); @@ -805,6 +896,79 @@ pub async fn ai_update_conversation_goals( Ok(()) } +// ============================================================ +// 无人值守任务边界 IPC(设计 §2.3):发起时一次拍板,存续期生效,随会话同亡。 +// 无边界=现状逐次审批零倒退;set 传 None 清除=回退逐次审批(接管转人工语义)。 +// ============================================================ + +/// 读取会话任务边界(发起卡回显/任务面板展示用)。 +/// +/// 内存优先(per_conv 即 live 真相),未加载(未切载/重启后未切)回退 DB 快照; +/// 两处皆无返回 None(幂等读,不报错不惰性建会话)。 +#[tauri::command] +pub async fn ai_task_boundary_get( + state: State<'_, AppState>, + conversation_id: String, +) -> Result, String> { + { + let session = state.ai_session.lock().await; + if let Some(b) = session.conv_read(&conversation_id).and_then(|c| c.boundary.clone()) { + return Ok(Some(b)); + } + } + let record = state.ai_conversations.get_by_id(&conversation_id).await.map_err(err_str)?; + Ok(record.and_then(|r| { + r.boundary_json.as_deref() + .filter(|s| !s.is_empty()) + .and_then(|s| match serde_json::from_str(s) { + Ok(b) => Some(b), + Err(e) => { + tracing::warn!( + "读取对话 {} boundary_json 解析失败(按无边界返回): {}", + conversation_id, e + ); + None + } + }) + })) +} + +/// 设置会话任务边界(发起时一次声明;中途改边界=新拍板,本 IPC 不拦重复调用—— +/// 拍板点 8 修订:一次性约束的是 AI 不回头问用户,用户动自己的任务不受限)。 +/// +/// boundary=None 清除 → 回退逐次审批。set 即生效(per_conv 内存真相源), +/// DB 快照 best-effort 同步:对话未落库(懒创建)时 UPDATE 影响 0 行属预期, +/// 首条消息 save_conversation 首插会随行带上 boundary_json。 +#[tauri::command] +pub async fn ai_task_boundary_set( + state: State<'_, AppState>, + conversation_id: String, + boundary: Option, +) -> Result<(), String> { + // 入口规范化:ceiling=High 钳到 Medium(拍板点 1,High 归 L3 不开放) + let normalized = boundary.map(|b| b.normalized()); + { + let mut session = state.ai_session.lock().await; + session.conv(&conversation_id).boundary = normalized.clone(); + } + match normalized.as_ref() { + Some(b) => { + let json = serde_json::to_string(b).map_err(|e| format!("序列化任务边界失败: {e}"))?; + if let Err(e) = state.ai_conversations.set_boundary_json(&conversation_id, Some(&json)).await { + tracing::warn!( + "写对话 {} boundary_json 失败(内存已生效,首插 save 随行补): {}", + conversation_id, e + ); + } + } + None => { + // 清除:列写 NULL;未落库会话影响 0 行属预期,静默 + let _ = state.ai_conversations.set_boundary_json(&conversation_id, None).await; + } + } + Ok(()) +} + /// 导出对话为指定格式对话导出) /// /// - 优先落库 messages(完整历史,与 switch 一致),内存 session 不读(可能被切走/未落库) @@ -1271,4 +1435,69 @@ mod tests { assert_eq!(got[1].id, "m_new"); assert_eq!(got[1].content, "压缩后新提问"); } + + /// ai_sub_agent_list 数据源:只列本父会话派生的子代理(origin='sub_agent' + id + /// 前缀约定),按 created_at 升序;LIKE 通配误命中与无子代理父不混入。 + #[tokio::test] + async fn sub_agent_list_scopes_to_parent_prefix() { + use df_storage::crud::AiConversationRepo; + use df_storage::db::Database; + use df_storage::models::AiConversationRecord; + + async fn insert_conv( + db: &Database, + id: &str, + title: Option<&str>, + origin: Option<&str>, + created_at: &str, + ) { + AiConversationRepo::new(db) + .insert(AiConversationRecord { + id: id.to_string(), + title: title.map(|s| s.to_string()), + messages: "[]".to_string(), + provider_id: None, + model: None, + models: None, + archived: false, + pinned: false, + prompt_tokens: None, + completion_tokens: None, + pinned_goals: None, + pending_approvals: None, + created_at: created_at.to_string(), + updated_at: created_at.to_string(), + origin: origin.map(|s| s.to_string()), + boundary_json: None, + }) + .await + .expect("测试会话插入应成功"); + } + + let db = Database::open_in_memory().await.expect("open_in_memory"); + insert_conv(&db, "conv-p", Some("父会话"), None, "100").await; + insert_conv(&db, "conv-p::agent-1", Some("子一"), Some("sub_agent"), "300").await; + insert_conv(&db, "conv-p::agent-2", None, Some("sub_agent"), "200").await; + // 他父的子代理 / origin 形态不符 id 前缀的子代理行:均不得混入 + insert_conv(&db, "conv-q::agent-1", Some("他父子"), Some("sub_agent"), "400").await; + insert_conv(&db, "conv-p::notagent", Some("形态不符"), Some("sub_agent"), "500").await; + // 父 id 含 LIKE 下划线通配:粗筛会误命中他父条目,须被 starts_with 精确校验剔除 + insert_conv(&db, "p_1::agent-1", Some("真子"), Some("sub_agent"), "600").await; + insert_conv(&db, "pX1::agent-9", Some("通配误命中"), Some("sub_agent"), "700").await; + + let out = find_sub_agent_conversations(&db, "conv-p").await.unwrap(); + assert_eq!(out.len(), 2, "只列 conv-p 派生的子代理: {:?}", out); + assert_eq!(out[0].conversation_id, "conv-p::agent-2", "created_at 升序"); + assert!(out[0].title.is_none()); + assert_eq!(out[1].conversation_id, "conv-p::agent-1"); + assert_eq!(out[1].title.as_deref(), Some("子一")); + assert_eq!(out[1].created_at, "300"); + + let out2 = find_sub_agent_conversations(&db, "p_1").await.unwrap(); + assert_eq!(out2.len(), 1, "LIKE 下划线通配的误命中须被精确校验剔除: {:?}", out2); + assert_eq!(out2[0].conversation_id, "p_1::agent-1"); + + // 无子代理的父返空 + assert!(find_sub_agent_conversations(&db, "conv-none").await.unwrap().is_empty()); + } } diff --git a/src-tauri/src/commands/ai/conversation.rs b/src-tauri/src/commands/ai/conversation.rs index 4411049..44032c0 100644 --- a/src-tauri/src/commands/ai/conversation.rs +++ b/src-tauri/src/commands/ai/conversation.rs @@ -171,6 +171,18 @@ pub(crate) fn truncate_parts_for_persist(parts: &[df_ai::provider::ContentPart]) } } +/// save 元数据快照(P3-5):子代理出口落库显式携带父会话的 provider_id/created_at。 +/// +/// save 内部默认读 `session.active_*` 决定首插的 provider_id/created_at——这对主会话 +/// 正确,对子代理错误:子代理后台长跑收尾时用户可能已切走会话,active 值已是别的会话的, +/// 子轨迹会继承错元数据。spawn 时刻快照、出口显式传入即可钉住。 +/// 主会话路径不经过本类型(save_conversation 传 None),读法与历史版本一致。 +#[derive(Debug, Clone, Default)] +pub(crate) struct SaveMetaOverride { + pub(crate) provider_id: Option, + pub(crate) created_at: Option, +} + /// 保存对话到数据库(按 conv_id 写库,不受 active_conversation_id 切换影响) /// /// 写 ai_messages(每条消息一行,F-260619-03 拆分存储)+ updated_at + 累加 token 用量 + @@ -190,13 +202,40 @@ pub(crate) async fn save_conversation( model: Option<&str>, touch_updated_at: bool, ) { - // 外层 timeout(SAVE_CONVERSATION_TIMEOUT_SECS):超时只 warn 不阻断。 - // save 是非关键路径(失败已 warn,内存真相源不变),防 DB/fs 卡死拖垮 agentic loop / IPC。 - // timeout 仅作用于 DB 写入段(此处包住整个 inner,因 session 锁/序列化也属 save 周期, - // 超时即放弃本次 save,下次 loop 轮次会重新落库,幂等覆盖无副作用)。 + save_with_meta(session_arc, db, conv_id, usage, model, touch_updated_at, None).await; +} + +/// 子代理出口落库入口(P3-5):显式携带父会话 save 元数据快照的 save 变体。 +/// 与 save_conversation 共用同一落库实现,仅首插的 provider_id/created_at 来源不同。 +pub(crate) async fn save_conversation_meta( + session_arc: &Arc>, + db: &Arc, + conv_id: &str, + usage: Option<&df_ai::provider::TokenUsage>, + model: Option<&str>, + touch_updated_at: bool, + meta: SaveMetaOverride, +) { + save_with_meta(session_arc, db, conv_id, usage, model, touch_updated_at, Some(meta)).await; +} + +/// save 统一实现(meta=None 即主会话既有行为,Some 即显式元数据覆盖)。 +/// 外层 timeout(SAVE_CONVERSATION_TIMEOUT_SECS):超时只 warn 不阻断。 +/// save 是非关键路径(失败已 warn,内存真相源不变),防 DB/fs 卡死拖垮 agentic loop / IPC。 +/// timeout 仅作用于 DB 写入段(此处包住整个 inner,因 session 锁/序列化也属 save 周期, +/// 超时即放弃本次 save,下次 loop 轮次会重新落库,幂等覆盖无副作用)。 +async fn save_with_meta( + session_arc: &Arc>, + db: &Arc, + conv_id: &str, + usage: Option<&df_ai::provider::TokenUsage>, + model: Option<&str>, + touch_updated_at: bool, + meta: Option, +) { if let Err(_elapsed) = tokio::time::timeout( Duration::from_secs(SAVE_CONVERSATION_TIMEOUT_SECS), - save_conversation_serialized(session_arc, db, conv_id, usage, model, touch_updated_at), + save_conversation_serialized(session_arc, db, conv_id, usage, model, touch_updated_at, meta), ) .await { @@ -216,16 +255,17 @@ async fn save_conversation_serialized( usage: Option<&df_ai::provider::TokenUsage>, model: Option<&str>, touch_updated_at: bool, + meta: Option, ) { let save_lock = { let mut session = session_arc.lock().await; session.save_lock(conv_id) }; let _save_guard = save_lock.lock().await; - save_conversation_inner(session_arc, db, conv_id, usage, model, touch_updated_at).await; + save_conversation_inner(session_arc, db, conv_id, usage, model, touch_updated_at, meta).await; } -/// save_conversation 内部实现(无超时,由外层 save_conversation 包 timeout 调用)。 +/// save_conversation 内部实现(无超时,由外层 save_with_meta 包 timeout 调用)。 async fn save_conversation_inner( session_arc: &Arc>, db: &Arc, @@ -233,6 +273,7 @@ async fn save_conversation_inner( usage: Option<&df_ai::provider::TokenUsage>, model: Option<&str>, touch_updated_at: bool, + meta: Option, ) { // 取 messages + 懒创建首次落库所需的 provider_id/created_at // 工具结果(content)超 8KB50KB→8KB,治 GLM 1214)时截断头尾各 ~3KB + 中段 @@ -253,7 +294,7 @@ async fn save_conversation_inner( // 避免同时持有 session 的 mut 和 immut 借用(E0502)。 // BE1无变化捷径提前到克隆之前——同一 lock 段内先判「消息数未增且非 dirty」, // 无变化则不 clone(每轮无变化 save 省一次全量 clone + 后续 truncate/records 构建)。 - let (provider_id, created_at, pinned_goals, msg_len, persisted_count, needs_full_rewrite, mut persist_msgs, conv_origin) = { + let (provider_id, created_at, pinned_goals, msg_len, persisted_count, needs_full_rewrite, mut persist_msgs, conv_origin, conv_boundary) = { let mut session = session_arc.lock().await; // 已删除对话直接跳过写入(loop 在「push 后、save 前」被删除的幽灵复活根治): // 此处不惰性重建 per_conv、不触发 INSERT 空壳 + insert_batch 复活。 @@ -268,8 +309,15 @@ async fn save_conversation_inner( let __wait = __lock_start.elapsed(); // 先取 session 顶层 owned 字段(immutable borrow 即刻结束),再取 conv mutable borrow, // 避免同时持有 session 的 mut 和 immut 借用(E0502)。 - let provider_id = session.active_provider_id.clone(); - let created_at = session.active_conv_created_at.clone(); + // P3-5:显式快照优先(SubAgent 出口落库用),否则读 active_*(主会话历史读法不变)。 + let provider_id = match &meta { + Some(m) => m.provider_id.clone(), + None => session.active_provider_id.clone(), + }; + let created_at = match &meta { + Some(m) => m.created_at.clone(), + None => session.active_conv_created_at.clone(), + }; // 会话来源标记(V44 子轨迹落库):子代理 conv 首插时随 record 落 origin 列,主会话 None let conv_origin = session.conv_origins.get(conv_id).cloned(); let conv = session.conv(conv_id); @@ -284,6 +332,8 @@ async fn save_conversation_inner( conv.messages.all_messages_clone() }; let pinned_goals = conv.pinned_goals.clone(); + // 无人值守任务边界随行落库(V45 boundary_json):每轮 save 同步内存真相源快照 + let conv_boundary = conv.boundary.clone(); let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长) ( provider_id, @@ -294,6 +344,7 @@ async fn save_conversation_inner( needs_full_rewrite, cloned, conv_origin, + conv_boundary, ) }; let __lock_total = __lock_start.elapsed(); @@ -403,6 +454,10 @@ async fn save_conversation_inner( rec.pinned_goals = Some(serde_json::to_string(&pinned_goals).unwrap_or_else(|_| "[]".to_string())); // 挂起审批快照持久化:每轮 save 同步当前 pending_approvals 快照 rec.pending_approvals = Some(pending_approvals_json.clone()); + // 无人值守任务边界随行持久化:None(未声明)保持 NULL,不写 "null" 脏串 + rec.boundary_json = conv_boundary.as_ref() + .map(|b| serde_json::to_string(b).unwrap_or_default()) + .filter(|s| !s.is_empty()); // update_full 仍写 messages 列(保留旧值,本批不改 messages 字段),写元数据 + updated_at if let Err(e) = conv_repo.update_full(&rec).await { tracing::warn!("更新对话元数据失败 {conv_id}: {e}"); @@ -473,6 +528,9 @@ async fn save_conversation_inner( created_at: conv_created.clone(), updated_at: now, origin: conv_origin, + boundary_json: conv_boundary.as_ref() + .map(|b| serde_json::to_string(b).unwrap_or_default()) + .filter(|s| !s.is_empty()), }; if let Err(e) = conv_repo.insert(rec).await { tracing::warn!("落库对话失败 {conv_id}: {e}"); @@ -492,10 +550,14 @@ async fn save_conversation_inner( // 失败保基线不变:下次 save 会重新尝试写未落库的消息(增量路径重试同 slice,INSERT OR IGNORE // 幂等不冲突;脏路径全量重写收敛)。基线推进必须在锁内与读时一致(本进程内单线程 save 串行)。 // 注意:写库期间若有其他操作 push/置 dirty,基线推进可能与最新内存错位——但 advance 用 - // max 防回退,且 dirty 标志由那次操作自己设(下次 save 仍走全量重写收敛),最终一致。 + // max 防回退;dirty 只在全量重写时清(增量/无变化路径写库期间新置的 dirty 如压缩 mark, + // 与本次 append 写库无关,误清会致改动滞留内存永不落库)。 if msgs_written_ok { let mut session = session_arc.lock().await; - session.conv(conv_id).messages.advance_persisted_count(total_len); + session + .conv(conv_id) + .messages + .advance_persisted_count(total_len, needs_full_rewrite); } // 自动 checkpoint(每 20 轮或总 token > 150k 时创建) diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index 785bc0d..af83f5d 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -29,6 +29,8 @@ pub mod agentic; pub mod augmentation; pub mod audit; +// 无人值守任务边界(TaskBoundary):结构 + 纯判定函数 + 单测(设计 §二/§三) +pub mod boundary; pub mod code_intel; pub mod command_stream; pub mod commands; @@ -236,6 +238,9 @@ pub enum AiChatEvent { /// - `status`:"completed" | "truncated" | "failed" /// - `rounds`/`tokens`:实际执行轮数与累计 token(启动失败为 0) /// - `rejected_tools`:被拒工具清单("工具名×次数",如 "write_file×3"),None=本次零拒绝 + /// - `stop_kind`:停止类别(snake_case 字符串,为前端备料):"cancelled"(用户停止级联) + /// | "wall_clock" | "max_rounds" | "token_budget" | "empty_convergence" | "error" + /// | "converged"(正常收敛);None=未分类(防御,当前所有路径均有类别) AiAgentFinished { agent_id: String, conversation_id: Option, @@ -246,6 +251,9 @@ pub enum AiChatEvent { /// S2 报告协议:子循环运行期被拒工具汇总,让父会话与用户看见子代理被拒了什么。 /// 可选字段:前端 S1 完成卡不消费该字段也不破坏(事件多字段向后兼容),消费留二期。 rejected_tools: Option>, + /// 停止类别(为前端备料):"cancelled" | "wall_clock" | "max_rounds" | "token_budget" + /// | "empty_convergence" | "error" | "converged"。前端不消费该字段也不破坏。 + stop_kind: Option, }, /// 流式调用自动重试提示流前失败重试中) /// @@ -425,6 +433,34 @@ pub enum AiChatEvent { resolved_by: String, conversation_id: Option, }, + /// 无人值守安全停靠(设计 §四,挂起家族终局):五路非正常终局(审批超时取消/挂起后 + /// 取消/熔断/预算耗尽/Fatal)统一出口。语义:现场已保存、generating 已复位(非死等)、 + /// reason 即待办清单(停止原因+已拒绝操作提示),前端操作卡据此提供恢复入口 + /// (既有 ai_chat_force_send/重发语义)。 + /// + /// 前端契约(钉死):事件名 `AiTaskParked`,字段 + /// `{ conversation_id: string, reason: string, stop_kind?: string }`。 + /// stop_kind 复用 AiAgentFinished 词表扩展: "approval_timeout" | "cancelled" + /// | "circuit_breaker" | "stall_breaker" | "max_rounds" | "fatal"。 + AiTaskParked { + conversation_id: Option, + reason: String, + stop_kind: Option, + }, + /// 无人值守进度可观测(设计 §4.3):主 loop 每 PROGRESS_EVERY_N_ROUNDS 轮 emit 一次, + /// 长任务静默期(子代理长跑/大工具执行/重入退避)前端可判断「在干活还是死了」。 + /// 事件即逝不落库,终局(AiCompleted/AiTaskParked 等)不补发。 + /// + /// 前端契约(钉死):事件名 `AiTaskProgress`,字段 + /// `{ conversation_id: string, round: number, max_rounds: number, tokens_used: number }`。 + /// round 1 起算(对齐 AiAgentRound 口径);max_rounds=0 表示不限轮次, + /// tokens_used 为本 loop 累计 token。 + AiTaskProgress { + conversation_id: Option, + round: u32, + max_rounds: u32, + tokens_used: u32, + }, } /// 模型信息(AiModelList 事件的载荷,对齐 apps/df-miniapp 模型选择器 ModelInfo 字段)。 @@ -662,19 +698,6 @@ pub struct AiSession { } impl AiSession { - pub fn new() -> Self { - Self { - active_provider_id: None, - active_conversation_id: None, - active_conv_created_at: None, - pending_approvals: HashMap::new(), - per_conv: HashMap::new(), - deleted_convs: HashSet::new(), - conv_origins: HashMap::new(), - save_locks: HashMap::new(), - namespace_store: df_ai::namespace_store::NamespaceStore::default(), - } - } /// 推导指定会话读侧状态视图(只读,不写任何字段)。 /// @@ -738,6 +761,25 @@ impl AiSession { .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) .clone() } + + /// 新建顶层会话容器(单例字段全默认:无活跃对话/无挂起审批/per_conv 空)。 + /// + /// B 批4 后本结构仅剩容器字段(单会话态全部下沉 per_conv),构造极简; + /// 历史单例字段(messages/stop_flag/...)的初值语义由 [`PerConvState::new`] 承接 + /// (调用方 session.conv(cid) 惰性创建时使用)。 + pub fn new() -> Self { + Self { + active_provider_id: None, + active_conversation_id: None, + active_conv_created_at: None, + pending_approvals: HashMap::new(), + per_conv: HashMap::new(), + deleted_convs: HashSet::new(), + conv_origins: HashMap::new(), + save_locks: HashMap::new(), + namespace_store: df_ai::namespace_store::NamespaceStore::default(), + } + } } #[cfg(test)] @@ -1031,6 +1073,30 @@ pub struct PerConvState { /// 仍为 view-only(不落库改历史,仅影响发送视图);键用内容哈希而非位置(裁剪/压缩后 /// 位置漂移,内容不变)。随会话销毁不落库。 pub tool_view_decisions: std::collections::HashMap, + /// 无人值守任务级授权边界(设计 §二):发起时一次声明,存续期内边界内零审批。 + /// None=未声明(现状逐次审批零倒退)。内存真相源在 per_conv,落库随行 + /// (ai_conversations.boundary_json,save 写/switch 读还原),随会话同亡。 + /// 子代理 per_conv 独立新建恒 None,不受父边界影响(拍板点 6 fail-closed)。 + pub boundary: Option, + /// 只读工具结果缓存的「写后失效」下界(消息索引,纯内存态,不落库)。 + /// + /// 治「只读结果缓存无写失效」缺陷:find_cached_readonly_result 通过反扫历史 messages + /// 命中旧 tool_result 回填缓存,但历史里 write_file/run_command 等写工具一旦执行过, + /// 其之前的只读结果(read_file/grep/list_directory)已可能过期——写后 LLM 重读会拿到 + /// 改前内容,缓存把脏数据喂回 LLM 致其基于旧状态决策。 + /// + /// 语义:非只读工具(`is_readonly(name)==false`)执行成功回填后,本字段被推到当时 + /// `messages.len()`(即该写结果 push 之后的位置)。缓存命中扫描仅认 `idx >= floor` + /// 的历史调用——写在前的只读结果全部失效(过界即脏),写在后的(写后新读)有效。 + /// + /// 脏失效范围必然覆盖「写工具影响的任何路径」:floor 是全量下界,不区分写的是哪个 + /// 文件(精确按路径失效需解析工具语义,过度工程;宁可多失效少喂脏——失效只是 + /// 多一次真执行,正确性无损)。 + /// + /// 索引漂移防御:floor 在锁内与 messages 同步读写;pop_last_assistant_round / + /// clear 会缩短 messages,此时 floor 若 > len 用 `min` 钳制(等于全失效后重新积累, + /// 保守方向安全)。floor 单调不减(取 max),避免写后多轮回退。 + pub readonly_cache_floor: usize, } /// 单个目标记录(含状态追踪)。 @@ -1041,6 +1107,73 @@ pub struct GoalEntry { pub status: GoalStatus, } +#[cfg(test)] +mod tests_task_parked { + use super::*; + + /// 停靠事件 serde 契约(前端钉死):事件名 type=AiTaskParked,字段 + /// {conversation_id: string, reason: string, stop_kind?: string}。 + /// 前端下批消费,本测试锁死后端不悄悄改协议。 + #[test] + fn ai_task_parked_event_serde_contract() { + let ev = AiChatEvent::AiTaskParked { + conversation_id: Some("conv-1".to_string()), + reason: "审批超时(15分钟)自动取消,任务已安全停靠".to_string(), + stop_kind: Some("approval_timeout".to_string()), + }; + let v = serde_json::to_value(&ev).unwrap(); + assert_eq!(v["type"], "AiTaskParked", "事件名(前端契约)"); + assert_eq!(v["conversation_id"], "conv-1", "conversation_id 序列化为 string"); + assert_eq!(v["reason"], "审批超时(15分钟)自动取消,任务已安全停靠"); + assert_eq!(v["stop_kind"], "approval_timeout"); + // stop_kind 可选语义:None → null(前端 stop_kind? 可选消费) + let ev2 = AiChatEvent::AiTaskParked { + conversation_id: Some("c".to_string()), + reason: "r".to_string(), + stop_kind: None, + }; + let v2 = serde_json::to_value(&ev2).unwrap(); + assert!(v2["stop_kind"].is_null(), "stop_kind 缺省序列化为 null(可选字段)"); + } + + /// stop_kind 词表锁定(六路非正常终局):前端操作卡/图标按此分派,新增值须同步前端契约。 + #[test] + fn ai_task_parked_stop_kind_vocabulary() { + const KINDS: [&str; 7] = [ + "approval_timeout", "cancelled", "circuit_breaker", + "stall_breaker", "max_rounds", "fatal", "midstream", + ]; + for k in KINDS { + let ev = AiChatEvent::AiTaskParked { + conversation_id: Some("c".to_string()), + reason: "r".to_string(), + stop_kind: Some(k.to_string()), + }; + let v = serde_json::to_value(&ev).unwrap(); + assert_eq!(v["stop_kind"], k); + } + } + + /// 进度事件 serde 契约(前端钉死):事件名 type=AiTaskProgress,字段 + /// {conversation_id: string, round: number, max_rounds: number, tokens_used: number}。 + /// max_rounds=0 语义=不限轮次(前端进度条按无上限处理),数值类型本测试锁死。 + #[test] + fn ai_task_progress_event_serde_contract() { + let ev = AiChatEvent::AiTaskProgress { + conversation_id: Some("conv-1".to_string()), + round: 6, + max_rounds: 20, + tokens_used: 12345, + }; + let v = serde_json::to_value(&ev).unwrap(); + assert_eq!(v["type"], "AiTaskProgress", "事件名(前端契约)"); + assert_eq!(v["conversation_id"], "conv-1", "conversation_id 序列化为 string"); + assert_eq!(v["round"], 6, "round 序列化为 number(1 起算)"); + assert_eq!(v["max_rounds"], 20, "max_rounds 序列化为 number(0=不限)"); + assert_eq!(v["tokens_used"], 12345, "tokens_used 序列化为 number(loop 累计)"); + } +} + /// 目标状态。 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -1096,6 +1229,8 @@ impl PerConvState { workflow_dag_summary: None, frozen_tool_names: None, tool_view_decisions: std::collections::HashMap::new(), + boundary: None, + readonly_cache_floor: 0, } } } @@ -1253,3 +1388,31 @@ pub(crate) async fn execute_run_workflow_for_tool( "note": "工作流已触发(后台异步执行,完成后自动推进任务状态,失败按退回态回滚)。前端 workflow-event 跟踪进度。" })) } + +// ============================================================ +// 无人值守安全停靠出口(设计 §四):五路非正常终局的统一事件+通知单点 +// ============================================================ + +/// 发停靠事件 AiTaskParked + NeedAction 系统通知(托盘场景补位,复用 notify 基建)。 +/// +/// 调用方职责(本函数不做,保持单一只读语义):调用前完成「保存现场(落库)+ +/// 不保持 generating(conv_state 复位)」两步——各终局路径现状已有 +/// (teardown_round/stream_relay Fatal/ai_chat_stop),审批超时路径由本批补齐。 +/// reason 即待办清单:把停止原因与恢复指引写成人话,前端操作卡直接展示。 +pub(crate) fn emit_task_parked(app: &tauri::AppHandle, conv_id: &str, reason: &str, stop_kind: &str) { + let ev = AiChatEvent::AiTaskParked { + conversation_id: Some(conv_id.to_string()), + reason: reason.to_string(), + stop_kind: Some(stop_kind.to_string()), + }; + event_bus::emit_ai_chat_event(app, ev); + // 托盘场景补位:停靠=需用户回来决策(继续/归档),与审批挂起同用 NeedAction 分组 + // (主窗聚焦跳过 + 5s 节流,重复终局不会刷屏)。 + notify::notify_session_event( + app, + notify::NotifyKind::NeedAction, + conv_id, + "任务已停靠", + reason, + ); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e33987c..365dcc4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -460,12 +460,17 @@ pub fn run() { commands::ai::ai_conversation_switch, // S3 子代理轨迹只读窥视(复用 switch 读路径,不切任何会话状态) commands::ai::ai_conversation_peek, + // S3 完成卡备料:按父会话反查子代理轨迹精简元数据(只读) + commands::ai::ai_sub_agent_list, commands::ai::ai_conversation_load_more, commands::ai::ai_conversation_delete, commands::ai::ai_conversation_rename, commands::ai::ai_conversation_archive, commands::ai::ai_conversation_set_pinned, commands::ai::ai_update_conversation_goals, + // 无人值守任务边界(设计 §2.3):发起时一次拍板 / 回显;清除=回退逐次审批 + commands::ai::ai_task_boundary_get, + commands::ai::ai_task_boundary_set, commands::ai::ai_conversation_export, commands::ai::ai_list_skills, // 核心设计6: 热重载技能(invalidate + 重扫,不重启生效) diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 6b0e565..a2a0120 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -18,8 +18,8 @@ mod llm_concurrency; // 对外公共符号(pub use):外部 crate/模块经 `crate::state::X` 路径消费 pub use allowed_dirs::{ - AllowedDirs, PathAuthDecision, check_path_authorization, is_in_system_blacklist, - paths_eq, strip_verbatim, + AllowedDirs, PathAuthDecision, best_effort_canonicalize, check_path_authorization, + is_in_system_blacklist, paths_eq, strip_verbatim, }; pub use knowledge_config::{ ExtractTrigger, KnowledgeConfig, KNOWLEDGE_CONFIG_KEY, APPROVAL_TIMEOUT_KEY, @@ -275,7 +275,6 @@ pub struct AppState { /// `projects.path` 并集 + `AllowedDirs` 推导。无绑定项目时 workspace_root 为空, /// 文件操作拒绝,引导用户绑定项目。当前保留编译期常量作为开发期兼容。 /// 已修复: -/// - authz_debug → std::env::temp_dir() (chat.rs) /// - workspace_drive → std::env::current_dir() 运行时确定 (chat.rs) fn workspace_root_path() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) diff --git a/src-tauri/src/state/allowed_dirs.rs b/src-tauri/src/state/allowed_dirs.rs index b81f24a..f082422 100644 --- a/src-tauri/src/state/allowed_dirs.rs +++ b/src-tauri/src/state/allowed_dirs.rs @@ -233,7 +233,7 @@ pub fn strip_verbatim(p: PathBuf) -> PathBuf { /// 父目录也不存在 → 回退原词法路径(交由 strip_verbatim + starts_with 尽力匹配,不阻断) /// /// 失败一律回退,绝不返回 Err——授权比对宁可降级匹配不可 panic/阻断合法访问。 -pub(crate) fn best_effort_canonicalize(p: &Path) -> PathBuf { +pub fn best_effort_canonicalize(p: &Path) -> PathBuf { if let Ok(real) = std::fs::canonicalize(p) { return real; } diff --git a/src/api/ai.ts b/src/api/ai.ts index 5cf42db..623e954 100644 --- a/src/api/ai.ts +++ b/src/api/ai.ts @@ -2,7 +2,7 @@ import { invoke } from '@tauri-apps/api/core' import { listen, type UnlistenFn } from '@tauri-apps/api/event' -import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProviderConfig, AiToolMeta, ContentPart, MentionSpan, ModelConfig, SkillInfo } from './types' +import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProviderConfig, AiToolMeta, ContentPart, MentionSpan, ModelConfig, SkillInfo, TaskBoundary } from './types' export const aiApi = { /** @@ -112,6 +112,19 @@ export const aiApi = { return invoke('ai_pending_tool_calls', { conversationId }) }, + // ── 无人值守任务边界(设计 §2.3):发起时一次授权,存续期生效,随会话同亡 ── + + /** 读取会话任务边界(配置卡回显/侧栏徽标)。无边界返回 null(幂等读不报错) */ + getTaskBoundary(conversationId: string): Promise { + return invoke('ai_task_boundary_get', { conversationId }) + }, + + /** 设置会话任务边界。boundary 传 null = 清除,回退逐次审批; + * 后端把 risk_ceiling=high 钳到 medium;对话未落库时内存即生效,首条消息落库随行 */ + setTaskBoundary(conversationId: string, boundary: TaskBoundary | null): Promise { + return invoke('ai_task_boundary_set', { conversationId, boundary }) + }, + /** 清空对话 */ clearChat(): Promise { return invoke('ai_chat_clear') @@ -378,6 +391,15 @@ export const aiApi = { return invoke('ai_conversation_peek', { conversationId }) }, + /** + * 按父会话反查子代理轨迹精简元数据(P2-2 完成卡回补数据源,只读无副作用)。 + * 后端按 origin='sub_agent' + id 前缀约定过滤,created_at 升序(轨迹时间线序)。 + * 重启/分离窗回收后内存完成卡清零,经此按父 conv 重建卡片入口。 + */ + listSubAgents(conversationId: string): Promise<{ conversation_id: string; title: string | null; created_at: string }[]> { + return invoke('ai_sub_agent_list', { conversationId }) + }, + /** * S4 TaskDrawer「关联会话」:按任务 id 反查发起过 create/update/advance_task 的会话。 * 后端从 ai_tool_executions 审计表推导(result JSON 顶层 id 精确判等,非 LIKE), diff --git a/src/api/types.ts b/src/api/types.ts index 0ae662c..f6ac207 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -480,6 +480,19 @@ export type AiChatEvent = ({ // 前端渲染求助卡显 reason + options 按钮供用户选,与 AiApprovalRequired(工具执行前审批)/ // AiMaxRoundsReached(达 max 被动截断)语义区分——求助=AI 主动「我搞不定」。 type: 'AiHelpRequired'; reason: string; context: string; options: string[] +} | { + // 无人值守安全停靠(设计 §四,挂起家族终局):六路非正常终局(审批超时取消/挂起后取消/ + // 熔断/预算耗尽/Fatal/流中断)统一出口。语义:现场已保存、generating 已复位(非死等),reason 即 + // 待办清单(停止原因+被拒操作提示),前端渲染停靠操作卡(继续续跑/停止归档)。 + // stop_kind 词表(后端契约钉死,复用 AiAgentFinished 词表扩展):approval_timeout / cancelled / + // circuit_breaker / stall_breaker / max_rounds / fatal / midstream;后端新增变体时前端未知值中性兜底。 + type: 'AiTaskParked'; reason: string; stop_kind?: string +} | { + // 无人值守进度可观测(设计 §4.3):后端主 loop 每 5 轮 emit,长任务静默期(子代理长跑/ + // 大工具执行/重入退避)可见「在干活」。round 1 起算;max_rounds=0=不限轮次(进度条只显轮次 + // 与 token,不显分母)。事件即逝,前端 per-conv Map 存最近快照,终局(AiCompleted/AiError/ + // AiHelpRequired/AiTaskParked)与会话删除时清理。 + type: 'AiTaskProgress'; round: number; max_rounds: number; tokens_used: number } | { // L2 统一状态机(批2 1b/1c):ConvState 经 AiChatEvent 推前端,供 status union 消费。 // conv_state 值为 idle|generating|stopping|error|compressed(snake_case,对齐后端 ConvState serde)。 @@ -806,6 +819,30 @@ export interface AiToolMeta { risk: 'low' | 'medium' | 'high' } +// ============================================================ +// 无人值守长任务(设计 docs/02-架构设计/专项设计/无人值守长任务-设计-2026-10-04.md §二/§2.3) +// 与后端 commands/ai/boundary.rs 严格对齐,snake_case 原样不做转译 +// ============================================================ + +/** 任务预算(对齐后端 boundary::TaskBudget;null/缺省=不限,继承后端默认量纲) */ +export interface TaskBudget { + max_rounds: number + max_tokens: number + wall_secs: number +} + +/** + * 任务级授权边界(挂会话,随会话同亡;发起时一次声明,存续期内 AI 全自主)。 + * risk_ceiling serde lowercase;入口只允许 low/medium(后端 set 把 high 钳到 medium)。 + * forbid_destructive 恒 true 仅展示,后端判定不读本字段。 + */ +export interface TaskBoundary { + risk_ceiling: 'low' | 'medium' | 'high' + dir_allowlist: string[] + forbid_destructive: boolean + budget: TaskBudget | null +} + // ============================================================ // 知识库 // ============================================================ diff --git a/src/components/ai/ConversationSidebar.vue b/src/components/ai/ConversationSidebar.vue index 5fd08fd..b7ad574 100644 --- a/src/components/ai/ConversationSidebar.vue +++ b/src/components/ai/ConversationSidebar.vue @@ -71,6 +71,10 @@ {{ pendingCountOf(conv.id) }} + + + + {{ formatRelative(conv.updated_at) }} @@ -161,6 +165,10 @@ {{ pendingCountOf(conv.id) }} + + + + {{ formatRelative(conv.updated_at) }} @@ -215,6 +223,10 @@ {{ pendingCountOf(conv.id) }} + + + + {{ formatRelative(conv.updated_at) }} @@ -279,6 +291,9 @@ import { useI18n } from 'vue-i18n' import { useAiStore } from '../../stores/ai' // V1 只读消费:跨会话待审批真相源(state.pendingApprovals 仅是活跃会话投影,后台会话挂起不进投影) import { convPendingApprovals } from '../../composables/ai/aiShared' +// 无人值守边界徽标只读消费:getConvBoundary 读镜像(reactive Map,模板内读取可追踪; +// 镜像由边界配置卡在切会话/写入时渐进填充,无批量 IPC 属契约限制) +import { getConvBoundary } from '../../composables/ai/useTaskBoundary' // UX走查 P2-7 只读订阅:rename/archive/pin 失败通知由 useAiConversations 发布,本组件弹就近 toast import { convOpFailNotice } from '../../composables/ai/useAiConversations' import { useToast } from '../../composables/useToast' @@ -508,6 +523,11 @@ function pendingCountOf(convId: string): number { return pendingCountByConv.value.get(convId) ?? 0 } +/** 该会话是否已授权无人值守边界(徽标显隐)。直接读 reactive Map,渲染效应自动追踪增删 */ +function hasBoundary(convId: string): boolean { + return getConvBoundary(convId) !== null +} + // ── 对话分组:今天 / 昨天 / 更早 ── const bucketLabels = computed>(() => ({ today: t('aiChat.today'), @@ -842,6 +862,19 @@ body.ai-sidebar-resizing * { text-align: center; cursor: help; } +/* 无人值守边界徽标(盾形,accent 色系与「已授权」语义一致;纯标记不计数) */ +.ai-conv-boundary-badge { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 12px; + height: 12px; + border-radius: 6px; + background: color-mix(in srgb, var(--df-accent) 18%, transparent); + color: var(--df-accent); + cursor: help; +} .ai-conv-item-actions { display: none; align-items: center; diff --git a/src/components/ai/TaskBoundaryCard.vue b/src/components/ai/TaskBoundaryCard.vue new file mode 100644 index 0000000..71f9712 --- /dev/null +++ b/src/components/ai/TaskBoundaryCard.vue @@ -0,0 +1,530 @@ + + + + + diff --git a/src/components/ai/TaskParkedCard.vue b/src/components/ai/TaskParkedCard.vue new file mode 100644 index 0000000..c092dfb --- /dev/null +++ b/src/components/ai/TaskParkedCard.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/src/components/ai/TopBar.vue b/src/components/ai/TopBar.vue index ae9ea4e..cf8c982 100644 --- a/src/components/ai/TopBar.vue +++ b/src/components/ai/TopBar.vue @@ -119,6 +119,20 @@
{{ currentConvTitle }}
+ +
+ + {{ taskProgressText }} + + + +
@@ -217,6 +231,7 @@ import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue' import { useI18n } from 'vue-i18n' import { useAiStore } from '../../stores/ai' import { aiApi } from '../../api/ai' +import { pendingTaskProgress } from '../../composables/ai/useAiPendingState' import type { AiMessage, ModelConfig, GoalEntry } from '../../api/types' defineProps<{ @@ -356,6 +371,28 @@ const currentConvTitle = computed(() => { return conv?.title || '' }) +// ── 无人值守任务进度(设计 §4.3,AiTaskProgress per-conv 快照)── +// 消费 useAiEvents 归集的 pendingTaskProgress(per-conv Map,终局即清):无快照不渲染。 +// 切会话自动跟随(Map 按 activeId 取),多会话并发各显各的,不串台。 +const taskProgress = computed(() => { + const id = store.state.activeConversationId + if (!id) return null + return pendingTaskProgress.get(id) ?? null +}) +const taskProgressText = computed(() => { + const p = taskProgress.value + if (!p) return '' + const tokens = p.tokensUsed >= 1000 ? `${(p.tokensUsed / 1000).toFixed(1)}k` : String(p.tokensUsed) + return p.maxRounds > 0 + ? t('ai.taskProgress.running', { round: p.round, max: p.maxRounds, tokens }) + : t('ai.taskProgress.unlimited', { round: p.round, tokens }) +}) +const taskProgressPct = computed(() => { + const p = taskProgress.value + if (!p || p.maxRounds <= 0) return 0 + return Math.min(100, Math.round((p.round / p.maxRounds) * 100)) +}) + // 🎯 对话目标面板(对话透明化 L1:Goal visibility) const goals = ref([]) const goalsExpanded = ref(false) @@ -744,7 +781,10 @@ onBeforeUnmount(() => { color: var(--df-text-secondary); min-width: 0; margin-right: auto; - + /* 长标题单行截断(防换行撑高工具行;右侧徽章族 flex-shrink:0,分离窄窗口更挤) */ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .ai-bottom-tools-right { display: flex; @@ -753,6 +793,45 @@ onBeforeUnmount(() => { flex-shrink: 0; margin-left: auto; } +/* 无人值守任务进度(设计 §4.3):绿点+轮次/token 文本+细进度条,与右侧徽章族同排 */ +.ai-task-progress { + display: inline-flex; + align-items: center; + gap: 5px; + margin-right: 4px; + white-space: nowrap; +} +.ai-task-progress-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--df-success); + flex-shrink: 0; + animation: ai-task-progress-pulse 1.6s ease-in-out infinite; +} +.ai-task-progress-text { + font-size: 11px; + color: var(--df-text-secondary); +} +.ai-task-progress-bar { + display: inline-block; + width: 48px; + height: 3px; + border-radius: 2px; + background: color-mix(in srgb, var(--df-text-dim) 25%, transparent); + overflow: hidden; +} +.ai-task-progress-fill { + display: block; + height: 100%; + border-radius: 2px; + background: var(--df-success); + transition: width .4s var(--df-ease); +} +@keyframes ai-task-progress-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} /* ═══ Provider Bar (header 中间区) ═══ */ .ai-provider-bar { display: inline-flex; diff --git a/src/composables/ai/useAiConversations.ts b/src/composables/ai/useAiConversations.ts index 1b77db9..318e4f6 100644 --- a/src/composables/ai/useAiConversations.ts +++ b/src/composables/ai/useAiConversations.ts @@ -10,9 +10,10 @@ import { useAppSettingsStore } from '@/stores/appSettings' import { state } from '@/stores/ai' import { persistUiState } from './useAiPanel' import { setStreaming } from './streamingGuard' -import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText, notifyConversationChanged, setConvPendingApprovals, discardConvPendingApprovals } from './aiShared' +import { emitDetachedOpenConversation, registerDetachedOpenConvListener } from './useAiWindow' +import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText, notifyConversationChanged, setConvPendingApprovals, discardConvPendingApprovals, backfillConvAgentFinished } from './aiShared' import { t } from '@/i18n/i18n-helpers' -import type { AiConversationDetail, AiMessage, AiToolCallInfo, ConvId, MessageId } from '@/api/types' +import type { AiConversationDetail, AiMessage, AiToolCallInfo, AiToolCallStatus, ConvId, MessageId } from '@/api/types' const appSettings = useAppSettingsStore() @@ -154,6 +155,12 @@ function getLoadMoreState() { return loadMoreCursor } +/** 会话是否处于在途生成态(非终止三态;switchConversation/loadMoreHistory 回载口径一致) */ +function isConvGenerating(convId: string): boolean { + const cs = getConvState(convId) + return cs === 'generating' || cs === 'stopping' || cs === 'compressed' +} + /** * 后端 ChatMessage[] → 前端 AiMessage[](switch + load_more 共用)。 * 过滤/映射语义与 switchConversation 原内联逻辑逐行对齐(tool 消息 + truncated 软删剔除、 @@ -161,8 +168,12 @@ function getLoadMoreState() { * id:优先后端真实消息 id(DB 主键,prepend 时 v-for key 稳定),缺失兜底 `${fallbackPrefix}-${i}` * (老数据无 id / 旧 messages JSON 列)。fallbackPrefix 区分 switch('loaded')与 load_more('older'), * 防两条 id 方案在 prepend 后冲突。 + * convGenerating:目标会话是否在途生成。主 loop 在工具执行前已落库带 tool_calls 的 assistant + * 消息,回载时无 tool_result 的 tool_call 不能恒标 completed(UX走查 P1-2:运行期切回父会话 + * 见「已完成」幽灵态);在途时标 running 由后续 AiToolCallCompleted 按 id 推进终态。 + * 会话不在生成(后端崩溃残留等)无更好真相,维持 completed 原行为。 */ -function parseConvMessages(rawMsgs: any[], fallbackPrefix: string): AiMessage[] { +function parseConvMessages(rawMsgs: any[], fallbackPrefix: string, convGenerating = false): AiMessage[] { // 构建 tool_call_id → tool_result 映射,用于回填工具执行结果 const toolResultMap = new Map() for (const m of rawMsgs) { @@ -227,7 +238,9 @@ function parseConvMessages(rawMsgs: any[], fallbackPrefix: string): AiMessage[] id: tc.id, name: tc.function?.name || '', args, - status: 'completed' as const, + status: (toolResultMap.has(tc.id) || !convGenerating + ? 'completed' + : 'running') as AiToolCallStatus, result: toolResultMap.get(tc.id), } }), @@ -248,7 +261,7 @@ async function loadMoreHistory(): Promise { // 过期丢弃:等待期间用户切走/删除会话,游标已指向别的 conv → 不 prepend 错视图 if (cur.convId !== state.activeConversationId) return false const rawMsgs = typeof res.messages === 'string' ? JSON.parse(res.messages) : res.messages - const older = parseConvMessages(rawMsgs, 'older') + const older = parseConvMessages(rawMsgs, 'older', isConvGenerating(cur.convId)) // 按 id 去重(防御:游标边界变化 / 与已加载消息重复) const existing = new Set(state.messages.map(m => m.id)) const fresh = older.filter(m => !existing.has(m.id)) @@ -272,12 +285,16 @@ async function loadMoreHistory(): Promise { /** 切换到指定会话:加载历史消息(含 tool_calls 回填 + tool_result 映射 + pending 审批恢复) * @param force true=跳过同会话短路(程序化刷新当前会话用:清空/压缩后回刷,如 AiChat.vue - * onCleared/onCompressed 回调);false(默认)=同会话点击直接 return。 */ -export async function switchConversation(id: string, force = false) { + * onCleared/onCompressed 回调);false(默认)=同会话点击直接 return。 + * @param opts.noCreateFallback true=「对话不存在」时不再兜底新建会话,返回 'not_found' + * 交调用方反馈(UX走查 P3-7:程序化跳转命中已删会话,静默新建落空白对话)。 + * @returns 'not_found' 仅在 noCreateFallback 且目标会话不存在时;其余路径(含瞬态失败, + * 已保留原视图并推气泡)一律 'ok'。 */ +export async function switchConversation(id: string, force = false, opts?: { noCreateFallback?: boolean }): Promise<'ok' | 'not_found'> { // 同会话点击短路:已活跃会话不重拉。原实现无条件走 currentText='' + 全量 parse + // pending 重恢复,流式中点侧栏高亮项会清空流式文本、delta 从空续流 → 回复缺前缀。短路保留 // in-flight 流式文本。程序化刷新(force=true)不受影响。 - if (!force && id === state.activeConversationId) return + if (!force && id === state.activeConversationId) return 'ok' // 切换会话时清空所有审批计时器(防切走后过期 timer 误拒后台挂起审批 + 错误气泡推错视图)。 // 新会话的审批计时器由下文恢复挂起路径的 startApprovalTimer 重新建立。 clearAllApprovalTimers() @@ -294,9 +311,14 @@ export async function switchConversation(id: string, force = false) { // 瞬态 IPC 失败(网络抖动/后端异常)保留原视图 + 推错误气泡,不再吞当前视图。 const errMsg = e instanceof Error ? e.message : String(e) if (errMsg.includes('对话不存在')) { + // 程序化跳转(P3-7):目标已删不静默新建,留原视图由调用方就地反馈 + if (opts?.noCreateFallback) { + console.warn('[AI] switchConversation: 会话不存在(已删除),保持原视图:', id) + return 'not_found' + } console.warn('[AI] switchConversation 失败(对话不存在),创建新对话:', id) const created = await aiApi.createConversation() - if (mySwitchId !== _latestSwitchId) return + if (mySwitchId !== _latestSwitchId) return 'ok' void appSettings.set('df-ai-active-conv', created.id) // 兜底置 activeId:此路径原只持久化不写 state.activeConversationId,旧(不存在)id 残留 // 会让后续所有事件被 isCurrent 判为非当前而丢弃或误路由 @@ -313,15 +335,15 @@ export async function switchConversation(id: string, force = false) { setConvPendingApprovals(created.id, []) notifyConversationChanged() scheduleConversationsRefresh() - return + return 'ok' } // 瞬态失败:保留当前视图 + 错误气泡(复用会话操作失败气泡模式);过期响应丢弃。 // 保留 scheduleConversationsRefresh():陈旧 id(已被后端删除)在下一次列表刷新中消失。 console.error('[AI] switchConversation 失败(瞬态),保留当前视图:', e) - if (mySwitchId !== _latestSwitchId) return + if (mySwitchId !== _latestSwitchId) return 'ok' pushConvOpFail('switchConvFail') scheduleConversationsRefresh() - return + return 'ok' } finally { // 切换结束(含失败/过期/新建会话路径)清切换中标记 + 统一收集并清空缓冲。 // 成功提交路径在下方用 _switchBufferedText 恢复续显;失败/过期路径缓冲被丢弃, @@ -331,7 +353,7 @@ export async function switchConversation(id: string, force = false) { setConvCurrentText(id, '') } // 过期响应丢弃(用户已切到别的对话,防 A 后返回覆盖 B) - if (mySwitchId !== _latestSwitchId) return + if (mySwitchId !== _latestSwitchId) return 'ok' // 透传 has_more/earliest_seq 游标(前端滚顶加载更多历史)。 // 后端在 generating/空对话等场景返 false/null,此处默认兜底。 const switchDetailAny = detail as any @@ -343,8 +365,7 @@ export async function switchConversation(id: string, force = false) { // (输入区 v-if=streaming)→ 点击停止会传 activeConversationId 发错会话。对齐新建会话的 // 复位语义;目标在后台生成时保留 true(停止/流式显示正确)。读会话状态(枚举真相源)派生, // 桥接语义:非终止三态(generating/stopping/compressed)视为生成中。 - const targetCs = getConvState(id) - const targetGen = targetCs === 'generating' || targetCs === 'stopping' || targetCs === 'compressed' + const targetGen = isConvGenerating(id) // 先 parse 成功替换 messages,成功后再置 activeConversationId(同一同步块内赋值, // 无 await 间隙 → 无中间态渲染)。原顺序先置 active 后 parse,parse 失败时 active 已是新 id 而 @@ -356,7 +377,8 @@ export async function switchConversation(id: string, force = false) { // 消息映射收敛进 parseConvMessages(切换 + 加载更多共用,过滤/映射语义一致)。 // 相比原内联逻辑唯一行为变化:id 由 `loaded-${i}` 改为优先后端真实消息 id // (DB 主键,prepend 时 v-for key 稳定不重建 DOM),缺失才兜底 `loaded-${i}`。 - state.messages = parseConvMessages(rawMsgs, 'loaded') + // targetGen 透传(UX走查 P1-2):在途回载时无结果的 tool_call 标 running。 + state.messages = parseConvMessages(rawMsgs, 'loaded', targetGen) // parse 成功才置 active(保证 active 与 messages 一致) state.activeConversationId = id // 切到 round0 进行中的会话时,后端该轮 per_conv 只有 user 消息(assistant 占位 @@ -393,12 +415,17 @@ export async function switchConversation(id: string, force = false) { isError: true, timestamp: Date.now(), } as AiMessage) - return + return 'ok' } // 切换提交完成才持久化活跃会话 + 重算 streaming(active 已指向目标 conv) void appSettings.set('df-ai-active-conv', id) setStreaming(targetGen, { convId: id, reason: 'switchConversation-recompute' }) + // P2-2 完成卡回补:重启/分离窗回收后内存卡清零,按父 conv 反查子代理轨迹重建。 + // 启动恢复也走本函数(loadConversations 的 switchConversation(pending)),一处挂载全覆盖; + // fire-and-forget 不阻塞切换,幂等去重与静默降级见 backfillConvAgentFinished。 + void backfillConvAgentFinished(id) + // 加载历史对话 token 总量(来自 DB summary);切换对话清空实时值 const conv = state.conversations.find(c => c.id === id) if (conv && conv.prompt_tokens != null && conv.completion_tokens != null) { @@ -429,7 +456,7 @@ export async function switchConversation(id: string, force = false) { try { const pending = await aiApi.pendingToolCalls(id) // 第二 await 后二次比对:切换 A→B 期间避免用 A 的挂起覆写 B 的 pendingApprovals - if (mySwitchId !== _latestSwitchId) return + if (mySwitchId !== _latestSwitchId) return 'ok' if (pending.length) { // kind 索引:tool_call_id → kind('risk'/'path'),供恢复工具卡/挂起列表按类型渲染 const pendingKindMap = new Map(pending.map(p => [p.tool_call_id, p.kind])) @@ -478,6 +505,7 @@ export async function switchConversation(id: string, force = false) { state.pendingApprovals = [] } } + return 'ok' } /** 删除会话;若删的是当前活跃会话则清空消息+移除活跃 id 持久化。 @@ -541,13 +569,16 @@ async function deleteConversation(id: string) { notifyConversationChanged({ deletedConvId: id }) } -/** 会话操作失败气泡的 i18n key(M30 族 rename/archive/pin + G3.4 new/delete + G3.3 switch 瞬态失败) */ +/** 会话操作失败气泡的 i18n key(M30 族 rename/archive/pin + G3.4 new/delete + G3.3 switch 瞬态失败 + * + convDeleted 关联会话跳转命中已删会话) */ type ConvOpFailKey = | 'renameConvFail' | 'archiveConvFail' | 'pinConvFail' | 'newConvFail' | 'deleteConvFail' | 'switchConvFail' + | 'convDeleted' /** UX走查 P2-7:rename/archive/pin 失败的侧栏就近通知(发布-订阅)。每次失败发布新对象, - * ConversationSidebar watch 到即弹 toast;本模块不感知展示细节(翻译/计时归订阅方)。 */ + * ConversationSidebar watch 到即弹 toast;本模块不感知展示细节(翻译/计时归订阅方)。 + * convDeleted(P3-7/P1-1 分离窗路径)也走此通道:反馈须落在承接会话的窗口内。 */ export const convOpFailNotice = ref<{ key: ConvOpFailKey params?: Record @@ -657,17 +688,33 @@ function toggleSidebar() { } /** 跨视图跳转会话(S4 TaskDrawer「关联会话」入口):AI 无路由,跳转 = 面板展开 + 切会话。 - * 主窗口面板未展开时先展开再切换;detached(分离窗口)下主窗口无面板,只切会话不动面板。 */ -async function openConversation(id: string) { + * 主窗口面板未展开时先展开再切换;detached(分离窗口)下主窗口无面板,emit 跨窗事件由 + * 分离窗切会话并聚焦(UX走查 P1-1:原实现只切主窗内存态,分离窗不跟随不聚焦,感知「点了没反应」)。 + * 跳转属程序化导航:命中已删会话不静默新建(P3-7),返回 'not_found' 由调用方就地反馈; + * detached 路径的实际切换结果落在分离窗,本窗一律返回 'ok'。 */ +async function openConversation(id: string): Promise<'ok' | 'not_found'> { + if (state.detached) { + await emitDetachedOpenConversation(id) + return 'ok' + } // 已展开不重复写 persistUiState(省一次 debounced SQLite 写) - if (!state.detached && !state.panelOpen) { + if (!state.panelOpen) { state.panelOpen = true persistUiState() } - await switchConversation(id) + return switchConversation(id, false, { noCreateFallback: true }) +} + +/** 分离窗消费跨窗切会话事件(P1-1):本地切会话;命中已删会话经 convOpFailNotice 由本窗 + * 侧栏弹 toast(事件发起方在主窗口,反馈必须落在承接会话的分离窗内)。 */ +async function handleDetachedOpenConversation(convId: string) { + const res = await switchConversation(convId, false, { noCreateFallback: true }) + if (res === 'not_found') convOpFailNotice.value = { key: 'convDeleted' } } export function useAiConversations() { + // 跨窗切会话监听注册(幂等;label 非分离窗不实际挂 listener,主窗口为事件发起方) + registerDetachedOpenConvListener(handleDetachedOpenConversation) return { loadConversations, scheduleConversationsRefresh, diff --git a/src/composables/ai/useAiEvents.ts b/src/composables/ai/useAiEvents.ts index 1166e4b..faa2096 100644 --- a/src/composables/ai/useAiEvents.ts +++ b/src/composables/ai/useAiEvents.ts @@ -12,6 +12,7 @@ //! useAiEvents() 返回 shape 保持不变(stores/ai.ts spread 形状不变 → 组件零改动)。 import { listen, emit } from '@tauri-apps/api/event' +import { t } from '@/i18n/i18n-helpers' import { aiApi } from '@/api' import { useAppSettingsStore } from '@/stores/appSettings' import { state } from '@/stores/ai' @@ -19,9 +20,11 @@ import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState import { resetStreamWatchdog, clearAllStreamWatchdogs, clearResumeAttempts } from './useAiStream' import { setStreaming } from './streamingGuard' import { scheduleConversationsRefresh } from './useAiConversations' +import { pendingTaskParked, pendingTaskProgress } from './useAiPendingState' +import { clearConvBoundaryCache } from './useTaskBoundary' import { handleStreamingEvent } from './useAiStreamingEvents' import { handleToolEvent } from './useAiToolEvents' -import { handleLifecycleEvent } from './useAiLifecycleEvents' +import { handleLifecycleEvent, recordTaskParked } from './useAiLifecycleEvents' import type { AiChatEvent, MessageId } from '@/api/types' // 跨域共享 helper re-export(原为 useAiEvents 导出,拆分后从 aiShared/叶子透传,消费方 import 路径不变) @@ -40,9 +43,12 @@ let _startPromise: Promise | null = null const appSettings = useAppSettingsStore() -// 看门狗不重置的事件集合(审批等待/完成/错误/达 max/路径授权/求助由各自 case 内 clear)。 +// 看门狗不重置的事件集合(审批等待/完成/错误/达 max/路径授权/求助/停靠由各自 case 内 clear)。 // 模块级 Set 复用,避免每事件(delta/token 高频)新建数组字面量做 includes。 -const NO_RESET_WATCHDOG = new Set(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired', 'AiHelpRequired']) +const NO_RESET_WATCHDOG = new Set(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired', 'AiHelpRequired', 'AiTaskParked']) + +// 后台会话完成 toast 的摘要截长(轻提醒只够判断「做完了/值不值得切回去看」) +const AGENT_FINISHED_TOAST_SUMMARY_MAX = 40 /** 全局审批类事件:跨会话放行,不因非当前会话被过滤。审批挂起是等用户决策的跨会话资产, * 丢弃后用户在切回该会话前没有任何感知与处理入口;归口仍按 conversation_id 写 per-conv @@ -73,6 +79,8 @@ function handleAgentFinishedEvent(event: AiChatEvent): boolean { status: AgentFinishedStatus rounds: number tokens: number + rejected_tools?: string[] | null + stop_kind?: string | null } const convId = event.conversation_id if (!convId || !ev.agent_id) return true @@ -82,13 +90,73 @@ function handleAgentFinishedEvent(event: AiChatEvent): boolean { status: ev.status, rounds: ev.rounds, tokens: ev.tokens, + // 后端 Option→null/缺省,卡片语义统一为缺省=零拒绝 + rejected_tools: ev.rejected_tools ?? undefined, + // 结构化终止原因(可选,旧后端不发):存卡供卡面状态词优先消费,替代 summary 启发式 + stop_kind: ev.stop_kind ?? undefined, }) + // P3-6:后台会话完成轻提醒(当前会话卡已在消息流可见,不弹)。 + // composable 无组件上下文,复用 ai-toast 事件总线先例(ai-tool-slow-toast),AiChat listen 弹本地 toast; + // 多个子代理连发时 toast 单槽自然合并,无风暴面。 + if (convId !== state.activeConversationId) { + // 未知 status 兜底按 completed 语气(防 t 回显键名);failed 用 warning 色与完成区分 + const statusKey = ev.status === 'failed' ? 'failed' : ev.status === 'truncated' ? 'truncated' : 'completed' + const collapsed = ev.summary.replace(/\s+/g, ' ').trim() + const summary = collapsed.length > AGENT_FINISHED_TOAST_SUMMARY_MAX + ? collapsed.slice(0, AGENT_FINISHED_TOAST_SUMMARY_MAX) + '…' + : collapsed + void emit('ai-toast', { + msg: t('ai.agentFinishedToast', { status: t(`ai.agentFinished.${statusKey}`), summary }), + type: ev.status === 'failed' ? 'warning' : 'info', + }) + } // 子代理执行是该会话的真实活跃信号(父 loop 在 spawn_agent 内等待):此前该事件穿透到 // 活跃事件分支会重置看门狗,现提前归集后显式补上,保持既有不误判停滞的语义。 resetStreamWatchdog(convId) return true } +/** 无人值守停靠域:消费 AiTaskParked(per-conv 落卡 + 后台会话收敛,挂起家族终局)。 + * 返回 true=已处理。仅拦截后台会话:当前会话放行到 lifecycle 域统一收尾(看门狗/流式复位 + * 在 cleanupTerminatedConversation,后台分支此处的收敛集合与其对齐但不重复触发两次)。 + * 停靠卡必须 per-conv 落卡:多会话并发各自可停靠,后台会话的卡丢了用户切回前无从得知。 */ +function handleTaskParkedEvent(event: AiChatEvent): boolean { + if (event.type !== 'AiTaskParked') return false + const convId = event.conversation_id || state.activeConversationId + if (convId && convId !== state.activeConversationId) { + recordTaskParked(convId, event.reason, event.stop_kind ?? undefined) + // 对齐 AiCompleted 家族后台分支的收敛:删状态项回不在生成、清 per-conv 流式态/去重记录/ + // 挂起审批集合/自动续跑计数/进度快照,再刷新侧栏(防抖调度) + convStates.delete(convId) + clearConvStreamState(convId) + clearLastDelta(convId) + discardConvPendingApprovals(convId) + clearResumeAttempts(convId || undefined) + pendingTaskProgress.delete(convId) + scheduleConversationsRefresh() + return true + } + return false +} + +/** 无人值守进度域:消费 AiTaskProgress(per-conv 快照,静默期「在干活」可见)。 + * 返回 true=已处理。isCurrent 守卫之前归集(同停靠卡范式):后台会话的进度也要落 Map, + * 切回仍可见。进度事件即活体信号,顺带 resetStreamWatchdog(子代理长跑/重入退避期间 + * 后端心跳之外的第二重存活证明,防看门狗误判停滞)。 */ +function handleTaskProgressEvent(event: AiChatEvent): boolean { + if (event.type !== 'AiTaskProgress') return false + const convId = event.conversation_id || state.activeConversationId + if (!convId) return true + pendingTaskProgress.set(convId, { + round: event.round, + maxRounds: event.max_rounds, + tokensUsed: event.tokens_used, + updatedAt: Date.now(), + }) + resetStreamWatchdog(convId) + return true +} + /** 跨端用户消息域:miniapp→桌面 user 气泡同步(仅处理 AiUserMessage)。 * 去重:末条已是同 content 的 user 则跳过(防本地乐观 push + 事件回灌双气泡)。 */ function handleUserMessageEvent(event: AiChatEvent): boolean { @@ -128,6 +196,10 @@ export function handleEvent(event: AiChatEvent) { if (handleUserMessageEvent(event)) return // S1 子代理完成卡归集:在 isCurrent 守卫之前处理(per-conv 落卡,后台会话不丢卡) if (handleAgentFinishedEvent(event)) return + // 无人值守停靠卡归集:在 isCurrent 守卫之前处理(per-conv 落卡,后台会话不丢卡) + if (handleTaskParkedEvent(event)) return + // 无人值守进度快照归集:在 isCurrent 守卫之前处理(per-conv 落 Map,后台会话不丢进度) + if (handleTaskProgressEvent(event)) return // 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏; // 全局审批类事件例外(isGlobalApprovalEvent),放行到下方按域分派 const isCurrent = !convId || convId === state.activeConversationId @@ -145,6 +217,8 @@ export function handleEvent(event: AiChatEvent) { clearLastDelta(convId || '') discardConvPendingApprovals(convId || '') clearResumeAttempts(convId || undefined) + // 终态即清进度快照(事件即逝态,会话已终止不留陈旧「进行中」) + pendingTaskProgress.delete(convId || '') // FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。 scheduleConversationsRefresh() // 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列) @@ -168,8 +242,10 @@ export function handleEvent(event: AiChatEvent) { // 无目标会话(事件缺 convId 且当前无活跃会话):流式 delta 无处累积,跳过写防 null 键无意义写入 if (!convId && !state.activeConversationId && event.type === 'AiTextDelta') return // 活跃事件(delta/工具/新轮/审批结果)到达即标记该会话生成中(enum 兜底写入口; - // 后端 AiConvStateChanged{generating} 已先到时同值 set 幂等覆盖,无副作用) - if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError' && event.type !== 'AiHelpRequired') { + // 后端 AiConvStateChanged{generating} 已先到时同值 set 幂等覆盖,无副作用)。 + // 终态家族(AiCompleted/AiError/AiHelpRequired/AiTaskParked)除外:标记生成中会与 + // 收尾的删项收敛打架(停靠后端已复位 generating,不能被兜底写回) + if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError' && event.type !== 'AiHelpRequired' && event.type !== 'AiTaskParked') { setConvState(convId, 'generating') } // 流式看门狗:活跃事件重置;审批等待/完成/错误在 case 内 clear(per-conv,各会话独立计时) @@ -206,6 +282,10 @@ export async function startListener() { // S1:会话删除连带清其完成卡(Map 不持已删会话条目;存在即清,不限当前视图) if (deleted) { clearConvAgentFinished(deleted) + // 无人值守三件随会话同亡:停靠卡挂起 + 进度快照 + 边界镜像(边界生命周期=会话存续,设计 §二) + pendingTaskParked.delete(deleted) + pendingTaskProgress.delete(deleted) + clearConvBoundaryCache(deleted) } }) _unlistenApprovalClear = await listen('ai-approval-clear-timers', () => { clearAllApprovalTimers() }) diff --git a/src/composables/ai/useAiLifecycleEvents.ts b/src/composables/ai/useAiLifecycleEvents.ts index 2b45bb9..f0b9410 100644 --- a/src/composables/ai/useAiLifecycleEvents.ts +++ b/src/composables/ai/useAiLifecycleEvents.ts @@ -1,6 +1,6 @@ //! 生命周期事件域 — handleLifecycleEvent + cleanupTerminatedConversation //! -//! 处理:AiCompleted / AiError / AiHelpRequired + 上下文生命周期事件(AiCompressing/ +//! 处理:AiCompleted / AiError / AiHelpRequired / AiTaskParked + 上下文生命周期事件(AiCompressing/ //! AiManualCompressed/AiAutoCompressed/AiContextCleared)。 //! 依赖方向:本模块 → aiShared / useAiPendingState(叶子)/ useAiStream / streamingGuard / //! useAiConversations,不导入任何同级子 composable。 @@ -10,7 +10,7 @@ import { useAppSettingsStore } from '@/stores/appSettings' import { state } from '@/stores/ai' import { t } from '@/i18n/i18n-helpers' import { nextMsgId, convStates, clearApprovalTimer, flushCurrentText, clearAllToolSlowTimers, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, notifyQueueCleared } from './aiShared' -import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths } from './useAiPendingState' +import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths, pendingTaskParked, pendingTaskProgress } from './useAiPendingState' import { clearStreamWatchdog, clearResumeAttempts } from './useAiStream' import { setStreaming } from './streamingGuard' import type { AiChatEvent, AiMessage, MessageId } from '@/api/types' @@ -43,14 +43,16 @@ function isShowTokenUsage(): boolean { } /** - * 公共会话终止收尾(AiCompleted/AiError/AiHelpRequired 共用):清看门狗/计时器/流式态/挂起。 + * 公共会话终止收尾(AiCompleted/AiError/AiHelpRequired/AiTaskParked 共用):清看门狗/计时器/流式态/挂起。 * * 语义差异(调用方自行处理): * - AiCompleted: 调用后追加 incomplete 气泡 / token 用量 / 队列 drain * - AiError: 调用后追加错误气泡 + 清 queue * - AiHelpRequired: 调用后翻 pendingHelp 驱动求助卡 + * - AiTaskParked: 调用后登记 pendingTaskParked 驱动停靠卡(摘旧卡;不清 pendingHelp, + * 熔断并存场景见下方 pendingHelp 分支注释) */ -function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | 'AiError' | 'AiHelpRequired') { +function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | 'AiError' | 'AiHelpRequired' | 'AiTaskParked') { clearStreamWatchdog(convId || undefined) // 正常收尾路径清该会话的 delta 去重记录:flushCurrentText 只清活跃会话的,这里按 convId // 显式清一遍兜住 convId 与 active 不一致或事件缺 convId 的时序,防残留记录误丢下轮同内容 delta @@ -83,8 +85,16 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | ' pendingMaxRounds.value = null } if (pendingHelp.value && pendingHelp.value.conversationId === convId) { - pendingHelp.value = null + // 熔断场景后端并存发求助卡+停靠卡(agentic/mod.rs 熔断分支先 emit 求助卡再「补发」 + // AiTaskParked,注释自称并存意图):停靠收尾不清求助卡,help options 三选项不丢, + // 两卡并存供用户任选入口。其余终态(AiCompleted/AiError/AiHelpRequired)照旧清。 + if (reason !== 'AiTaskParked') pendingHelp.value = null } + // 无人值守停靠卡:会话有任何终态(含停靠本身)即摘旧卡;停靠 case 在收尾后重新登记, + // 其余终态(AiCompleted/AiError,点继续续跑后的正常收敛/出错)摘卡即消失,语义对齐 pendingHelp + pendingTaskParked.delete(convId) + // 无人值守进度快照:终态即清(事件即逝态,终止后不留陈旧「进行中」;下次 loop 每 5 轮重建) + pendingTaskProgress.delete(convId) // 路径授权挂起仅清本 conv(终止只清本会话弹窗,不连累并发会话;无归属旧项保守保留) pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId !== convId) // 审批收尾仅清目标 conv 的待审批项 + 对称清其超时计时器(其他会话的审批卡/计时器不受影响; @@ -104,6 +114,21 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | ' localStorage.removeItem('df-ai-text') } +/** + * 登记无人值守停靠卡(幂等,convId 键控覆盖)。lifecycle 主路径与 useAiEvents 后台会话 + * 分支共用唯一写入口,防止两处字段口径漂移。convId 缺失兜底当前活跃会话(优于丢卡)。 + */ +export function recordTaskParked(convId: string | null | undefined, reason: string, stopKind?: string): void { + const id = convId || state.activeConversationId + if (!id) return + pendingTaskParked.set(id, { + reason, + stopKind: stopKind || undefined, + conversationId: id, + parkedAt: Date.now(), + }) +} + /** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError)。返回 true=已处理。 */ export function handleLifecycleEvent(event: AiChatEvent): boolean { switch (event.type) { @@ -215,6 +240,18 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean { return true } + case 'AiTaskParked': { + // 无人值守安全停靠(设计 §四):五路非正常终局统一出口,后端已保存现场并复位 + // generating(guard 复位非死等)。对齐 AiHelpRequired 家族:终止态收尾不建错误气泡, + // 改登记 pendingTaskParked 驱动停靠卡(reason + stop_kind 标签 + 继续/停止归档)。 + // 队列不动:停靠等用户拍板,排队消息不自动续发不静默清(继续/停止由用户决定)。 + cleanupTerminatedConversation(event.conversation_id || '', 'AiTaskParked') + recordTaskParked(event.conversation_id, event.reason, event.stop_kind ?? undefined) + // FE1:停靠刷新侧栏(更新活跃时间排序),由 listener 防抖调度统一收口。 + notifyConversationChanged() + return true + } + case 'AiCompressing': _contextCallbacks.onCompressing?.() return true diff --git a/src/composables/ai/useAiPendingState.ts b/src/composables/ai/useAiPendingState.ts index b71eebd..7c2bf74 100644 --- a/src/composables/ai/useAiPendingState.ts +++ b/src/composables/ai/useAiPendingState.ts @@ -5,7 +5,7 @@ //! useAiEvents 拆分后,streaming/tool/lifecycle 三域与组件(ChatInput/MaxRoundsCard/ //! DirAuthDialog/HelpRequiredCard)统一从这里取这些跨域信号。 -import { ref } from 'vue' +import { reactive, ref } from 'vue' import { useAppSettingsStore } from '@/stores/appSettings' const appSettings = useAppSettingsStore() @@ -75,3 +75,29 @@ export interface PendingHelp { conversationId: string | null } export const pendingHelp = ref(null) + +// 无人值守停靠挂起(后端 AiTaskParked 置):convId 键控 Map 而非单 ref。 +// 与 pendingHelp 单 ref 的差异原因:停靠是无人值守长任务的常态终局,多会话并发下 +// 多个会话可各自停靠,单 ref 后到覆盖先到会丢卡(待审批 pendingDirAuths 同款教训)。 +// 消费方(TaskParkedCard)按 activeConversationId 取卡,per-conversation 归位不串台。 +// 幂等:同会话重复停靠按键覆盖(后端单出口单发,覆盖仅防监听重放双卡)。 +export interface PendingTaskParked { + reason: string + /** 后端结构化停止原因(词表 approval_timeout/cancelled/circuit_breaker/stall_breaker/max_rounds/fatal/midstream);旧后端不发为 undefined */ + stopKind?: string + conversationId: string + parkedAt: number +} +export const pendingTaskParked = reactive(new Map()) + +// 无人值守进度快照(后端 AiTaskProgress 每 5 轮置,设计 §4.3):convId 键控 Map(停靠卡同范式, +// 多会话并发各自有进度互不覆盖)。只存最近一次快照(事件即逝,后端不落库);消费方(TopBar +// 进度条)按 activeConversationId 取,静默期(子代理长跑/重入退避)凭最后快照可见「在干活」。 +// 终局(AiCompleted/AiError/AiHelpRequired/AiTaskParked)与会话删除时清理。maxRounds=0=不限轮次。 +export interface PendingTaskProgress { + round: number + maxRounds: number + tokensUsed: number + updatedAt: number +} +export const pendingTaskProgress = reactive(new Map()) diff --git a/src/composables/ai/useTaskBoundary.ts b/src/composables/ai/useTaskBoundary.ts new file mode 100644 index 0000000..4f5d58b --- /dev/null +++ b/src/composables/ai/useTaskBoundary.ts @@ -0,0 +1,61 @@ +//! 无人值守任务边界(per-conv 边界缓存 + IPC 读写) +//! +//! 后端真相源是 per_conv.boundary 内存态 + boundary_json 落库快照(ai_task_boundary_get/set)。 +//! 前端这里维护一份只读镜像 Map,服务两个消费面: +//! - ConversationSidebar 徽标:批量 IPC 不在契约内,镜像按「访问过/设置过」渐进填充 +//! (切会话时配置卡拉取、设置卡写入),未访问会话不显徽标属已接受取舍; +//! - TaskBoundaryCard 回显:打开卡/切会话时读镜像,miss 时拉一次。 +//! +//! 叶子定位:仅依赖 vue + api 层,不导入任何 composable,无环风险。 + +import { reactive } from 'vue' +import { aiApi } from '@/api' +import type { TaskBoundary } from '@/api/types' + +/** convId → 当前边界镜像(无键=无边界或未拉取)。后端 set/get 单发,前端不做节流 */ +export const convBoundaries = reactive(new Map()) + +/** 拉取失败会话集合(镜像 miss 的两种语义须区分:确认无边界 vs 拉取失败。 + * 失败态配置卡禁「一次授权」,防默认空表单静默覆盖库中原授权) */ +export const convBoundaryFetchFailed = reactive(new Set()) + +/** 某会话边界最近一次拉取是否失败(失败态镜像内容不可信) */ +export function isConvBoundaryFetchFailed(convId: string | null | undefined): boolean { + if (!convId) return false + return convBoundaryFetchFailed.has(convId) +} + +/** 读某会话边界镜像;无记录返回 null */ +export function getConvBoundary(convId: string | null | undefined): TaskBoundary | null { + if (!convId) return null + return convBoundaries.get(convId) ?? null +} + +/** 拉取某会话边界并刷新镜像(切会话/打开配置卡时调)。侧栏徽标消费侧保持静默降级; + * 配置卡经 isConvBoundaryFetchFailed 读取失败态(成功即清标记,失败置标记) */ +export async function fetchConvBoundary(convId: string | null | undefined): Promise { + if (!convId) return + try { + const b = await aiApi.getTaskBoundary(convId) + convBoundaryFetchFailed.delete(convId) + if (b) convBoundaries.set(convId, b) + else convBoundaries.delete(convId) + } catch (e) { + convBoundaryFetchFailed.add(convId) + console.warn('[TaskBoundary] 读取边界失败(徽标静默降级,配置卡显失败态):', convId, e) + } +} + +/** 写入某会话边界(boundary 传 null 清除),IPC 成功后才更新镜像(失败不撒谎) */ +export async function saveConvBoundary(convId: string | null | undefined, boundary: TaskBoundary | null): Promise { + if (!convId) return + await aiApi.setTaskBoundary(convId, boundary) + if (boundary) convBoundaries.set(convId, boundary) + else convBoundaries.delete(convId) +} + +/** 清某会话镜像(会话删除时调,防 Map 持已删会话条目) */ +export function clearConvBoundaryCache(convId: string | null | undefined): void { + if (!convId) return + convBoundaries.delete(convId) +} diff --git a/src/i18n/en/ai.ts b/src/i18n/en/ai.ts index a0d7289..59c0786 100644 --- a/src/i18n/en/ai.ts +++ b/src/i18n/en/ai.ts @@ -49,6 +49,8 @@ export default { // conversation load/switch failure feedback (network jitter/IPC down → empty list/blank, was silent) loadConvFail: 'Failed to load conversations. Please check your network or backend connection.', switchConvFail: 'Failed to switch conversation. Please retry.', + // UX走查 P3-7/P1-1: linked-conversation jump hit a deleted conversation (was silently creating a blank one / dead click in detached window) + convDeleted: 'Conversation deleted', // M30: rename/archive/pin IPC failure feedback (was no try/catch, exceptions leaked to unhandledrejection) renameConvFail: 'Failed to rename. Please retry.', archiveConvFail: 'Failed to {action}. Please retry.', @@ -87,11 +89,23 @@ export default { title: 'Sub-agent', completed: 'completed', truncated: 'truncated', + cancelled: 'cancelled', failed: 'failed', + // stop_kind structured stop reasons (replaces summary heuristics; truncation sub-kinds surfaced) + truncMaxRounds: 'max rounds reached', + truncWallClock: 'timed out', + truncTokenBudget: 'token budget reached', + noReport: 'no report produced', + // Rejected-tools chip ({tools} = "tool×count" items, first 2 shown then +N) + rejected: 'rejected {tools}', + // Startup-failed sub-agents have no persisted trace; card is click-disabled + noTrace: 'No trace (failed to start)', meta: '{rounds} rounds · {tokens} tokens', // S3: finished card is clickable to open the read-only trace (hover hint) viewHint: 'Click to view execution trace', }, + // P3-6 background-conversation sub-agent finished light toast ({status} reuses agentFinished status words above, {summary} truncated to 40 chars) + agentFinishedToast: 'Sub-agent {status}: {summary}', // S3 sub-agent trace overlay (opened from the finished card, read-only simple render, // discarded on close) agentTrace: { @@ -102,6 +116,71 @@ export default { roleAssistant: 'Assistant', roleTool: 'Tool result', roleSystem: 'System', + deniedTool: 'Denied: {tool}', + riskLow: 'low risk', + riskMedium: 'medium risk', + riskHigh: 'high risk', + }, + // Unattended task parked card (AiTaskParked, terminal of the pending family): + // Continue re-authorizes the loop, Stop archives without resuming + taskParked: { + title: 'Task parked', + // stop_kind structured stop reason (vocabulary pinned by backend contract; unknown values fall back to unknownKind) + stopKind: { + approval_timeout: 'Approval timed out', + cancelled: 'Cancelled', + circuit_breaker: 'Circuit breaker', + stall_breaker: 'Stall breaker', + max_rounds: 'Max rounds reached', + fatal: 'Repeated failures (retry budget exhausted)', + midstream: 'Stream interrupted', + }, + unknownKind: 'Parked', + stopArchive: 'Stop & Archive', + stoppedToast: 'Task stopped, will not resume', + archiveFailed: 'Stop failed: {msg}', + }, + // Unattended task progress (AiTaskProgress, design §4.3): TopBar lightweight progress, + // visible as "still working" during silent periods + taskProgress: { + running: 'Running · round {round}/{max} · {tokens} tokens', + unlimited: 'Running · round {round} · {tokens} tokens', + barHint: 'Round {round}/{max}', + }, + // Unattended boundary config card (authorized once at kickoff, effective for the task + // lifetime, dies with the conversation) + taskBoundary: { + toggle: 'Unattended', + badge: 'Unattended boundary authorized', + riskCeiling: 'Risk ceiling', + riskLow: 'Low', + riskMedium: 'Medium', + dirs: 'Directory allowlist', + dirPlaceholder: 'Enter absolute directory path', + dirAdd: 'Add', + dirEmpty: 'No directories added', + forbidDestructive: 'Destructive commands always blocked', + budgetToggle: 'Budget (advanced)', + budgetRounds: 'Max rounds', + budgetTokens: 'Max tokens', + budgetWallSecs: 'Wall-clock seconds', + budgetHint: 'Leave empty for unlimited', + budgetInvalid: 'Budget values must be positive integers', + confirmSet: 'Authorize once, no interruptions for the whole task', + clearBoundary: 'Clear boundary', + setOk: 'Unattended boundary authorized', + clearOk: 'Boundary cleared, back to per-action approval', + setFail: 'Failed to set boundary: {msg}', + lockedGenerating: 'Task is running: only clearing (revoke) is allowed, fields are locked', + loadFailed: 'Failed to load boundary; the form below is not trustworthy', + retryLoad: 'Retry', + dirErrIllegal: 'Path contains illegal characters < > : " | ? *', + dirErrRelative: 'Enter an absolute path (e.g. D:\\projects or /home/user)', + clearConfirm: 'Clearing the boundary revokes unattended authorization immediately; tool calls in the running task will fall back to per-action approval. Clear?', + echoRisk: 'risk ceiling {risk}', + echoDirs: '{n} dirs', + echoBudget: 'budget {rounds} rounds / {tokens} tokens / {secs}s', + echoNoBudget: 'no budget limit', }, }, } diff --git a/src/i18n/en/aiChat.ts b/src/i18n/en/aiChat.ts index bd7a653..a49f8c5 100644 --- a/src/i18n/en/aiChat.ts +++ b/src/i18n/en/aiChat.ts @@ -110,6 +110,7 @@ export default { // ── Skill ── clearSkill: 'Clear skill', + skillBadgeTip: 'Injected full skill playbook: {name}', placeholderWithSkill: 'Skill selected. Type arguments and press Enter', placeholderDefault: "Type a message... (/ for skills, {'@'} to mention)", skillNoMatch: 'No matching skill', @@ -141,6 +142,8 @@ export default { imageTooLarge: 'Image too large (max 10MB), skipped', imageLimit: 'Up to {n} images, extras ignored', imageReadFailed: 'Failed to read image', + imageIgnoredWarn: 'Current model does not support images; they will be ignored on send', + imageOnlyBlocked: 'Current model does not support images; switch to a vision model or add some text', // ── Confirm dialog ── confirmDeleteConversation: 'Delete chat "{title}"? This cannot be undone.', diff --git a/src/i18n/zh-CN/ai.ts b/src/i18n/zh-CN/ai.ts index 8941b60..f264a9c 100644 --- a/src/i18n/zh-CN/ai.ts +++ b/src/i18n/zh-CN/ai.ts @@ -49,6 +49,8 @@ export default { // 对话加载/切换失败反馈(网络抖动/IPC 断开 → 列表空/切换空白,原静默无反馈) loadConvFail: '对话列表加载失败,请检查网络或后端连接', switchConvFail: '对话切换失败,请重试', + // UX走查 P3-7/P1-1: 关联会话跳转命中已删会话(原静默新建空会话/分离窗死点击) + convDeleted: '会话已删除', // M30:重命名/归档/置顶 IPC 失败反馈(原无 try/catch 异常进 unhandledrejection,用户无感) renameConvFail: '重命名失败,请重试', archiveConvFail: '{action}失败,请重试', @@ -86,11 +88,23 @@ export default { title: '子代理', completed: '已完成', truncated: '已截断', + cancelled: '已取消', failed: '失败', + // stop_kind 结构化终止原因细分(根治 summary 启发式;截断三态各自透出) + truncMaxRounds: '已达轮次上限', + truncWallClock: '超时截断', + truncTokenBudget: 'token 预算截断', + noReport: '未产出报告', + // 被拒工具 chip({tools} 为「工具名×次数」枚举,多枚前 2 枚 + N) + rejected: '被拒 {tools}', + // 启动失败型子代理未落库无轨迹,卡禁点(hover 说明替代 viewHint) + noTrace: '无轨迹(启动失败)', meta: '{rounds} 轮 · {tokens} tokens', // S3:完成卡可点击查看只读轨迹(hover 提示) viewHint: '点击查看执行轨迹', }, + // P3-6 后台会话子代理完成轻提醒({status} 复用上方 agentFinished 状态词,{summary} 截前 40 字) + agentFinishedToast: '子代理{status}: {summary}', // S3 子代理轨迹浮层(完成卡点击展开,只读简版渲染,关即弃) agentTrace: { title: '子代理执行轨迹', @@ -100,6 +114,68 @@ export default { roleAssistant: '助手', roleTool: '工具结果', roleSystem: '系统', + deniedTool: '被拒:{tool}', + riskLow: '低风险', + riskMedium: '中风险', + riskHigh: '高风险', + }, + // 无人值守安全停靠卡(AiTaskParked,挂起家族终局):继续=重新授权续跑,停止归档=不再续跑 + taskParked: { + title: '任务已停靠', + // stop_kind 结构化停止原因(词表后端契约钉死,未知值走 unknownKind 中性兜底) + stopKind: { + approval_timeout: '审批超时', + cancelled: '已取消', + circuit_breaker: '熔断保护', + stall_breaker: '停滞保护', + max_rounds: '已达轮次上限', + fatal: '连续失败(重入预算耗尽)', + midstream: '响应流中断', + }, + unknownKind: '任务停靠', + stopArchive: '停止归档', + stoppedToast: '任务已停止,不再续跑', + archiveFailed: '停止失败:{msg}', + }, + // 无人值守任务进度(AiTaskProgress,设计 §4.3):TopBar 轻量进度条,静默期可见「在干活」 + taskProgress: { + running: '进行中 · 第 {round}/{max} 轮 · {tokens} tokens', + unlimited: '进行中 · 第 {round} 轮 · {tokens} tokens', + barHint: '第 {round}/{max} 轮', + }, + // 无人值守边界配置卡(发起时一次授权,存续期生效,随会话同亡) + taskBoundary: { + toggle: '无人值守', + badge: '无人值守边界已授权', + riskCeiling: '风险上限', + riskLow: '低', + riskMedium: '中', + dirs: '目录白名单', + dirPlaceholder: '输入目录绝对路径', + dirAdd: '添加', + dirEmpty: '尚未添加目录', + forbidDestructive: '破坏性命令恒禁止', + budgetToggle: '预算(高级)', + budgetRounds: '最大轮次', + budgetTokens: '最大 tokens', + budgetWallSecs: '挂钟秒数', + budgetHint: '留空表示不限', + budgetInvalid: '预算需为正整数', + confirmSet: '一次授权,任务期内不再打扰', + clearBoundary: '清除边界', + setOk: '已授权无人值守边界', + clearOk: '已清除边界,回到逐次审批', + setFail: '边界设置失败:{msg}', + lockedGenerating: '任务运行中,仅支持清除边界(收权),字段不可修改', + loadFailed: '边界读取失败,当前表单不可信', + retryLoad: '重试', + dirErrIllegal: '路径含非法字符 < > : " | ? *', + dirErrRelative: '请输入绝对路径(如 D:\\projects 或 /home/user)', + clearConfirm: '清除边界将立即收回无人值守授权,运行中的任务后续工具调用回到逐次审批。确定清除?', + echoRisk: '风险上限 {risk}', + echoDirs: '{n} 个目录', + echoBudget: '预算 {rounds} 轮 / {tokens} tokens / {secs}s', + echoNoBudget: '预算不限', }, }, } diff --git a/src/i18n/zh-CN/aiChat.ts b/src/i18n/zh-CN/aiChat.ts index 6b0936a..3805fe5 100644 --- a/src/i18n/zh-CN/aiChat.ts +++ b/src/i18n/zh-CN/aiChat.ts @@ -110,6 +110,7 @@ export default { // ── 技能 ── clearSkill: '取消技能', + skillBadgeTip: '已注入技能「{name}」的完整规程', placeholderWithSkill: '已选技能,输入参数后回车', placeholderDefault: "输入消息...(/ 触发技能,{'@'} 引用实体)", skillNoMatch: '无匹配技能', @@ -142,6 +143,8 @@ export default { imageTooLarge: '图片过大(上限 10MB),已跳过', imageLimit: '最多 {n} 张图片,已忽略多余的', imageReadFailed: '图片读取失败', + imageIgnoredWarn: '当前模型不支持图片,发送时图片将被忽略', + imageOnlyBlocked: '当前模型不支持图片,请改用视觉模型或补充文字说明', // ── 确认弹层 ── confirmDeleteConversation: '确定删除对话「{title}」?该操作不可恢复。',