新增: 无人值守P0任务边界全链

- 任务级授权边界判定与审批旁路单点插桩
- boundary_json 随会话落库(V45 可逆迁移)
- 六路非正常终局统一安全停靠与托盘通知
- 每五轮进度事件与会话头部进度条
- 前端边界配置卡侧栏徽标及 UX 校验补齐
This commit is contained in:
lxy
2026-10-04 16:27:30 +08:00
parent 8396849d04
commit b2d44f85e9
29 changed files with 2773 additions and 142 deletions
@@ -108,6 +108,8 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
// origin 列老库经 v44 迁移补建。unwrap_or(None) 兜底(同 message_id v21 模式): // origin 列老库经 v44 迁移补建。unwrap_or(None) 兜底(同 message_id v21 模式):
// 迁移未跑/极端情况防御,NULL=主会话。 // 迁移未跑/极端情况防御,NULL=主会话。
origin: row.get("origin").unwrap_or(None), origin: row.get("origin").unwrap_or(None),
// boundary_json 列老库经 v45 迁移补建。unwrap_or(None) 兜底(NULL=未声明任务边界)。
boundary_json: row.get("boundary_json").unwrap_or(None),
}) })
} }
@@ -183,24 +185,25 @@ impl_repo!(
from_row => |row| ai_conversation_from_row(row), from_row => |row| ai_conversation_from_row(row),
insert => |conn, rec| { insert => |conn, rec| {
conn.execute( 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) "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)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
params![ params![
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 }, if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens, 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| { update => |conn, rec| {
conn.execute( 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![ params![
rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 }, if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens, 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)? .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<bool> {
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 子行(单事务原子)。 /// G1.3: 删除对话 + 其全部 ai_messages 子行(单事务原子)。
/// ///
/// 背景:原宏生成 `delete` 只删 ai_conversations 主行,而 ai_messages 表无外键级联 /// 背景:原宏生成 `delete` 只删 ai_conversations 主行,而 ai_messages 表无外键级联
+59 -6
View File
@@ -51,7 +51,9 @@ pub fn run(conn: &Connection) -> Result<()> {
// 纯数据 UPDATE 不经过 promote 函数,不触发嵌入生成)。 // 纯数据 UPDATE 不经过 promote 函数,不触发嵌入生成)。
// V44 = 子代理轨迹来源标记(ai_conversations.origin TEXT,spawn_agent 出口整轨迹落库, // V44 = 子代理轨迹来源标记(ai_conversations.origin TEXT,spawn_agent 出口整轨迹落库,
// 桌面侧栏/miniapp 列表按 origin 过滤子会话)。 // 桌面侧栏/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), (1, migrate_v1),
(2, migrate_v2), (2, migrate_v2),
(3, migrate_v3), (3, migrate_v3),
@@ -96,6 +98,7 @@ pub fn run(conn: &Connection) -> Result<()> {
(42, migrate_v42), (42, migrate_v42),
(43, migrate_v43), (43, migrate_v43),
(44, migrate_v44), (44, migrate_v44),
(45, migrate_v45),
]; ];
for (version, migrate_fn) in steps { for (version, migrate_fn) in steps {
@@ -1334,6 +1337,23 @@ fn migrate_v44(conn: &Connection) -> Result<()> {
Ok(()) 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 表 /// V21 建表 SQL — 消息拆分存储 ai_messages 表
/// ///
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。 /// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
@@ -2338,7 +2358,7 @@ mod tests {
"tasks.module_id 列缺失(V41 加)" "tasks.module_id 列缺失(V41 加)"
); );
// 3. schema_version 应推进到 43(全量迁移成功落版本号) // 3. schema_version 应推进到 45(全量迁移成功落版本号)
let max_version: i64 = conn let max_version: i64 = conn
.query_row( .query_row(
"SELECT COALESCE(MAX(version), 0) FROM schema_version", "SELECT COALESCE(MAX(version), 0) FROM schema_version",
@@ -2347,8 +2367,8 @@ mod tests {
) )
.expect("查 schema_version 应成功"); .expect("查 schema_version 应成功");
assert_eq!( assert_eq!(
max_version, 44, max_version, 45,
"全量迁移后 schema_version 应为 44(实际 {}),说明某条 migrate_vN 链路断在中间", "全量迁移后 schema_version 应为 45(实际 {}),说明某条 migrate_vN 链路断在中间",
max_version max_version
); );
@@ -2425,7 +2445,7 @@ mod tests {
|r| r.get(0), |r| r.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(max_v, 44, "首轮应推进到 44"); assert_eq!(max_v, 45, "首轮应推进到 45");
// 清空版本表强制全链第二遍(每步 execute 第二次) // 清空版本表强制全链第二遍(每步 execute 第二次)
conn.execute("DELETE FROM schema_version", []).unwrap(); conn.execute("DELETE FROM schema_version", []).unwrap();
@@ -2437,7 +2457,7 @@ mod tests {
|r| r.get(0), |r| r.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(max_v2, 44, "重跑后应重新推进到 44"); assert_eq!(max_v2, 45, "重跑后应重新推进到 45");
} }
// ============================================================ // ============================================================
@@ -2510,4 +2530,37 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(v, 41); 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<String> = conn
.query_row("SELECT boundary_json FROM ai_conversations WHERE id = 'c1'", [], |r| r.get(0))
.unwrap();
assert!(got.is_some(), "boundary_json 应可写可读");
}
} }
+3
View File
@@ -374,6 +374,9 @@ pub struct AiConversationRecord {
/// 会话来源标记(V44):None=主会话;Some("sub_agent")=子代理轨迹(spawn_agent 出口落库), /// 会话来源标记(V44):None=主会话;Some("sub_agent")=子代理轨迹(spawn_agent 出口落库),
/// 桌面侧栏/miniapp 列表按此过滤。不可变列(UPDATE 不写)。 /// 桌面侧栏/miniapp 列表按此过滤。不可变列(UPDATE 不写)。
pub origin: Option<String>, pub origin: Option<String>,
/// 无人值守任务边界快照(V45):TaskBoundary JSON;None=未声明(现状逐次审批)。
/// 生命周期=会话存续(发起时一次声明,随会话同亡),save 随行写/switch 读还原。
pub boundary_json: Option<String>,
} }
/// AI 工具执行审计记录 /// AI 工具执行审计记录
+208 -27
View File
@@ -86,6 +86,18 @@ pub const DEFAULT_MAX_AGENT_RETRIES: usize = 3;
/// N=3 对齐 DEFAULT_MAX_AGENT_RETRIES 量级,3 轮连续饱和才判持续过载,防抖动误降级。 /// N=3 对齐 DEFAULT_MAX_AGENT_RETRIES 量级,3 轮连续饱和才判持续过载,防抖动误降级。
pub const PROVIDER_SATURATION_DEMOTE_THRESHOLD: usize = 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 /// 改进3 B 常量开关 KEYWORD_FALLBACK_ENABLED 已随压缩逻辑迁至 context_lifecycle.rs
/// (在该模块内部为私有 const,文档见 context_lifecycle.rs 顶部)。 /// (在该模块内部为私有 const,文档见 context_lifecycle.rs 顶部)。
/// ///
@@ -154,10 +166,15 @@ pub const MAX_GOALS: usize = 5;
/// system_prompt 零变化,退回改动前行为(单点回退,不影响 augmentation 的 / 联想)。 /// system_prompt 零变化,退回改动前行为(单点回退,不影响 augmentation 的 / 联想)。
pub const SKILL_LIST_INJECT_ENABLED: bool = true; pub const SKILL_LIST_INJECT_ENABLED: bool = true;
/// 技能清单注入最大条数(默认 20)。 /// 技能清单注入最大条数(默认 120)。
/// ///
/// 防本机大量技能全量进 system_prompt 占 sys_tokens 预算;取前 N 条(name 去重序),截断描述。 /// 防本机大量技能全量进 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 字符)。 /// 技能清单单条描述截断长度(默认 80 字符)。
/// ///
@@ -1039,6 +1056,10 @@ pub(crate) async fn run_agentic_loop(
// 仅 per-provider 层非阻塞降级,不影响 global/per_conv 阻塞限流。 // 仅 per-provider 层非阻塞降级,不影响 global/per_conv 阻塞限流。
let mut provider_saturation: HashMap<String, usize> = HashMap::new(); let mut provider_saturation: HashMap<String, usize> = HashMap::new();
// 跨轮任务级重入(ROUND_REENTRY_MAX):连续轮失败计数,Continue 成功即归零
// (只认连续失败,偶发一轮网络故障不消耗后续耐受力)。
let mut consecutive_round_failures: usize = 0;
// 0 = 不限:effective_max=usize::MAX,for 到不了上界,靠 stop_flag/收敛/审批退出(下方达上限暂停分支不触发) // 0 = 不限:effective_max=usize::MAX,for 到不了上界,靠 stop_flag/收敛/审批退出(下方达上限暂停分支不触发)
let effective_max = if max_iterations == 0 { usize::MAX } else { max_iterations }; 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 不生效(尽力兜底)。 // 依赖 panic=unwind(tokio 默认),panic=abort 构建下 catch_unwind 不生效(尽力兜底)。
let outcome = unwind_to_result(async { let outcome = unwind_to_result(async {
for iteration in start_iteration..effective_max { 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/心跳) // ── 轮次准备段:入口 stop/stale 收尾闸门 → 主题标记 → conv 一致性(iteration_used/epoch/心跳)
// → 轮通知 → 自动压缩 → 请求消息构建(build_for_request + namespace 展开 + 易变尾段 // → 轮通知 → 自动压缩 → 请求消息构建(build_for_request + namespace 展开 + 易变尾段
// + tool_result 摘要 + 占位断言)→ estimated_prompt + agentic_req 重建 ── // + tool_result 摘要 + 占位断言)→ estimated_prompt + agentic_req 重建 ──
@@ -1070,29 +1105,81 @@ pub(crate) async fn run_agentic_loop(
PreparedRound::Exit => return, PreparedRound::Exit => return,
}; };
// ── 流式消费段:单轮流式调用(candidate 链 + MidStream 保文)── // ── 流式消费段:单轮流式调用(candidate 链 + MidStream 保文 + 跨轮重入)──
// 实现见 stream_relay::stream_round。RoundOutcome 四态:Continue 携输出继续; // 实现见 stream_relay::stream_round。RoundOutcome 四态:Continue 携输出继续;
// StaleAfterStream(流后 epoch 变,guard 已 disarm)/ MidStreamSaved(保文已完整收尾)/ // StaleAfterStream(流后 epoch 变,guard 已 disarm)/ MidStreamSaved(保文已完整收尾)/
// Fatal(候选 Fatal 或全耗尽,guard.reset + emit + save 已完成)→ 本轮终止 return。 // Fatal(候选 Fatal 或全耗尽,guard.reset + emit + save 已完成)。
let (full_text, tool_calls_acc, round_usage, round_is_estimated) = match stream_round( // 'stream_round 重入层(ROUND_REENTRY_MAX):Fatal 不再立即终局,指数退避后从
&session_arc, &db, &conv_id, // 当前轮重入(stream_relay 每 per-permit 重取重入获全新 30s 预算 + 全新候选链)。
&messages, &tool_defs, &app_handle, let (full_text, tool_calls_acc, round_usage, round_is_estimated, round_truncated) = 'stream_round: loop {
&stop_flag, &notify, match stream_round(
max_retries, &model_override, &agentic_req, estimated_prompt, &session_arc, &db, &conv_id,
&mut last_round_estimated, &mut last_reasoning_content, &messages, &tool_defs, &app_handle,
&mut resolved_model, &mut provider_config, &stop_flag, &notify,
&mut provider_saturation, max_retries, &model_override, &agentic_req, estimated_prompt,
&mut tokens, &mut saved_token_snapshot, &mut guard, &mut last_round_estimated, &mut last_reasoning_content,
&candidates, &llm_concurrency, &mut resolved_model, &mut provider_config,
&pinned_goals_snapshot, &mut provider_saturation,
&loop_epoch_arc, my_epoch, &mut tokens, &mut saved_token_snapshot, &mut guard,
).await { &candidates, &llm_concurrency,
RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated } => { &pinned_goals_snapshot,
(full_text, tool_calls, round_usage, round_is_estimated) &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 持有, // global/per_conv permit 已上移 loop 入口整 loop 持有,
// stream 后不再立即释放(会话级并发语义:工具执行期间也占槽)。per-provider permit // 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, &full_text, &tool_calls_acc, &round_usage, round_is_estimated,
&last_reasoning_content, &last_reasoning_content,
&mut goal_verify_rounds, &mut goal_verify_rounds,
round_truncated,
).await { ).await {
// RunTools → 继续工具调用段(工具调用数由该段内部取,与原时序一致) // RunTools → 继续工具调用段(工具调用数由该段内部取,与原时序一致)
RoundEpilogue::RunTools => {} RoundEpilogue::RunTools => {}
@@ -1182,6 +1270,14 @@ pub(crate) async fn run_agentic_loop(
&loop_epoch_arc, my_epoch, &loop_epoch_arc, my_epoch,
false, false,
).await; ).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; return;
} }
@@ -1661,6 +1757,8 @@ async fn finalize_round_output(
round_is_estimated: bool, round_is_estimated: bool,
last_reasoning_content: &Option<String>, last_reasoning_content: &Option<String>,
goal_verify_rounds: &mut u32, goal_verify_rounds: &mut u32,
// 本轮流因 provider max_tokens 上限截断(P2-1),收敛时据实提示不冒充完整
round_truncated_by_max_tokens: bool,
) -> RoundEpilogue { ) -> RoundEpilogue {
// 累加本轮 token:用 provider 真实 usage。GLM 等流式不报 completion_tokens 时为 0,如实反映(不预估)。 // 累加本轮 token:用 provider 真实 usage。GLM 等流式不报 completion_tokens 时为 0,如实反映(不预估)。
// 分项 token(2026-08-02):cache/reasoning 透传 round。三真相源收口:prompt 估算兜底 + // 分项 token(2026-08-02):cache/reasoning 透传 round。三真相源收口:prompt 估算兜底 +
@@ -1773,6 +1871,20 @@ async fn finalize_round_output(
); );
} }
// 正常收敛(loop 主体置 converged 后 break) // 正常收敛(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; return RoundEpilogue::Converged;
} }
} }
@@ -1817,7 +1929,7 @@ async fn process_round_tools(
// AC-2 ①:记录本轮工具调用数(供超限警告判定;process_tool_calls 会 move 走 tool_calls_acc)。 // AC-2 ①:记录本轮工具调用数(供超限警告判定;process_tool_calls 会 move 走 tool_calls_acc)。
let round_tool_call_count = tool_calls_acc.len(); let round_tool_call_count = tool_calls_acc.len();
// 处理工具调用(Low 自动执行 / Medium+High 待审批;主对话恒 Normal 策略,子循环走 SubAgent) // 处理工具调用(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, // DIRAUTH 审批链已闭环。原 eprintln 诊断降级为 tracing::debug,
// 避免污染 stderr(用户可见),保留排障能力(RUST_LOG=debug 可见)。 // 避免污染 stderr(用户可见),保留排障能力(RUST_LOG=debug 可见)。
tracing::debug!( tracing::debug!(
@@ -1864,6 +1976,13 @@ async fn process_round_tools(
return ToolsOutcome::Exit; return ToolsOutcome::Exit;
} }
emit_circuit_breaker_tripped(app_handle, conv_id, max_count, &sample_key); 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; return ToolsOutcome::Exit;
} }
} }
@@ -1881,6 +2000,12 @@ async fn process_round_tools(
*loop_warned = new_loop_warned; *loop_warned = new_loop_warned;
if tripped { if tripped {
guard.reset().await; guard.reset().await;
// 安全停靠(设计 §四):探索熔断从「等用户」改「存档+通知」,语义对齐五路统一出口。
crate::commands::ai::emit_task_parked(
app_handle, conv_id,
"探索熔断(连续无进展的重复探索),任务安全停靠;已完成进度已保存,补充新方向后重发消息可继续",
"stall_breaker",
);
return ToolsOutcome::Exit; return ToolsOutcome::Exit;
} }
} }
@@ -2309,13 +2434,47 @@ async fn insert_tool_call_over_limit_warning(
.await; .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_fatal_error: 致命错误统一 emit AiError(扁平抽自原 Fatal/Exhausted 双分支重复代码) ──
// 双写经 emit_ai_chat_event。error_type=Network 对齐原 Fatal 分支(鉴权/4xx归类); // 双写经 emit_ai_chat_event。error_type: 可判定的永久性 provider 错误(模型不存在/令牌无权/
// 两端统一 Network(原 publish 端漏传 None 属漂移,2026-10-01 顺带修)。 // 参数拒绝类 400)归 ProviderConfig,其余维持 Network 现状(2026-10-01 两端统一时的口径)。
pub(crate) async fn emit_fatal_error(app_handle: &AppHandle, conv_id: &str, error: &str) { 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 { emit_ai_chat_event(app_handle, AiChatEvent::AiError {
error: error.to_string(), error: error.to_string(),
error_type: Some(ErrorType::Network), error_type: Some(error_type),
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}); });
// P1 bg 化:失败系统通知(条件发送,失败静默)。正文截断防长堆栈撑爆系统气泡。 // P1 bg 化:失败系统通知(条件发送,失败静默)。正文截断防长堆栈撑爆系统气泡。
@@ -2584,8 +2743,9 @@ async fn build_runtime_prompt(
.collect(); .collect();
if !lines.is_empty() { if !lines.is_empty() {
system_prompt = format!( system_prompt = format!(
"{}\n\n## 可用技能\n以下为本机 Claude 技能/命令(名称: 描述)。用户请求命中某技能用途时,提示用户输入 /技能名 触发,或引导其按需使用:\n{}", "{}\n\n## 可用技能\n以下为本机 Claude 技能/命令(名称: 描述,已去重,上限 {} 条)。**自动触发规则**:用户请求(含普通任务指令)与某技能用途匹配时,你应先调 read_skill 工具读取该技能规程全文,按其流程直接执行,无需等待用户显式输入 /技能名;仅当技能涉及高风险操作或不确定用户意图时,才提示用户输入 /技能名 确认。未列出的技能也可用 read_skill 按名查询:\n{}",
system_prompt, system_prompt,
SKILL_LIST_MAX_ITEMS,
lines.join("\n") lines.join("\n")
); );
tracing::debug!( tracing::debug!(
@@ -2974,3 +3134,24 @@ mod panic_guard_tests {
assert!(dropped.load(std::sync::atomic::Ordering::SeqCst), "unwind 时 RAII Drop 应执行"); 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]);
}
}
+308 -20
View File
@@ -31,7 +31,7 @@ mod diff;
// 第六批从本文件抽离,行为零变更。pub(super) use 供 tests 子模块引用 + process_tool_calls 裸名调用。 // 第六批从本文件抽离,行为零变更。pub(super) use 供 tests 子模块引用 + process_tool_calls 裸名调用。
// 模块提为 pub(crate):state 层 allowed_dirs 的 is_authorized 第三道门需直接读放行标志,私有模块路径段不可穿越 // 模块提为 pub(crate):state 层 allowed_dirs 的 is_authorized 第三道门需直接读放行标志,私有模块路径段不可穿越
pub(crate) mod path_auth; 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 参数,经此跨模块读进程级标志 // 黑名单放行判据:tool_registry 的 validate_path 无 bypass 参数,经此跨模块读进程级标志
pub(super) use path_auth::is_blacklist_bypassed; pub(super) use path_auth::is_blacklist_bypassed;
// 测试专用:改写标志验证 bypass 放行语义 + 串行闸,防依赖标志结果的测试并行互踩 // 测试专用:改写标志验证 bypass 放行语义 + 串行闸,防依赖标志结果的测试并行互踩
@@ -78,7 +78,7 @@ pub(crate) use finalize::audit_finalize;
// cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。 // cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。
// 第三批从本文件抽离,行为零变更。 // 第三批从本文件抽离,行为零变更。
mod cache; 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 数据变更联动刷新。 // data_change(audit/data_change.rs):AR-11 数据变更联动刷新。
// 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见 // 第四批从本文件抽离,行为零变更。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; "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<Mutex<AiSession>>,
conv_id: &str,
draft: &ToolCallDraft,
tools_arc: &Arc<AiToolRegistry>,
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 进审批门控 /// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控
/// 返回待审批的工具数量(0 = 全部自动执行完成) /// 返回待审批的工具数量(0 = 全部自动执行完成)
///
/// `allowed`(P2-1):SubAgent 策略的执行期工具白名单(spawn 入口钳制产物)。
/// Low 风险工具不在名单同样就地拒绝(layer=whitelist-clamp);主会话调用方传 None,语义不变。
/// 工具执行 + 心跳保活:execute 期间每 30s emit AiHeartbeat(对齐 stream_recv.rs /// 工具执行 + 心跳保活:execute 期间每 30s emit AiHeartbeat(对齐 stream_recv.rs
/// stream_llm select! 心跳语义),execute 完 abort 心跳 task。 /// stream_llm select! 心跳语义),execute 完 abort 心跳 task。
/// ///
@@ -481,6 +542,33 @@ async fn cancel_expired_approval(
.await; .await;
// 5. 落库会话状态变更(占位消息替换后需持久化,防重启后丢失) // 5. 落库会话状态变更(占位消息替换后需持久化,防重启后丢失)
super::conversation::save_conversation(&state.ai_session, &state.db, conv_id, None, None, true).await; 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( pub(crate) async fn process_tool_calls(
@@ -491,6 +579,7 @@ pub(crate) async fn process_tool_calls(
app_handle: &AppHandle, app_handle: &AppHandle,
conv_id: &str, conv_id: &str,
policy: ToolExecPolicy, policy: ToolExecPolicy,
allowed: Option<&[String]>,
) -> usize { ) -> usize {
// 策略快照(SubAgent:须人工介入的路径全部就地拒绝,见 ToolExecPolicy 文档) // 策略快照(SubAgent:须人工介入的路径全部就地拒绝,见 ToolExecPolicy 文档)
let is_sub_agent = matches!(policy, ToolExecPolicy::SubAgent); let is_sub_agent = matches!(policy, ToolExecPolicy::SubAgent);
@@ -598,6 +687,18 @@ pub(crate) async fn process_tool_calls(
.await .await
.unwrap_or_else(|| "low".to_string()); .unwrap_or_else(|| "low".to_string());
// ── 无人值守任务边界快照(设计 §三判定链输入)──
// per_conv.boundary 即会话级单真相源,None=未声明:下方两处插桩分支全部跳过,
// auto_exec_mode/trust/审批/路径授权全链路现状零变更(渐进增强零倒退)。
// 边界优先于全局三档:有边界会话上限内零打断,超上限结构化拒绝(不挂起不弹卡)。
let boundary: Option<crate::commands::ai::boundary::TaskBoundary> = {
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) // 在 RiskLevel 分类前,对文件工具(read/write/list/patch/info/append/delete/rename/search)
// 逐条预校验路径授权(persistent + 会话 session_allowed_dirs + 黑名单): // 逐条预校验路径授权(persistent + 会话 session_allowed_dirs + 黑名单):
@@ -623,6 +724,14 @@ pub(crate) async fn process_tool_calls(
// 同步进程级黑名单放行标志:validate_path(tool_registry)无 bypass 参数,靠此标志放行黑名单; // 同步进程级黑名单放行标志:validate_path(tool_registry)无 bypass 参数,靠此标志放行黑名单;
// 每轮工具执行前刷新,与下方 check_file_tool_auth 的入参 bypass 同源同值,两处拦截点语义一致。 // 每轮工具执行前刷新,与下方 check_file_tool_auth 的入参 bypass 同源同值,两处拦截点语义一致。
path_auth::set_blacklist_bypass(path_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 循环) // 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环)
let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new(); let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new();
let mut path_denied: Vec<(ToolCallDraft, String)> = 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(系统黑名单)分开标注—— // 第三元=denial_layer:needs-auth(待授权)与 path-blacklist(系统黑名单)分开标注——
// 黑名单主会话也放不了行,混标会误导子 LLM 走授权通道。 // 黑名单主会话也放不了行,混标会误导子 LLM 走授权通道。
let mut sub_agent_path_denied: Vec<(ToolCallDraft, String, &'static str)> = Vec::new(); 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(); let mut authorized_drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = Vec::new();
for (idx, draft, args) in drafts { for (idx, draft, args) in drafts {
// 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空), // 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空),
// handler 闭包 read lock 与此处预校验读同一字段,两端授权判定一致。 // 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::Authorized => authorized_drafts.push((idx, draft, args)),
FileToolAuthOutcome::NeedsAuth(req) => { FileToolAuthOutcome::NeedsAuth(req) => {
// SubAgent 策略:子代理无授权弹窗通道,挂起即永久停摆 → 就地拒绝。 // SubAgent 策略:子代理无授权弹窗通道,挂起即永久停摆 → 就地拒绝。
@@ -642,6 +753,21 @@ pub(crate) async fn process_tool_calls(
sub_agent_path_denied.push((draft, "路径未授权且子代理无授权弹窗通道".to_string(), "needs-auth")); sub_agent_path_denied.push((draft, "路径未授权且子代理无授权弹窗通道".to_string(), "needs-auth"));
continue; 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<String> = 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 门同语义。 // all 档全自动(用户拍板):路径授权弹窗也不打断,与 RiskLevel 门同语义。
// 黑名单 Denied 不受影响——系统敏感目录是绝对安全线,全接管也不放行。 // 黑名单 Denied 不受影响——系统敏感目录是绝对安全线,全接管也不放行。
// 放行的 draft 走 authorized_drafts → RiskLevel 分类 → all 放行 → 审计留痕闭环。 // 放行的 draft 走 authorized_drafts → RiskLevel 分类 → all 放行 → 审计留痕闭环。
@@ -702,6 +828,15 @@ pub(crate) async fn process_tool_calls(
).await; ).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)。 // Phase B: NeedsAuth 路径 → 挂起 loop(insert pending + emit AiDirAuthRequired + push 占位 tool_result)。
// 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。 // 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。
// pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。 // 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 // trust_hits 收集:AE-04 会话信任命中(同会话已批准同类操作),真实执行但移到锁外 spawn
// (对齐 Low risk 不持锁模式)。命中时此处只 emit toast + 收集,不 .await execute。 // (对齐 Low risk 不持锁模式)。命中时此处只 emit toast + 收集,不 .await execute。
let mut trust_hits: Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)> = Vec::new(); 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 { for (_, draft, args) in drafts {
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
// SubAgent 策略:Medium/High 就地拒绝(无审批通道,挂起即子循环永久停摆)。 // SubAgent 策略:Medium/High 就地拒绝(无审批通道,挂起即子循环永久停摆)。
@@ -825,6 +966,39 @@ pub(crate) async fn process_tool_calls(
).await; ).await;
continue; 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 目录列举绕行检测(最保守版,只警告不硬拒)。 // AC-2 ③:run_command 目录列举绕行检测(最保守版,只警告不硬拒)。
// 实证 09e7abfa:list_directory 后 LLM 用 run_command Get-ChildItem 再探同目录, // 实证 09e7abfa:list_directory 后 LLM 用 run_command Get-ChildItem 再探同目录,
// 绕过 ② 的目录级去重。检测到绕行 → insert 一条 system 警告(非静默),命令照常 // 绕过 ② 的目录级去重。检测到绕行 → insert 一条 system 警告(非静默),命令照常
@@ -893,6 +1067,7 @@ pub(crate) async fn process_tool_calls(
} }
})).await; })).await;
// 串行回填 tool_result + 审计(短 lock push + 锁外 audit) // 串行回填 tool_result + 审计(短 lock push + 锁外 audit)
for (draft, risk_level, outcome) in results { for (draft, risk_level, outcome) in results {
let (status, content) = match outcome { let (status, content) = match outcome {
@@ -914,6 +1089,12 @@ pub(crate) async fn process_tool_calls(
content.clone() content.clone()
}; };
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &msg_content)); 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),留痕可追溯 // 锁外:审计(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; 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() content.clone()
}; };
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &msg_content)); 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 才进)。 // F-#97 审计留痕:low_risk 向量中 risk_level != Low 即 mode 放行(Med 仅 medium/all、High 仅 all 才进)。
// decided_by 区分接管来源,审计表可追溯 all 模式执行了多少高危命令(治 securityReview blocker: // 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)); session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &warned_content));
} }
dupe_audit_records.push(build_audit_record( dupe_audit_records.push(build_audit_record(
conv_id, &draft.id, &draft.name, &draft.args, "completed", risk_level, conv_id, &draft.id, &draft.name, &draft.args,
Some(warned_content), Some("cache_hit"), current_message_id, "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() { 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 { if let Err(e) = audit_repo.insert_batch(dupe_audit_records).await {
tracing::error!( tracing::error!(
conv_id = %conv_id, conv_id = %conv_id,
batch = dup_len, batch = batch_len,
"[ai] insert_batch 轮内去重审计批量写失败(已回滚,共 {} 条): {}", "[ai] insert_batch(轮内重复)批量写审计记录失败(已回滚,共 {} 条): {}",
dup_len, batch_len,
e 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<String, String>)> =
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 区分)。 // 单表 pending_approvals 统计 pending 总数(path/risk 合一,kind 区分)。
// 短 lock 段:读 pending_approvals 统计(纯读,无 await) // 短 lock 段:读 pending_approvals 统计(纯读,无 await)
let (path_count, risk_count, pending_approvals_len) = { let (path_count, risk_count, pending_approvals_len) = {
@@ -1195,7 +1448,7 @@ mod tests {
let allowed = crate::state::AllowedDirs::default(); let allowed = crate::state::AllowedDirs::default();
let args = serde_json::json!({ "pattern": "foo", "path": "C:/some/unauthorized/dir" }); 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 { match outcome {
FileToolAuthOutcome::NeedsAuth(_) => { /* 期望:grep 触发授权申请 */ } FileToolAuthOutcome::NeedsAuth(_) => { /* 期望:grep 触发授权申请 */ }
FileToolAuthOutcome::Authorized => panic!("grep 未授权应返 NeedsAuth(申请授权),实际 Authorized"), FileToolAuthOutcome::Authorized => panic!("grep 未授权应返 NeedsAuth(申请授权),实际 Authorized"),
@@ -1203,7 +1456,7 @@ mod tests {
} }
// 对照:search_files 同样未授权但被硬拒(Denied),锁定两工具差异 // 对照: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 { match search_outcome {
FileToolAuthOutcome::Denied(_) => { /* 期望:search_files 盲拒 */ } FileToolAuthOutcome::Denied(_) => { /* 期望:search_files 盲拒 */ }
FileToolAuthOutcome::NeedsAuth(_) => panic!("search_files 未授权应返 Denied(盲拒),实际 NeedsAuth"), FileToolAuthOutcome::NeedsAuth(_) => panic!("search_files 未授权应返 Denied(盲拒),实际 NeedsAuth"),
@@ -1219,7 +1472,7 @@ mod tests {
let allowed = crate::state::AllowedDirs::default_with_root(); let allowed = crate::state::AllowedDirs::default_with_root();
// .ssh 命中系统黑名单(is_in_system_blacklist) // .ssh 命中系统黑名单(is_in_system_blacklist)
let args = serde_json::json!({ "pattern": "foo", "path": "/home/user/.ssh/config" }); 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 { match outcome {
FileToolAuthOutcome::Denied(_) => { /* 期望:黑名单硬拒 */ } FileToolAuthOutcome::Denied(_) => { /* 期望:黑名单硬拒 */ }
FileToolAuthOutcome::NeedsAuth(_) => panic!("grep 黑名单路径应返 Denied,实际 NeedsAuth"), FileToolAuthOutcome::NeedsAuth(_) => panic!("grep 黑名单路径应返 Denied,实际 NeedsAuth"),
@@ -1234,4 +1487,39 @@ mod tests {
let paths = extract_file_tool_paths("grep", &args); let paths = extract_file_tool_paths("grep", &args);
assert_eq!(paths, vec!["src/main.rs".to_string()], "grep 应取 path 参数(忽略 glob 等其他参数)"); 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=禁用超时不会到点,防御)");
}
} }
+58 -15
View File
@@ -59,22 +59,38 @@ pub(crate) enum FileToolAuthOutcome {
Denied(String), 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`: /// - 路径任一未命中白名单(persistent + session_dirs)且非黑名单 → `NeedsAuth`:
/// 附带 PathAuthRequest(规范化父目录 + 原始路径列表),供挂起弹窗 /// 附带 PathAuthRequest(规范化父目录 + 原始路径列表),供挂起弹窗
/// - 全部已授权 → `Authorized`:走原 Low/Med/High 流程 /// - 全部已授权 → `Authorized`:走原 Low/Med/High 流程
/// - `bypass=true`(「信任所有目录」总开关):未授权目录不再产生 NeedsAuth,黑名单 Denied /// - `bypass=All`(「信任所有目录」总开关,主会话):未授权目录不再产生 NeedsAuth,黑名单
/// 也一并放行(产品拍板:开关语义为完全放行,含敏感目录,体验优先,search_files 盲拒 /// Denied 也一并放行(产品拍板:开关语义为完全放行,含敏感目录,体验优先,search_files 盲拒
/// 随 NeedsAuth 消失一并解除)。bypass 由调用方 process_tool_calls 读 KV 后传入; /// 随 NeedsAuth 消失一并解除)。`bypass=KeepBlacklist`(子代理):只豁免 NeedsAuth,
/// validate_path 无此参数才走全局标志 PATH_AUTH_BYPASS_ACTIVE(与入参同源等价, /// 黑名单硬拒保持。bypass 由调用方 process_tool_calls 读 KV 后按策略分流传入;
/// audit 层每轮刷新保证两处拦截点一致)。本函数同步签名,不自取异步 settings。 /// validate_path 无此参数才走全局标志 PATH_AUTH_BYPASS_ACTIVE(主会话语义,与 All 同源等价,
/// audit 层每轮刷新保证两处拦截点一致;子代理文件工具在预校验层已硬拒,handler 级标志不生效)。
/// 本函数同步签名,不自取异步 settings。
pub(crate) fn check_file_tool_auth( pub(crate) fn check_file_tool_auth(
tool_name: &str, tool_name: &str,
args: &serde_json::Value, args: &serde_json::Value,
allowed: &crate::state::AllowedDirs, allowed: &crate::state::AllowedDirs,
bypass: bool, bypass: PathBypass,
) -> FileToolAuthOutcome { ) -> FileToolAuthOutcome {
use crate::state::{check_path_authorization, PathAuthDecision}; use crate::state::{check_path_authorization, PathAuthDecision};
let paths = extract_file_tool_paths(tool_name, args); let paths = extract_file_tool_paths(tool_name, args);
@@ -88,16 +104,17 @@ pub(crate) fn check_file_tool_auth(
for p in &paths { for p in &paths {
match check_path_authorization(p, allowed) { match check_path_authorization(p, allowed) {
PathAuthDecision::Denied { reason } => { PathAuthDecision::Denied { reason } => {
// 总开关开启时黑名单一并放行(产品拍板:体验优先,用户知情选择);关闭时硬拒不变。 // 总开关=All 时黑名单一并放行(产品拍板:体验优先,用户知情选择,仅主会话);
// 关闭与 KeepBlacklist(子代理)时硬拒不变——黑名单是安全线。
// 此处用入参 bypass;validate_path 无参才走全局标志,两者同源等价(audit 每轮刷同一 KV)。 // 此处用入参 bypass;validate_path 无参才走全局标志,两者同源等价(audit 每轮刷同一 KV)。
if bypass { if matches!(bypass, PathBypass::All) {
continue; continue;
} }
return FileToolAuthOutcome::Denied(reason); return FileToolAuthOutcome::Denied(reason);
} }
PathAuthDecision::NeedsAuthorization { dir } => { PathAuthDecision::NeedsAuthorization { dir } => {
// 总开关开启:未授权目录直接放行(黑名单的放行在上一分支处理) // 总开关开启(All 或 KeepBlacklist):未授权目录直接放行(黑名单的放行仅在 All 分支)
if bypass { if !matches!(bypass, PathBypass::Off) {
continue; continue;
} }
if !pending_dirs.contains(&dir) { if !pending_dirs.contains(&dir) {
@@ -136,17 +153,43 @@ mod tests {
let allowed = crate::state::AllowedDirs::default(); let allowed = crate::state::AllowedDirs::default();
let args = serde_json::json!({ "path": "C:/some/unauthorized/dir" }); let args = serde_json::json!({ "path": "C:/some/unauthorized/dir" });
// 关(基线,行为不变):NeedsAuth // 关(基线,行为不变):NeedsAuth
match check_file_tool_auth("read_file", &args, &allowed, false) { match check_file_tool_auth("read_file", &args, &allowed, PathBypass::Off) {
FileToolAuthOutcome::NeedsAuth(_) => {} FileToolAuthOutcome::NeedsAuth(_) => {}
_ => panic!("bypass 关时未授权路径应仍返 NeedsAuth"), _ => panic!("bypass 关时未授权路径应仍返 NeedsAuth"),
} }
// 开:直接 Authorized,不挂起 // 开:直接 Authorized,不挂起
match check_file_tool_auth("read_file", &args, &allowed, true) { match check_file_tool_auth("read_file", &args, &allowed, PathBypass::All) {
FileToolAuthOutcome::Authorized => {} FileToolAuthOutcome::Authorized => {}
_ => panic!("bypass 开时未授权路径应直接 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 开:黑名单也放行(语义反转:总开关=完全放行,含敏感目录)。 /// bypass 开:黑名单也放行(语义反转:总开关=完全放行,含敏感目录)。
/// 进程级标志须持串行闸并在断言前复位(先取结果再复位再断言,防 panic 泄漏标志污染并行测试)。 /// 进程级标志须持串行闸并在断言前复位(先取结果再复位再断言,防 panic 泄漏标志污染并行测试)。
#[test] #[test]
@@ -157,7 +200,7 @@ mod tests {
// .ssh 命中系统黑名单(is_in_system_blacklist) // .ssh 命中系统黑名单(is_in_system_blacklist)
let args = serde_json::json!({ "path": "/home/user/.ssh/config" }); let args = serde_json::json!({ "path": "/home/user/.ssh/config" });
let outcome_allowed = matches!( 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 FileToolAuthOutcome::Authorized
); );
set_blacklist_bypass(false); 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 _guard = BLACKLIST_BYPASS_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let allowed = crate::state::AllowedDirs::default_with_root(); let allowed = crate::state::AllowedDirs::default_with_root();
let args = serde_json::json!({ "path": "/home/user/.ssh/config" }); 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(_) => {} FileToolAuthOutcome::Denied(_) => {}
_ => panic!("bypass 关时黑名单路径应 Denied(行为不变)"), _ => panic!("bypass 关时黑名单路径应 Denied(行为不变)"),
} }
+43 -3
View File
@@ -296,15 +296,23 @@ fn match_task_audit_row(tool_name: &str, result: Option<&str>, task_id: &str) ->
/// 故走专用 SELECT(与 state.rs 清理孤儿 pending 消息同款 app 层直查先例);SQL 里的工具名单 /// 故走专用 SELECT(与 state.rs 清理孤儿 pending 消息同款 app 层直查先例);SQL 里的工具名单
/// 是 TASK_CONV_TOOLS 的硬拷贝,单测覆盖三个名字防漂移。 /// 是 TASK_CONV_TOOLS 的硬拷贝,单测覆盖三个名字防漂移。
/// ///
/// P2-3 task_id 预筛下沉 SQL:`result LIKE '%"id":"<task_id>"%'` 只作预筛(缩小扫描窗口,
/// 参数化绑定防注入),精确判等仍在 Rust 侧 match_task_audit_row(解析 JSON 取顶层 id 字段
/// 判等,「禁裸 LIKE 判等」不破)。预筛只会多放进相关行(嵌套 id/巧合同形文本),由 Rust
/// 侧剔除;不会漏放——audit result 均由 serde_json 紧凑序列化落库(无空格),顶层 id 字段
/// 必然以 `"id":"<id>"` 形态出现。若无预筛,LIMIT 窗口取的是全局最近 1000 行,高流量下
/// 目标任务的关联行会被无关行挤出窗外(关联静默消失);预筛后窗口=含该任务 id 形文本的
/// 最近 1000 行,早期关联仍可命中(有单测锁定)。
///
/// 过滤:① 命中行按会话去重(行按 requested_at 倒序,首见=最近一次);② 子代理会话 /// 过滤:① 命中行按会话去重(行按 requested_at 倒序,首见=最近一次);② 子代理会话
/// (origin='sub_agent',V44)剔除,v1 只列主会话;③ 会话已不在库的 id 一并剔除 /// (origin='sub_agent',V44)剔除,v1 只列主会话;③ 会话已不在库的 id 一并剔除
/// (switchConversation 对不存在 id 会走"新建兜底",跳过去是空会话,不如不列)。 /// (switchConversation 对不存在 id 会走"新建兜底",跳过去是空会话,不如不列)。
/// LIMIT 1000 扫描上界:抽屉每次打开都查,封顶防超大审计表全表拖慢;取最近 1000 行, /// LIMIT 1000 扫描上界:抽屉每次打开都查,封顶防超大审计表全表拖慢。
/// 更早的关联对"跳回最近上下文"场景无意义。
pub(crate) async fn find_task_conversations( pub(crate) async fn find_task_conversations(
db: &Database, db: &Database,
task_id: &str, task_id: &str,
) -> Result<Vec<TaskConversationRef>, String> { ) -> Result<Vec<TaskConversationRef>, String> {
let pattern = format!("%\"id\":\"{}\"%", task_id);
let conn = db.conn(); let conn = db.conn();
let rows: Vec<(String, String, Option<String>, String)> = let rows: Vec<(String, String, Option<String>, String)> =
tokio::task::spawn_blocking(move || -> Result<Vec<(String, String, Option<String>, String)>, String> { tokio::task::spawn_blocking(move || -> Result<Vec<(String, String, Option<String>, 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 \ "SELECT conversation_id, tool_name, result, requested_at FROM ai_tool_executions \
WHERE tool_name IN ('create_task','update_task','advance_task') \ WHERE tool_name IN ('create_task','update_task','advance_task') \
AND conversation_id IS NOT NULL \ AND conversation_id IS NOT NULL \
AND result LIKE ?1 \
ORDER BY CAST(requested_at AS INTEGER) DESC LIMIT 1000", ORDER BY CAST(requested_at AS INTEGER) DESC LIMIT 1000",
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let mapped = stmt let mapped = stmt
.query_map([], |row| { .query_map([&pattern], |row| {
Ok(( Ok((
row.get::<_, String>(0)?, row.get::<_, String>(0)?,
row.get::<_, String>(1)?, row.get::<_, String>(1)?,
@@ -590,6 +599,7 @@ mod tests {
created_at: "1".to_string(), created_at: "1".to_string(),
updated_at: "1".to_string(), updated_at: "1".to_string(),
origin: origin.map(|s| s.to_string()), origin: origin.map(|s| s.to_string()),
boundary_json: None,
}) })
.await .await
.expect("测试会话插入应成功"); .expect("测试会话插入应成功");
@@ -637,6 +647,36 @@ mod tests {
assert_eq!(out[0].requested_at, (t - 10).to_string()); 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 不等 均不命中,精确判等才命中。 /// S4 纯函数判定表:名单外工具 / 非 JSON / 缺 id / id 不等 均不命中,精确判等才命中。
#[test] #[test]
fn match_task_audit_row_truth_table() { fn match_task_audit_row_truth_table() {
+256
View File
@@ -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<String>,
/// 破坏性命令禁令(恒 true,字段留作显式声明供 UI 展示)。
/// 判定**不读本字段**——直接调 is_destructive_command,防手改 JSON 置 false 绕过
/// (安全判定不信任输入)。
#[serde(default = "default_forbid_destructive")]
pub forbid_destructive: bool,
/// 任务预算,None=不限。
#[serde(default)]
pub budget: Option<TaskBudget>,
}
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(), "新会话默认无边界(无边界=现状逐次审批零倒退)");
}
}
@@ -400,6 +400,21 @@ pub async fn ai_conversation_switch(
}; };
conv.pinned_goals = pinned_goals; 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::<crate::commands::ai::boundary::TaskBoundary>(s) {
Ok(b) => Some(b),
Err(e) => {
tracing::warn!(
"切换对话 {} 解析 boundary_json 失败(按无边界处理): {}",
conversation_id, e
);
None
}
});
// 首次切入才复位会话级临时态(信任/语言/模型覆盖保留,防切回重复审批);停止信号与迭代计数始终复位 // 首次切入才复位会话级临时态(信任/语言/模型覆盖保留,防切回重复审批);停止信号与迭代计数始终复位
if !conv_was_present { if !conv_was_present {
conv.model_override = None; 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<String>,
/// 创建时间(毫秒字符串,同 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<Vec<SubAgentRef>, 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>, String)> =
tokio::task::spawn_blocking(move || -> Result<Vec<(String, Option<String>, 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<String>>(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<Vec<SubAgentRef>, String> {
find_sub_agent_conversations(&state.db, &conversation_id).await
}
/// 删除对话 /// 删除对话
#[tauri::command] #[tauri::command]
pub async fn ai_conversation_delete( pub async fn ai_conversation_delete(
@@ -687,8 +776,10 @@ pub async fn ai_conversation_delete(
// spawn_agent 级联取消:父会话删除,其派生的子代理同步置停(子循环停止检查点 // spawn_agent 级联取消:父会话删除,其派生的子代理同步置停(子循环停止检查点
// 读 own stop_flag 即退出;父 per_conv 条目随下方 remove 消失是兜底判据)。 // 读 own stop_flag 即退出;父 per_conv 条目随下方 remove 消失是兜底判据)。
// 短临界区纯内存操作(无 await),不延长 session 持锁。 // 短临界区纯内存操作(无 await),不延长 session 持锁。
// Delete 形态:同锁内对 child 补 deleted_convs,出口 alive 判定放弃落库,防孤儿轨迹复活。
crate::commands::ai::agentic::sub_agent::cascade_stop_children_locked( crate::commands::ai::agentic::sub_agent::cascade_stop_children_locked(
&mut session, &state.sub_agent_registry, &conversation_id, &mut session, &state.sub_agent_registry, &conversation_id,
crate::commands::ai::agentic::sub_agent::CascadeStopForm::Delete,
); );
// 删除 conv 时移除其 per_conv 条目(loop 检测 conv 不存在即退出)。 // 删除 conv 时移除其 per_conv 条目(loop 检测 conv 不存在即退出)。
session.per_conv.remove(&conversation_id); session.per_conv.remove(&conversation_id);
@@ -805,6 +896,79 @@ pub async fn ai_update_conversation_goals(
Ok(()) 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<Option<crate::commands::ai::boundary::TaskBoundary>, 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<crate::commands::ai::boundary::TaskBoundary>,
) -> 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 不读(可能被切走/未落库) /// - 优先落库 messages(完整历史,与 switch 一致),内存 session 不读(可能被切走/未落库)
@@ -1271,4 +1435,69 @@ mod tests {
assert_eq!(got[1].id, "m_new"); assert_eq!(got[1].id, "m_new");
assert_eq!(got[1].content, "压缩后新提问"); 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());
}
} }
+74 -12
View File
@@ -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<String>,
pub(crate) created_at: Option<String>,
}
/// 保存对话到数据库(按 conv_id 写库,不受 active_conversation_id 切换影响) /// 保存对话到数据库(按 conv_id 写库,不受 active_conversation_id 切换影响)
/// ///
/// 写 ai_messages(每条消息一行,F-260619-03 拆分存储)+ updated_at + 累加 token 用量 + /// 写 ai_messages(每条消息一行,F-260619-03 拆分存储)+ updated_at + 累加 token 用量 +
@@ -190,13 +202,40 @@ pub(crate) async fn save_conversation(
model: Option<&str>, model: Option<&str>,
touch_updated_at: bool, touch_updated_at: bool,
) { ) {
// 外层 timeout(SAVE_CONVERSATION_TIMEOUT_SECS):超时只 warn 不阻断。 save_with_meta(session_arc, db, conv_id, usage, model, touch_updated_at, None).await;
// save 是非关键路径(失败已 warn,内存真相源不变),防 DB/fs 卡死拖垮 agentic loop / IPC。 }
// timeout 仅作用于 DB 写入段(此处包住整个 inner,因 session 锁/序列化也属 save 周期,
// 超时即放弃本次 save,下次 loop 轮次会重新落库,幂等覆盖无副作用)。 /// 子代理出口落库入口(P3-5):显式携带父会话 save 元数据快照的 save 变体。
/// 与 save_conversation 共用同一落库实现,仅首插的 provider_id/created_at 来源不同。
pub(crate) async fn save_conversation_meta(
session_arc: &Arc<Mutex<AiSession>>,
db: &Arc<Database>,
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<Mutex<AiSession>>,
db: &Arc<Database>,
conv_id: &str,
usage: Option<&df_ai::provider::TokenUsage>,
model: Option<&str>,
touch_updated_at: bool,
meta: Option<SaveMetaOverride>,
) {
if let Err(_elapsed) = tokio::time::timeout( if let Err(_elapsed) = tokio::time::timeout(
Duration::from_secs(SAVE_CONVERSATION_TIMEOUT_SECS), 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 .await
{ {
@@ -216,16 +255,17 @@ async fn save_conversation_serialized(
usage: Option<&df_ai::provider::TokenUsage>, usage: Option<&df_ai::provider::TokenUsage>,
model: Option<&str>, model: Option<&str>,
touch_updated_at: bool, touch_updated_at: bool,
meta: Option<SaveMetaOverride>,
) { ) {
let save_lock = { let save_lock = {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
session.save_lock(conv_id) session.save_lock(conv_id)
}; };
let _save_guard = save_lock.lock().await; 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( async fn save_conversation_inner(
session_arc: &Arc<Mutex<AiSession>>, session_arc: &Arc<Mutex<AiSession>>,
db: &Arc<Database>, db: &Arc<Database>,
@@ -233,6 +273,7 @@ async fn save_conversation_inner(
usage: Option<&df_ai::provider::TokenUsage>, usage: Option<&df_ai::provider::TokenUsage>,
model: Option<&str>, model: Option<&str>,
touch_updated_at: bool, touch_updated_at: bool,
meta: Option<SaveMetaOverride>,
) { ) {
// 取 messages + 懒创建首次落库所需的 provider_id/created_at // 取 messages + 懒创建首次落库所需的 provider_id/created_at
// 工具结果(content)超 8KB50KB→8KB,治 GLM 1214)时截断头尾各 ~3KB + 中段 // 工具结果(content)超 8KB50KB→8KB,治 GLM 1214)时截断头尾各 ~3KB + 中段
@@ -253,7 +294,7 @@ async fn save_conversation_inner(
// 避免同时持有 session 的 mut 和 immut 借用(E0502)。 // 避免同时持有 session 的 mut 和 immut 借用(E0502)。
// BE1无变化捷径提前到克隆之前——同一 lock 段内先判「消息数未增且非 dirty」, // BE1无变化捷径提前到克隆之前——同一 lock 段内先判「消息数未增且非 dirty」,
// 无变化则不 clone(每轮无变化 save 省一次全量 clone + 后续 truncate/records 构建)。 // 无变化则不 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; let mut session = session_arc.lock().await;
// 已删除对话直接跳过写入(loop 在「push 后、save 前」被删除的幽灵复活根治): // 已删除对话直接跳过写入(loop 在「push 后、save 前」被删除的幽灵复活根治):
// 此处不惰性重建 per_conv、不触发 INSERT 空壳 + insert_batch 复活。 // 此处不惰性重建 per_conv、不触发 INSERT 空壳 + insert_batch 复活。
@@ -268,8 +309,15 @@ async fn save_conversation_inner(
let __wait = __lock_start.elapsed(); let __wait = __lock_start.elapsed();
// 先取 session 顶层 owned 字段(immutable borrow 即刻结束),再取 conv mutable borrow, // 先取 session 顶层 owned 字段(immutable borrow 即刻结束),再取 conv mutable borrow,
// 避免同时持有 session 的 mut 和 immut 借用(E0502)。 // 避免同时持有 session 的 mut 和 immut 借用(E0502)。
let provider_id = session.active_provider_id.clone(); // P3-5:显式快照优先(SubAgent 出口落库用),否则读 active_*(主会话历史读法不变)。
let created_at = session.active_conv_created_at.clone(); 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 // 会话来源标记(V44 子轨迹落库):子代理 conv 首插时随 record 落 origin 列,主会话 None
let conv_origin = session.conv_origins.get(conv_id).cloned(); let conv_origin = session.conv_origins.get(conv_id).cloned();
let conv = session.conv(conv_id); let conv = session.conv(conv_id);
@@ -284,6 +332,8 @@ async fn save_conversation_inner(
conv.messages.all_messages_clone() conv.messages.all_messages_clone()
}; };
let pinned_goals = conv.pinned_goals.clone(); let pinned_goals = conv.pinned_goals.clone();
// 无人值守任务边界随行落库(V45 boundary_json):每轮 save 同步内存真相源快照
let conv_boundary = conv.boundary.clone();
let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长) let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长)
( (
provider_id, provider_id,
@@ -294,6 +344,7 @@ async fn save_conversation_inner(
needs_full_rewrite, needs_full_rewrite,
cloned, cloned,
conv_origin, conv_origin,
conv_boundary,
) )
}; };
let __lock_total = __lock_start.elapsed(); 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())); rec.pinned_goals = Some(serde_json::to_string(&pinned_goals).unwrap_or_else(|_| "[]".to_string()));
// 挂起审批快照持久化:每轮 save 同步当前 pending_approvals 快照 // 挂起审批快照持久化:每轮 save 同步当前 pending_approvals 快照
rec.pending_approvals = Some(pending_approvals_json.clone()); 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 // update_full 仍写 messages 列(保留旧值,本批不改 messages 字段),写元数据 + updated_at
if let Err(e) = conv_repo.update_full(&rec).await { if let Err(e) = conv_repo.update_full(&rec).await {
tracing::warn!("更新对话元数据失败 {conv_id}: {e}"); tracing::warn!("更新对话元数据失败 {conv_id}: {e}");
@@ -473,6 +528,9 @@ async fn save_conversation_inner(
created_at: conv_created.clone(), created_at: conv_created.clone(),
updated_at: now, updated_at: now,
origin: conv_origin, 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 { if let Err(e) = conv_repo.insert(rec).await {
tracing::warn!("落库对话失败 {conv_id}: {e}"); tracing::warn!("落库对话失败 {conv_id}: {e}");
@@ -492,10 +550,14 @@ async fn save_conversation_inner(
// 失败保基线不变:下次 save 会重新尝试写未落库的消息(增量路径重试同 slice,INSERT OR IGNORE // 失败保基线不变:下次 save 会重新尝试写未落库的消息(增量路径重试同 slice,INSERT OR IGNORE
// 幂等不冲突;脏路径全量重写收敛)。基线推进必须在锁内与读时一致(本进程内单线程 save 串行)。 // 幂等不冲突;脏路径全量重写收敛)。基线推进必须在锁内与读时一致(本进程内单线程 save 串行)。
// 注意:写库期间若有其他操作 push/置 dirty,基线推进可能与最新内存错位——但 advance 用 // 注意:写库期间若有其他操作 push/置 dirty,基线推进可能与最新内存错位——但 advance 用
// max 防回退,且 dirty 标志由那次操作自己设(下次 save 仍走全量重写收敛),最终一致。 // max 防回退;dirty 只在全量重写时清(增量/无变化路径写库期间新置的 dirty 如压缩 mark,
// 与本次 append 写库无关,误清会致改动滞留内存永不落库)。
if msgs_written_ok { if msgs_written_ok {
let mut session = session_arc.lock().await; 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 时创建) // 自动 checkpoint(每 20 轮或总 token > 150k 时创建)
+176 -13
View File
@@ -29,6 +29,8 @@
pub mod agentic; pub mod agentic;
pub mod augmentation; pub mod augmentation;
pub mod audit; pub mod audit;
// 无人值守任务边界(TaskBoundary):结构 + 纯判定函数 + 单测(设计 §二/§三)
pub mod boundary;
pub mod code_intel; pub mod code_intel;
pub mod command_stream; pub mod command_stream;
pub mod commands; pub mod commands;
@@ -236,6 +238,9 @@ pub enum AiChatEvent {
/// - `status`:"completed" | "truncated" | "failed" /// - `status`:"completed" | "truncated" | "failed"
/// - `rounds`/`tokens`:实际执行轮数与累计 token(启动失败为 0) /// - `rounds`/`tokens`:实际执行轮数与累计 token(启动失败为 0)
/// - `rejected_tools`:被拒工具清单("工具名×次数",如 "write_file×3"),None=本次零拒绝 /// - `rejected_tools`:被拒工具清单("工具名×次数",如 "write_file×3"),None=本次零拒绝
/// - `stop_kind`:停止类别(snake_case 字符串,为前端备料):"cancelled"(用户停止级联)
/// | "wall_clock" | "max_rounds" | "token_budget" | "empty_convergence" | "error"
/// | "converged"(正常收敛);None=未分类(防御,当前所有路径均有类别)
AiAgentFinished { AiAgentFinished {
agent_id: String, agent_id: String,
conversation_id: Option<String>, conversation_id: Option<String>,
@@ -246,6 +251,9 @@ pub enum AiChatEvent {
/// S2 报告协议:子循环运行期被拒工具汇总,让父会话与用户看见子代理被拒了什么。 /// S2 报告协议:子循环运行期被拒工具汇总,让父会话与用户看见子代理被拒了什么。
/// 可选字段:前端 S1 完成卡不消费该字段也不破坏(事件多字段向后兼容),消费留二期。 /// 可选字段:前端 S1 完成卡不消费该字段也不破坏(事件多字段向后兼容),消费留二期。
rejected_tools: Option<Vec<String>>, rejected_tools: Option<Vec<String>>,
/// 停止类别(为前端备料):"cancelled" | "wall_clock" | "max_rounds" | "token_budget"
/// | "empty_convergence" | "error" | "converged"。前端不消费该字段也不破坏。
stop_kind: Option<String>,
}, },
/// 流式调用自动重试提示流前失败重试中) /// 流式调用自动重试提示流前失败重试中)
/// ///
@@ -425,6 +433,34 @@ pub enum AiChatEvent {
resolved_by: String, resolved_by: String,
conversation_id: Option<String>, conversation_id: Option<String>,
}, },
/// 无人值守安全停靠(设计 §四,挂起家族终局):五路非正常终局(审批超时取消/挂起后
/// 取消/熔断/预算耗尽/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<String>,
reason: String,
stop_kind: Option<String>,
},
/// 无人值守进度可观测(设计 §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<String>,
round: u32,
max_rounds: u32,
tokens_used: u32,
},
} }
/// 模型信息(AiModelList 事件的载荷,对齐 apps/df-miniapp 模型选择器 ModelInfo 字段)。 /// 模型信息(AiModelList 事件的载荷,对齐 apps/df-miniapp 模型选择器 ModelInfo 字段)。
@@ -662,19 +698,6 @@ pub struct AiSession {
} }
impl 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(()))) .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone() .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)] #[cfg(test)]
@@ -1031,6 +1073,30 @@ pub struct PerConvState {
/// 仍为 view-only(不落库改历史,仅影响发送视图);键用内容哈希而非位置(裁剪/压缩后 /// 仍为 view-only(不落库改历史,仅影响发送视图);键用内容哈希而非位置(裁剪/压缩后
/// 位置漂移,内容不变)。随会话销毁不落库。 /// 位置漂移,内容不变)。随会话销毁不落库。
pub tool_view_decisions: std::collections::HashMap<u64, bool>, pub tool_view_decisions: std::collections::HashMap<u64, bool>,
/// 无人值守任务级授权边界(设计 §二):发起时一次声明,存续期内边界内零审批。
/// None=未声明(现状逐次审批零倒退)。内存真相源在 per_conv,落库随行
/// (ai_conversations.boundary_json,save 写/switch 读还原),随会话同亡。
/// 子代理 per_conv 独立新建恒 None,不受父边界影响(拍板点 6 fail-closed)。
pub boundary: Option<boundary::TaskBoundary>,
/// 只读工具结果缓存的「写后失效」下界(消息索引,纯内存态,不落库)。
///
/// 治「只读结果缓存无写失效」缺陷: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, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@@ -1096,6 +1229,8 @@ impl PerConvState {
workflow_dag_summary: None, workflow_dag_summary: None,
frozen_tool_names: None, frozen_tool_names: None,
tool_view_decisions: std::collections::HashMap::new(), 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 跟踪进度。" "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,
);
}
+5
View File
@@ -460,12 +460,17 @@ pub fn run() {
commands::ai::ai_conversation_switch, commands::ai::ai_conversation_switch,
// S3 子代理轨迹只读窥视(复用 switch 读路径,不切任何会话状态) // S3 子代理轨迹只读窥视(复用 switch 读路径,不切任何会话状态)
commands::ai::ai_conversation_peek, commands::ai::ai_conversation_peek,
// S3 完成卡备料:按父会话反查子代理轨迹精简元数据(只读)
commands::ai::ai_sub_agent_list,
commands::ai::ai_conversation_load_more, commands::ai::ai_conversation_load_more,
commands::ai::ai_conversation_delete, commands::ai::ai_conversation_delete,
commands::ai::ai_conversation_rename, commands::ai::ai_conversation_rename,
commands::ai::ai_conversation_archive, commands::ai::ai_conversation_archive,
commands::ai::ai_conversation_set_pinned, commands::ai::ai_conversation_set_pinned,
commands::ai::ai_update_conversation_goals, 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_conversation_export,
commands::ai::ai_list_skills, commands::ai::ai_list_skills,
// 核心设计6: 热重载技能(invalidate + 重扫,不重启生效) // 核心设计6: 热重载技能(invalidate + 重扫,不重启生效)
+2 -3
View File
@@ -18,8 +18,8 @@ mod llm_concurrency;
// 对外公共符号(pub use):外部 crate/模块经 `crate::state::X` 路径消费 // 对外公共符号(pub use):外部 crate/模块经 `crate::state::X` 路径消费
pub use allowed_dirs::{ pub use allowed_dirs::{
AllowedDirs, PathAuthDecision, check_path_authorization, is_in_system_blacklist, AllowedDirs, PathAuthDecision, best_effort_canonicalize, check_path_authorization,
paths_eq, strip_verbatim, is_in_system_blacklist, paths_eq, strip_verbatim,
}; };
pub use knowledge_config::{ pub use knowledge_config::{
ExtractTrigger, KnowledgeConfig, KNOWLEDGE_CONFIG_KEY, APPROVAL_TIMEOUT_KEY, ExtractTrigger, KnowledgeConfig, KNOWLEDGE_CONFIG_KEY, APPROVAL_TIMEOUT_KEY,
@@ -275,7 +275,6 @@ pub struct AppState {
/// `projects.path` 并集 + `AllowedDirs` 推导。无绑定项目时 workspace_root 为空, /// `projects.path` 并集 + `AllowedDirs` 推导。无绑定项目时 workspace_root 为空,
/// 文件操作拒绝,引导用户绑定项目。当前保留编译期常量作为开发期兼容。 /// 文件操作拒绝,引导用户绑定项目。当前保留编译期常量作为开发期兼容。
/// 已修复: /// 已修复:
/// - authz_debug → std::env::temp_dir() (chat.rs)
/// - workspace_drive → std::env::current_dir() 运行时确定 (chat.rs) /// - workspace_drive → std::env::current_dir() 运行时确定 (chat.rs)
fn workspace_root_path() -> PathBuf { fn workspace_root_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")) PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+1 -1
View File
@@ -233,7 +233,7 @@ pub fn strip_verbatim(p: PathBuf) -> PathBuf {
/// 父目录也不存在 → 回退原词法路径(交由 strip_verbatim + starts_with 尽力匹配,不阻断) /// 父目录也不存在 → 回退原词法路径(交由 strip_verbatim + starts_with 尽力匹配,不阻断)
/// ///
/// 失败一律回退,绝不返回 Err——授权比对宁可降级匹配不可 panic/阻断合法访问。 /// 失败一律回退,绝不返回 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) { if let Ok(real) = std::fs::canonicalize(p) {
return real; return real;
} }
+23 -1
View File
@@ -2,7 +2,7 @@
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event' 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 = { export const aiApi = {
/** /**
@@ -112,6 +112,19 @@ export const aiApi = {
return invoke('ai_pending_tool_calls', { conversationId }) return invoke('ai_pending_tool_calls', { conversationId })
}, },
// ── 无人值守任务边界(设计 §2.3):发起时一次授权,存续期生效,随会话同亡 ──
/** 读取会话任务边界(配置卡回显/侧栏徽标)。无边界返回 null(幂等读不报错) */
getTaskBoundary(conversationId: string): Promise<TaskBoundary | null> {
return invoke('ai_task_boundary_get', { conversationId })
},
/** 设置会话任务边界。boundary 传 null = 清除,回退逐次审批;
* 后端把 risk_ceiling=high 钳到 medium;对话未落库时内存即生效,首条消息落库随行 */
setTaskBoundary(conversationId: string, boundary: TaskBoundary | null): Promise<void> {
return invoke('ai_task_boundary_set', { conversationId, boundary })
},
/** 清空对话 */ /** 清空对话 */
clearChat(): Promise<void> { clearChat(): Promise<void> {
return invoke('ai_chat_clear') return invoke('ai_chat_clear')
@@ -378,6 +391,15 @@ export const aiApi = {
return invoke('ai_conversation_peek', { conversationId }) 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 的会话。 * S4 TaskDrawer「关联会话」:按任务 id 反查发起过 create/update/advance_task 的会话。
* 后端从 ai_tool_executions 审计表推导(result JSON 顶层 id 精确判等,非 LIKE), * 后端从 ai_tool_executions 审计表推导(result JSON 顶层 id 精确判等,非 LIKE),
+37
View File
@@ -480,6 +480,19 @@ export type AiChatEvent = ({
// 前端渲染求助卡显 reason + options 按钮供用户选,与 AiApprovalRequired(工具执行前审批)/ // 前端渲染求助卡显 reason + options 按钮供用户选,与 AiApprovalRequired(工具执行前审批)/
// AiMaxRoundsReached(达 max 被动截断)语义区分——求助=AI 主动「我搞不定」。 // AiMaxRoundsReached(达 max 被动截断)语义区分——求助=AI 主动「我搞不定」。
type: 'AiHelpRequired'; reason: string; context: string; options: string[] 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 消费。 // L2 统一状态机(批2 1b/1c):ConvState 经 AiChatEvent 推前端,供 status union 消费。
// conv_state 值为 idle|generating|stopping|error|compressed(snake_case,对齐后端 ConvState serde)。 // conv_state 值为 idle|generating|stopping|error|compressed(snake_case,对齐后端 ConvState serde)。
@@ -806,6 +819,30 @@ export interface AiToolMeta {
risk: 'low' | 'medium' | 'high' 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
}
// ============================================================ // ============================================================
// 知识库 // 知识库
// ============================================================ // ============================================================
+33
View File
@@ -71,6 +71,10 @@
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span> <span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) --> <!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span> <span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
<span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
<svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
</span>
</span> </span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span> <span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
</div> </div>
@@ -161,6 +165,10 @@
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span> <span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) --> <!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span> <span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
<span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
<svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
</span>
</span> </span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span> <span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
</div> </div>
@@ -215,6 +223,10 @@
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span> <span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) --> <!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span> <span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
<span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
<svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
</span>
</span> </span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span> <span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
</div> </div>
@@ -279,6 +291,9 @@ import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai' import { useAiStore } from '../../stores/ai'
// V1 只读消费:跨会话待审批真相源(state.pendingApprovals 仅是活跃会话投影,后台会话挂起不进投影) // V1 只读消费:跨会话待审批真相源(state.pendingApprovals 仅是活跃会话投影,后台会话挂起不进投影)
import { convPendingApprovals } from '../../composables/ai/aiShared' import { convPendingApprovals } from '../../composables/ai/aiShared'
// 无人值守边界徽标只读消费:getConvBoundary 读镜像(reactive Map,模板内读取可追踪;
// 镜像由边界配置卡在切会话/写入时渐进填充,无批量 IPC 属契约限制)
import { getConvBoundary } from '../../composables/ai/useTaskBoundary'
// UX走查 P2-7 只读订阅:rename/archive/pin 失败通知由 useAiConversations 发布,本组件弹就近 toast // UX走查 P2-7 只读订阅:rename/archive/pin 失败通知由 useAiConversations 发布,本组件弹就近 toast
import { convOpFailNotice } from '../../composables/ai/useAiConversations' import { convOpFailNotice } from '../../composables/ai/useAiConversations'
import { useToast } from '../../composables/useToast' import { useToast } from '../../composables/useToast'
@@ -508,6 +523,11 @@ function pendingCountOf(convId: string): number {
return pendingCountByConv.value.get(convId) ?? 0 return pendingCountByConv.value.get(convId) ?? 0
} }
/** 该会话是否已授权无人值守边界(徽标显隐)。直接读 reactive Map,渲染效应自动追踪增删 */
function hasBoundary(convId: string): boolean {
return getConvBoundary(convId) !== null
}
// ── 对话分组:今天 / 昨天 / 更早 ── // ── 对话分组:今天 / 昨天 / 更早 ──
const bucketLabels = computed<Record<string, string>>(() => ({ const bucketLabels = computed<Record<string, string>>(() => ({
today: t('aiChat.today'), today: t('aiChat.today'),
@@ -842,6 +862,19 @@ body.ai-sidebar-resizing * {
text-align: center; text-align: center;
cursor: help; 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 { .ai-conv-item-actions {
display: none; display: none;
align-items: center; align-items: center;
+530
View File
@@ -0,0 +1,530 @@
<template>
<!-- 无人值守边界配置卡(设计 §2.2):发起时一次性授权,存续期生效,随会话同亡。
默认收起(现状零变化);展开后对当前活跃会话 ai_task_boundary_set 一次拍板。
折叠行在有边界时只读回显摘要(数据源镜像,拉取见下);任务 generating 中锁编辑
(中途改边界=边跑边扩权,设计明令禁止)。不引入对话框,最小折叠卡。 -->
<div class="ai-boundary">
<button type="button" class="ai-boundary-toggle" @click="toggleOpen">
<svg class="ai-boundary-chevron" :class="{ 'is-open': open }" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<span class="ai-boundary-toggle-label">{{ t('ai.taskBoundary.toggle') }}</span>
<span v-if="current" class="ai-boundary-echo">{{ echoSummary }}</span>
</button>
<div v-if="open" class="ai-boundary-body">
<div v-if="locked" class="ai-boundary-locked">{{ t('ai.taskBoundary.lockedGenerating') }}</div>
<!-- 拉取失败 ≠ 无边界:失败态禁「一次授权」(防默认空表单覆盖原授权),保留表单现状供重试 -->
<div v-if="loadFailed" class="ai-boundary-loadfail">
<span>{{ t('ai.taskBoundary.loadFailed') }}</span>
<button type="button" class="ai-boundary-mini-btn" @click="retryHydrate">{{ t('ai.taskBoundary.retryLoad') }}</button>
</div>
<div class="ai-boundary-row">
<span class="ai-boundary-label">{{ t('ai.taskBoundary.riskCeiling') }}</span>
<label class="ai-boundary-radio" :class="{ 'is-checked': risk === 'low' }">
<input v-model="risk" type="radio" value="low" :disabled="locked" />
<span>{{ t('ai.taskBoundary.riskLow') }}</span>
</label>
<label class="ai-boundary-radio" :class="{ 'is-checked': risk === 'medium' }">
<input v-model="risk" type="radio" value="medium" :disabled="locked" />
<span>{{ t('ai.taskBoundary.riskMedium') }}</span>
</label>
</div>
<div class="ai-boundary-row ai-boundary-row--col">
<span class="ai-boundary-label">{{ t('ai.taskBoundary.dirs') }}</span>
<div v-if="dirs.length" class="ai-boundary-dirs">
<span v-for="d in dirs" :key="d" class="ai-boundary-dir">
<span class="ai-boundary-dir-path" :title="d">{{ d }}</span>
<button type="button" class="ai-boundary-dir-x" :disabled="locked" @click="removeDir(d)">×</button>
</span>
</div>
<span v-else class="ai-boundary-dim">{{ t('ai.taskBoundary.dirEmpty') }}</span>
<div class="ai-boundary-dir-add">
<input
v-model="dirInput"
class="ai-boundary-input"
type="text"
:placeholder="t('ai.taskBoundary.dirPlaceholder')"
:disabled="locked"
@keydown="onDirInputKeydown"
/>
<button type="button" class="ai-boundary-mini-btn" :disabled="locked || !dirInput.trim()" @click="addDir">{{ t('ai.taskBoundary.dirAdd') }}</button>
</div>
<!-- A2: 坏输入就近红字(非法字符/相对路径),不弹窗;存在性不阻断(白名单目录可尚不存在) -->
<span v-if="dirError" class="ai-boundary-dir-err">{{ dirError === 'illegal' ? t('ai.taskBoundary.dirErrIllegal') : t('ai.taskBoundary.dirErrRelative') }}</span>
</div>
<div class="ai-boundary-row">
<span class="ai-boundary-label">{{ t('ai.taskBoundary.forbidDestructive') }}</span>
</div>
<button type="button" class="ai-boundary-budget-toggle" @click="budgetOpen = !budgetOpen">
<svg class="ai-boundary-chevron" :class="{ 'is-open': budgetOpen }" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
{{ t('ai.taskBoundary.budgetToggle') }}
</button>
<div v-if="budgetOpen" class="ai-boundary-budget">
<label class="ai-boundary-field">
<span>{{ t('ai.taskBoundary.budgetRounds') }}</span>
<input v-model="rounds" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked" />
</label>
<label class="ai-boundary-field">
<span>{{ t('ai.taskBoundary.budgetTokens') }}</span>
<input v-model="tokens" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked" />
</label>
<label class="ai-boundary-field">
<span>{{ t('ai.taskBoundary.budgetWallSecs') }}</span>
<input v-model="wallSecs" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked" />
</label>
<span class="ai-boundary-dim">{{ t('ai.taskBoundary.budgetHint') }}</span>
</div>
<div class="ai-boundary-actions">
<button type="button" class="ai-boundary-confirm" :disabled="locked || loadFailed" @click="confirmSet">{{ t('ai.taskBoundary.confirmSet') }}</button>
<!-- A3: 清除=收权(安全向),generating 中保持可用,与字段编辑锁分离;有 confirm 二次确认 -->
<button v-if="current" type="button" class="ai-boundary-clear" @click="clearBoundary">{{ t('ai.taskBoundary.clearBoundary') }}</button>
</div>
</div>
<!-- 清除边界二次确认(运行中收权=后续工具回到逐次审批,须用户拍板;useConfirm 每实例独立 ref) -->
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" :danger-label="t('ai.taskBoundary.clearBoundary')" @result="answerConfirm" />
</div>
</template>
<script setup lang="ts">
// 无人值守边界配置卡:字段宁少勿预留(设计 §2.2)。目录白名单用简化交互(文本添加+删除),
// 不复刻设置页完整目录选择器;预算三项折叠,空=不限(不传 budget)。
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { fetchConvBoundary, getConvBoundary, saveConvBoundary, isConvBoundaryFetchFailed } from '../../composables/ai/useTaskBoundary'
import { useConfirm } from '../../composables/useConfirm'
import ConfirmDialog from '../ConfirmDialog.vue'
import type { TaskBoundary } from '../../api/types'
const emit = defineEmits<{
(e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' | 'success' }): void
}>()
const store = useAiStore()
const { t } = useI18n()
// 清除边界二次确认(A3);useConfirm 每调用独立 ref,不与 AiChat 的实例串台
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// 收起为默认态(未展开 = 现状逐次审批,零行为变化)
const open = ref(false)
const risk = ref<'low' | 'medium'>('low')
const dirs = ref<string[]>([])
const dirInput = ref('')
// A2: 目录输入词法校验结果(空串=无错;illegal=含非法字符;relative=非绝对路径)
const dirError = ref<'' | 'illegal' | 'relative'>('')
// A1: 边界拉取失败态(≠ 无边界;禁「一次授权」防默认空表单覆盖原授权)
const loadFailed = ref(false)
const budgetOpen = ref(false)
const rounds = ref('')
const tokens = ref('')
const wallSecs = ref('')
const activeId = computed(() => store.state.activeConversationId)
// 生成中锁编辑:防「边跑边扩权」。isGenerating 派生自 convStates reactive Map,computed 内可追踪
const locked = computed(() => store.isGenerating(activeId.value))
// 当前会话已授权边界(镜像;折叠行回显摘要数据源)
const current = computed(() => getConvBoundary(activeId.value))
function resetForm(): void {
risk.value = 'low'
dirs.value = []
dirInput.value = ''
dirError.value = ''
budgetOpen.value = false
rounds.value = ''
tokens.value = ''
wallSecs.value = ''
}
/** 从镜像回填表单(展开或切会话时调);镜像 miss 先拉一次再回填。
* A1: 拉取失败与「确认无边界」分流——失败态置 loadFailed 禁授权,表单保留现状不清空 */
async function hydrate(): Promise<void> {
const id = activeId.value
if (!id) {
resetForm()
loadFailed.value = false
return
}
await fetchConvBoundary(id)
if (activeId.value !== id) return
if (isConvBoundaryFetchFailed(id)) {
loadFailed.value = true
return
}
loadFailed.value = false
const b = getConvBoundary(id)
if (!b) {
resetForm()
return
}
// 单选只提供 low/medium(后端把 high 钳到 medium);万一存量 high 回显按 medium 呈现
risk.value = b.risk_ceiling === 'medium' || b.risk_ceiling === 'high' ? 'medium' : 'low'
dirs.value = [...b.dir_allowlist]
rounds.value = b.budget ? String(b.budget.max_rounds) : ''
tokens.value = b.budget ? String(b.budget.max_tokens) : ''
wallSecs.value = b.budget ? String(b.budget.wall_secs) : ''
budgetOpen.value = !!b.budget
}
/** 失败态重试(错误条按钮):重走拉取+回填 */
function retryHydrate(): void {
void hydrate()
}
function toggleOpen(): void {
open.value = !open.value
if (open.value) void hydrate()
}
// 切会话:预拉边界喂侧栏徽标镜像;展开态走 hydrate(内含拉取)重填表单,防上一会话表单串台
watch(activeId, (id) => {
if (!id) return
if (open.value) void hydrate()
else void fetchConvBoundary(id)
})
onMounted(() => {
if (activeId.value) void fetchConvBoundary(activeId.value)
})
/** 目录输入词法校验(A2,只做本地词法;存在性不阻断——白名单目录可尚不存在,后端
* 运行期 canonicalize 本就是 best-effort)。非法字符:Windows 保留集(冒号仅盘符位合法);
* 相对路径:须盘符/UNC/POSIX 根开头。返回空串=合法 */
function validateDirInput(d: string): '' | 'illegal' | 'relative' {
const body = /^[a-zA-Z]:/.test(d) ? d.slice(2) : d
if (/[<>:"|?*]/.test(body) || /[\u0000-\u001f]/.test(d)) return 'illegal'
const isAbsolute = /^[a-zA-Z]:[\\/]/.test(d) || d.startsWith('\\\\') || d.startsWith('/')
if (!isAbsolute) return 'relative'
return ''
}
function addDir(): void {
const d = dirInput.value.trim()
if (!d) return
const err = validateDirInput(d)
if (err) {
dirError.value = err
return
}
dirError.value = ''
if (!dirs.value.includes(d)) dirs.value.push(d)
dirInput.value = ''
}
// 输入变化即清红字(改一个字就该有机会过下一轮校验,红字只描述当前输入的上一态)
watch(dirInput, () => {
if (dirError.value) dirError.value = ''
})
/** 目录输入回车即添加;IME 组词中的回车属选词非提交,放行给输入法 */
function onDirInputKeydown(e: KeyboardEvent): void {
if (e.isComposing || e.keyCode === 229) return
if (e.key === 'Enter') {
e.preventDefault()
addDir()
}
}
function removeDir(d: string): void {
dirs.value = dirs.value.filter(x => x !== d)
}
/** 预算三项解析:全空=undefined(不限);任一非空但非正整数视为无效返回 null */
function parseBudget(): { max_rounds: number; max_tokens: number; wall_secs: number } | undefined | null {
const fields = [
{ v: rounds.value.trim(), min: 1 },
{ v: tokens.value.trim(), min: 1 },
{ v: wallSecs.value.trim(), min: 1 },
]
if (fields.every(f => !f.v)) return undefined
const nums: number[] = []
for (const f of fields) {
if (!f.v) {
nums.push(0)
continue
}
const n = Number(f.v)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) return null
nums.push(n)
}
return { max_rounds: nums[0], max_tokens: nums[1], wall_secs: nums[2] }
}
/** 一次授权:set 当前活跃会话边界并收卡。未落库会话后端内存即生效,首条消息落库随行 */
async function confirmSet(): Promise<void> {
const id = activeId.value
if (!id || locked.value || loadFailed.value) return
const budget = parseBudget()
if (budget === null) {
emit('toast', { msg: t('ai.taskBoundary.budgetInvalid'), type: 'warning' })
return
}
const boundary: TaskBoundary = {
risk_ceiling: risk.value,
dir_allowlist: dirs.value.slice(),
forbid_destructive: true,
budget: budget ?? null,
}
try {
await saveConvBoundary(id, boundary)
open.value = false
emit('toast', { msg: t('ai.taskBoundary.setOk'), type: 'success' })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
emit('toast', { msg: t('ai.taskBoundary.setFail', { msg }), type: 'error' })
}
}
/** 清除边界:set null,回退逐次审批(接管转人工语义),可随时重新授权。
* A3: 收权是安全向,不受 generating 编辑锁限制(任务跑偏时可立即收回授权,唯一出路
* 不该是停掉整个任务);因运行中收权影响后续工具审批方式,加 confirm 二次确认 */
async function clearBoundary(): Promise<void> {
const id = activeId.value
if (!id) return
if (!await confirmDialog(t('ai.taskBoundary.clearConfirm'))) return
try {
await saveConvBoundary(id, null)
resetForm()
emit('toast', { msg: t('ai.taskBoundary.clearOk'), type: 'info' })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
emit('toast', { msg: t('ai.taskBoundary.setFail', { msg }), type: 'error' })
}
}
// 折叠行只读回显摘要:「已授权 · 风险上限 中 · 2 个目录 · 预算不限」
const echoSummary = computed(() => {
const b = current.value
if (!b) return ''
const riskWord = b.risk_ceiling === 'low'
? t('ai.taskBoundary.riskLow')
: b.risk_ceiling === 'medium'
? t('ai.taskBoundary.riskMedium')
: t('ai.agentTrace.riskHigh') // 存量 high 边界回显(单选已不产出 high,兜底转 i18n 不直出裸串)
const parts = [
t('ai.taskBoundary.echoRisk', { risk: riskWord }),
t('ai.taskBoundary.echoDirs', { n: b.dir_allowlist.length }),
b.budget
? t('ai.taskBoundary.echoBudget', { rounds: b.budget.max_rounds, tokens: b.budget.max_tokens, secs: b.budget.wall_secs })
: t('ai.taskBoundary.echoNoBudget'),
]
return parts.join(' · ')
})
</script>
<style scoped>
/* 输入区上方的轻量折叠条,视觉克制度对齐 .ai-input-hint(不抢输入框焦点注意力) */
.ai-boundary {
margin-bottom: 4px;
font-size: 11px;
color: var(--df-text-dim);
}
.ai-boundary-toggle {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 4px;
border: none;
background: none;
color: var(--df-text-dim);
font-size: 11px;
cursor: pointer;
border-radius: var(--df-radius-sm, 4px);
max-width: 100%;
}
.ai-boundary-toggle:hover { color: var(--df-text); }
.ai-boundary-chevron {
transition: transform 0.15s var(--df-ease);
flex-shrink: 0;
}
.ai-boundary-chevron.is-open { transform: rotate(90deg); }
.ai-boundary-toggle-label { font-weight: 600; }
.ai-boundary-echo {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--df-accent);
opacity: 0.85;
}
.ai-boundary-body {
margin-top: 4px;
display: flex;
flex-direction: column;
gap: 7px;
padding: 8px;
background: color-mix(in srgb, var(--df-accent) 5%, transparent);
border: 0.5px solid color-mix(in srgb, var(--df-accent) 20%, transparent);
border-radius: var(--df-radius);
}
.ai-boundary-locked {
font-size: 10.5px;
color: var(--df-warning);
}
.ai-boundary-loadfail {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 10.5px;
color: var(--df-danger);
}
.ai-boundary-dir-err {
font-size: 10.5px;
color: var(--df-danger);
}
.ai-boundary-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.ai-boundary-row--col {
flex-direction: column;
align-items: stretch;
gap: 5px;
}
.ai-boundary-label {
font-weight: 600;
color: var(--df-text);
}
.ai-boundary-radio {
display: inline-flex;
align-items: center;
gap: 3px;
cursor: pointer;
color: var(--df-text-dim);
}
.ai-boundary-radio.is-checked { color: var(--df-accent); }
.ai-boundary-radio input { accent-color: var(--df-accent); margin: 0; }
.ai-boundary-radio:has(input:disabled) { cursor: not-allowed; opacity: 0.6; }
.ai-boundary-dirs {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.ai-boundary-dir {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 1px 4px;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: calc(var(--df-radius) - 2px);
font-size: 10.5px;
color: var(--df-text);
max-width: 100%;
}
.ai-boundary-dir-path {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
direction: rtl;
font-family: var(--df-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
}
.ai-boundary-dir-x {
border: none;
background: none;
color: var(--df-text-dim);
cursor: pointer;
font-size: 12px;
line-height: 1;
padding: 0 2px;
}
.ai-boundary-dir-x:hover:not(:disabled) { color: var(--df-danger); }
.ai-boundary-dir-x:disabled { cursor: not-allowed; opacity: 0.5; }
.ai-boundary-dir-add {
display: flex;
gap: 4px;
}
.ai-boundary-input {
flex: 1;
min-width: 0;
font-size: 11px;
padding: 2px 6px;
background: var(--df-bg-card);
color: var(--df-text);
border: 0.5px solid var(--df-border);
border-radius: calc(var(--df-radius) - 2px);
outline: none;
}
.ai-boundary-input:focus { border-color: color-mix(in srgb, var(--df-accent) 40%, transparent); }
.ai-boundary-input:disabled { opacity: 0.5; cursor: not-allowed; }
.ai-boundary-input--num { flex: 0 0 90px; }
.ai-boundary-mini-btn {
border: 0.5px solid var(--df-border);
background: var(--df-bg-card);
color: var(--df-text-dim);
font-size: 11px;
padding: 2px 8px;
cursor: pointer;
border-radius: calc(var(--df-radius) - 2px);
flex-shrink: 0;
}
.ai-boundary-mini-btn:hover:not(:disabled) { color: var(--df-text); }
.ai-boundary-mini-btn:disabled { cursor: not-allowed; opacity: 0.5; }
.ai-boundary-budget-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
border: none;
background: none;
padding: 0;
color: var(--df-text-dim);
font-size: 11px;
cursor: pointer;
align-self: flex-start;
}
.ai-boundary-budget-toggle:hover { color: var(--df-text); }
.ai-boundary-budget {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.ai-boundary-field {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--df-text-dim);
white-space: nowrap;
}
.ai-boundary-dim {
font-size: 10px;
opacity: 0.7;
}
.ai-boundary-actions {
display: flex;
align-items: center;
gap: 8px;
}
.ai-boundary-confirm {
border: none;
background: var(--df-accent);
color: #fff;
font-size: 11px;
font-weight: 600;
padding: 4px 10px;
cursor: pointer;
border-radius: calc(var(--df-radius) - 2px);
transition: background 0.15s var(--df-ease);
}
.ai-boundary-confirm:hover:not(:disabled) { background: var(--df-accent-hover); }
.ai-boundary-confirm:disabled { opacity: 0.5; cursor: not-allowed; }
.ai-boundary-clear {
border: 0.5px solid var(--df-border);
background: transparent;
color: var(--df-text-dim);
font-size: 11px;
padding: 4px 10px;
cursor: pointer;
border-radius: calc(var(--df-radius) - 2px);
}
.ai-boundary-clear:hover:not(:disabled) { color: var(--df-danger); border-color: var(--df-danger); }
.ai-boundary-clear:disabled { cursor: not-allowed; opacity: 0.5; }
</style>
+170
View File
@@ -0,0 +1,170 @@
<template>
<!-- 无人值守安全停靠卡(设计 §四):后端非正常终局(六路,stop_kind 词表见 i18n)emit AiTaskParked(已保存现场并复位
generating,非死等),useAiEvents/useAiLifecycleEvents 登记 pendingTaskParked(per-conv Map)
后本卡渲染。复用 HelpRequiredCard 模式:per-conv 守卫(挂起会话===当前展示会话)+ 行为后摘卡。
卡面 = reason 人话 + stop_kind 标签;操作两枚:继续(ai_continue_loop 重新授权续跑)/
停止归档(ai_chat_stop 既有停止路径,归档会话走侧栏既有入口,此处不代做)。
cancelled 例外只显停止归档(用户主动取消后不再诱导续跑)。 -->
<div v-if="parkedActive" ref="cardRef" class="ai-task-parked">
<div class="ai-task-parked-head">
<span class="ai-task-parked-tag" :class="kindClass">{{ kindLabel }}</span>
<span class="ai-task-parked-title">{{ t('ai.taskParked.title') }}</span>
</div>
<span class="ai-task-parked-reason">{{ parkedActive.reason }}</span>
<div class="ai-task-parked-actions">
<!-- cancelled(审批挂起中被用户主动取消)不显「继续」:用户刚点过停止,首位按钮再诱导续跑与意图相逆 -->
<button v-if="parkedActive.stopKind !== 'cancelled'" class="ai-task-parked-btn" :disabled="busy" @click="handleContinue">{{ t('aiChat.continueLoop') }}</button>
<button class="ai-task-parked-btn ai-task-parked-btn--muted" :disabled="busy" @click="handleStopArchive">{{ t('ai.taskParked.stopArchive') }}</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { aiApi } from '../../api'
import { pendingTaskParked } from '../../composables/ai/useAiPendingState'
// toast 经 emit 转父(保持单一 toast 源,与 HelpRequiredCard 一致)
const emit = defineEmits<{
(e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' }): void
}>()
const store = useAiStore()
const { t } = useI18n()
// 当前展示会话的停靠挂起(无则不渲染)。后端停靠已复位 generating,与 HelpRequiredCard 同理
// 不依赖 streaming 判断显隐。Map 键控 per-conv,多会话各自停靠互不覆盖。
const parkedActive = computed(() => {
const id = store.state.activeConversationId
if (!id) return null
return pendingTaskParked.get(id) ?? null
})
// stop_kind 词表(后端契约钉死):命中词表取对应文案与色级,未知值中性兜底(向后兼容后端新增变体)
const KNOWN_KINDS = new Set(['approval_timeout', 'cancelled', 'circuit_breaker', 'stall_breaker', 'max_rounds', 'fatal', 'midstream'])
const kindLabel = computed(() => {
const k = parkedActive.value?.stopKind
if (k && KNOWN_KINDS.has(k)) return t(`ai.taskParked.stopKind.${k}`)
return t('ai.taskParked.unknownKind')
})
// fatal 走 danger 红色(连续失败重入预算耗尽,警示级最高;语义上仍有「继续」路),其余 warning 琥珀
const kindClass = computed(() => (parkedActive.value?.stopKind === 'fatal' ? 'ai-task-parked-tag--fatal' : ''))
// 卡片根 ref。停靠常发生在多轮迭代后消息流已长,卡显时 scrollIntoView 让操作按钮立即可见
const cardRef = ref<HTMLElement | null>(null)
watch(parkedActive, async (p) => {
if (!p) return
await nextTick()
cardRef.value?.scrollIntoView({ block: 'center', behavior: 'smooth' })
})
// 操作进行中防重复点击(继续/停止都是 IPC 往返,连点会重复触发续跑/停止)
const busy = ref(false)
/** 继续:ai_continue_loop 重新授权续跑(预算重计=用户再授权语义);成功即摘卡 */
async function handleContinue(): Promise<void> {
const convId = parkedActive.value?.conversationId
if (!convId || busy.value) return
busy.value = true
try {
await aiApi.continueLoop(convId)
pendingTaskParked.delete(convId)
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
emit('toast', { msg: t('aiChat.continueLoopFailed', { msg }), type: 'error' })
} finally {
busy.value = false
}
}
/** 停止归档:既有 ai_chat_stop 停止路径;停靠态 loop 已复位,此调用确保任何残留终局并落库,
* 成功后摘卡(用户不再续跑)。会话归档沿用侧栏既有入口,此处不代做避免不可逆误触 */
async function handleStopArchive(): Promise<void> {
const convId = parkedActive.value?.conversationId
if (!convId || busy.value) return
busy.value = true
try {
await aiApi.stopChat(convId)
pendingTaskParked.delete(convId)
emit('toast', { msg: t('ai.taskParked.stoppedToast'), type: 'info' })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
emit('toast', { msg: t('ai.taskParked.archiveFailed', { msg }), type: 'error' })
} finally {
busy.value = false
}
}
</script>
<style scoped>
/* 复用 HelpRequiredCard 样式基调(warning 色系,停靠属"需用户拍板"信号,视觉一致) */
.ai-task-parked {
display: flex;
flex-direction: column;
gap: 3px;
margin-bottom: 6px;
padding: 6px 8px;
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
border: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent);
border-radius: var(--df-radius);
}
.ai-task-parked-head {
display: flex;
align-items: center;
gap: 6px;
}
.ai-task-parked-tag {
flex-shrink: 0;
padding: 0 6px;
border-radius: calc(var(--df-radius) - 2px);
background: var(--df-warning);
color: #000;
font-size: 10px;
font-weight: 600;
line-height: 16px;
}
.ai-task-parked-tag--fatal {
background: var(--df-danger);
color: #fff;
}
.ai-task-parked-title {
font-size: 11.5px;
font-weight: 600;
color: var(--df-warning);
}
.ai-task-parked-reason {
font-size: 11px;
color: var(--df-text-dim);
opacity: 0.9;
word-break: break-word;
white-space: pre-wrap;
}
.ai-task-parked-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 2px;
}
.ai-task-parked-btn {
border: 0.5px solid color-mix(in srgb, var(--df-warning) 60%, transparent);
background: var(--df-warning);
color: #000;
cursor: pointer;
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: calc(var(--df-radius) - 2px);
transition: filter 0.15s var(--df-ease);
}
.ai-task-parked-btn:hover { filter: brightness(1.1); }
.ai-task-parked-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.ai-task-parked-btn--muted {
background: transparent;
color: var(--df-text-dim);
border-color: var(--df-border);
}
.ai-task-parked-btn--muted:hover { color: var(--df-text); }
</style>
+80 -1
View File
@@ -119,6 +119,20 @@
<div class="ai-bottom-tools" ref="goalsRef"> <div class="ai-bottom-tools" ref="goalsRef">
<span v-if="currentConvTitle" class="ai-bottom-title">{{ currentConvTitle }}</span> <span v-if="currentConvTitle" class="ai-bottom-title">{{ currentConvTitle }}</span>
<div class="ai-bottom-tools-right"> <div class="ai-bottom-tools-right">
<!-- 无人值守任务进度(AiTaskProgress,设计 §4.3):当前会话有进度快照时显示,
静默期(子代理长跑/大工具执行/重入退避)凭最后快照可见「在干活」;终局即清。
max_rounds=0(不限轮次)不显分母进度条,只显轮次与 token。 -->
<div v-if="taskProgress" class="ai-task-progress">
<span class="ai-task-progress-dot"></span>
<span class="ai-task-progress-text">{{ taskProgressText }}</span>
<span
v-if="taskProgress.maxRounds > 0"
class="ai-task-progress-bar"
:title="$t('ai.taskProgress.barHint', { round: taskProgress.round, max: taskProgress.maxRounds })"
>
<span class="ai-task-progress-fill" :style="{ width: taskProgressPct + '%' }"></span>
</span>
</div>
<!-- 目标计数 --> <!-- 目标计数 -->
<div v-if="goals.length" class="ai-goals-inline"> <div v-if="goals.length" class="ai-goals-inline">
<span class="ai-goals-inline-badge ai-tool-badge" @click="togglePanel('goals')" :title="$t('aiChat.viewGoals')"> <span class="ai-goals-inline-badge ai-tool-badge" @click="togglePanel('goals')" :title="$t('aiChat.viewGoals')">
@@ -217,6 +231,7 @@ import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai' import { useAiStore } from '../../stores/ai'
import { aiApi } from '../../api/ai' import { aiApi } from '../../api/ai'
import { pendingTaskProgress } from '../../composables/ai/useAiPendingState'
import type { AiMessage, ModelConfig, GoalEntry } from '../../api/types' import type { AiMessage, ModelConfig, GoalEntry } from '../../api/types'
defineProps<{ defineProps<{
@@ -356,6 +371,28 @@ const currentConvTitle = computed(() => {
return conv?.title || '' 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) // 🎯 对话目标面板(对话透明化 L1:Goal visibility)
const goals = ref<GoalEntry[]>([]) const goals = ref<GoalEntry[]>([])
const goalsExpanded = ref(false) const goalsExpanded = ref(false)
@@ -744,7 +781,10 @@ onBeforeUnmount(() => {
color: var(--df-text-secondary); color: var(--df-text-secondary);
min-width: 0; min-width: 0;
margin-right: auto; margin-right: auto;
/* 长标题单行截断(防换行撑高工具行;右侧徽章族 flex-shrink:0,分离窄窗口更挤) */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.ai-bottom-tools-right { .ai-bottom-tools-right {
display: flex; display: flex;
@@ -753,6 +793,45 @@ onBeforeUnmount(() => {
flex-shrink: 0; flex-shrink: 0;
margin-left: auto; 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 中间区) ═══ */ /* ═══ Provider Bar (header 中间区) ═══ */
.ai-provider-bar { .ai-provider-bar {
display: inline-flex; display: inline-flex;
+71 -24
View File
@@ -10,9 +10,10 @@ import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
import { persistUiState } from './useAiPanel' import { persistUiState } from './useAiPanel'
import { setStreaming } from './streamingGuard' 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 { 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() const appSettings = useAppSettingsStore()
@@ -154,6 +155,12 @@ function getLoadMoreState() {
return loadMoreCursor 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 共用)。 * 后端 ChatMessage[] → 前端 AiMessage[](switch + load_more 共用)。
* 过滤/映射语义与 switchConversation 原内联逻辑逐行对齐(tool 消息 + truncated 软删剔除、 * 过滤/映射语义与 switchConversation 原内联逻辑逐行对齐(tool 消息 + truncated 软删剔除、
@@ -161,8 +168,12 @@ function getLoadMoreState() {
* id:优先后端真实消息 id(DB 主键,prepend 时 v-for key 稳定),缺失兜底 `${fallbackPrefix}-${i}` * id:优先后端真实消息 id(DB 主键,prepend 时 v-for key 稳定),缺失兜底 `${fallbackPrefix}-${i}`
* (老数据无 id / 旧 messages JSON 列)。fallbackPrefix 区分 switch('loaded')与 load_more('older'), * (老数据无 id / 旧 messages JSON 列)。fallbackPrefix 区分 switch('loaded')与 load_more('older'),
* 防两条 id 方案在 prepend 后冲突。 * 防两条 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 映射,用于回填工具执行结果 // 构建 tool_call_id → tool_result 映射,用于回填工具执行结果
const toolResultMap = new Map<string, string>() const toolResultMap = new Map<string, string>()
for (const m of rawMsgs) { for (const m of rawMsgs) {
@@ -227,7 +238,9 @@ function parseConvMessages(rawMsgs: any[], fallbackPrefix: string): AiMessage[]
id: tc.id, id: tc.id,
name: tc.function?.name || '', name: tc.function?.name || '',
args, args,
status: 'completed' as const, status: (toolResultMap.has(tc.id) || !convGenerating
? 'completed'
: 'running') as AiToolCallStatus,
result: toolResultMap.get(tc.id), result: toolResultMap.get(tc.id),
} }
}), }),
@@ -248,7 +261,7 @@ async function loadMoreHistory(): Promise<boolean> {
// 过期丢弃:等待期间用户切走/删除会话,游标已指向别的 conv → 不 prepend 错视图 // 过期丢弃:等待期间用户切走/删除会话,游标已指向别的 conv → 不 prepend 错视图
if (cur.convId !== state.activeConversationId) return false if (cur.convId !== state.activeConversationId) return false
const rawMsgs = typeof res.messages === 'string' ? JSON.parse(res.messages) : res.messages 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 去重(防御:游标边界变化 / 与已加载消息重复) // 按 id 去重(防御:游标边界变化 / 与已加载消息重复)
const existing = new Set(state.messages.map(m => m.id)) const existing = new Set(state.messages.map(m => m.id))
const fresh = older.filter(m => !existing.has(m.id)) const fresh = older.filter(m => !existing.has(m.id))
@@ -272,12 +285,16 @@ async function loadMoreHistory(): Promise<boolean> {
/** 切换到指定会话:加载历史消息(含 tool_calls 回填 + tool_result 映射 + pending 审批恢复) /** 切换到指定会话:加载历史消息(含 tool_calls 回填 + tool_result 映射 + pending 审批恢复)
* @param force true=跳过同会话短路(程序化刷新当前会话用:清空/压缩后回刷,如 AiChat.vue * @param force true=跳过同会话短路(程序化刷新当前会话用:清空/压缩后回刷,如 AiChat.vue
* onCleared/onCompressed 回调);false(默认)=同会话点击直接 return。 */ * onCleared/onCompressed 回调);false(默认)=同会话点击直接 return。
export async function switchConversation(id: string, force = false) { * @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 + // 同会话点击短路:已活跃会话不重拉。原实现无条件走 currentText='' + 全量 parse +
// pending 重恢复,流式中点侧栏高亮项会清空流式文本、delta 从空续流 → 回复缺前缀。短路保留 // pending 重恢复,流式中点侧栏高亮项会清空流式文本、delta 从空续流 → 回复缺前缀。短路保留
// in-flight 流式文本。程序化刷新(force=true)不受影响。 // in-flight 流式文本。程序化刷新(force=true)不受影响。
if (!force && id === state.activeConversationId) return if (!force && id === state.activeConversationId) return 'ok'
// 切换会话时清空所有审批计时器(防切走后过期 timer 误拒后台挂起审批 + 错误气泡推错视图)。 // 切换会话时清空所有审批计时器(防切走后过期 timer 误拒后台挂起审批 + 错误气泡推错视图)。
// 新会话的审批计时器由下文恢复挂起路径的 startApprovalTimer 重新建立。 // 新会话的审批计时器由下文恢复挂起路径的 startApprovalTimer 重新建立。
clearAllApprovalTimers() clearAllApprovalTimers()
@@ -294,9 +311,14 @@ export async function switchConversation(id: string, force = false) {
// 瞬态 IPC 失败(网络抖动/后端异常)保留原视图 + 推错误气泡,不再吞当前视图。 // 瞬态 IPC 失败(网络抖动/后端异常)保留原视图 + 推错误气泡,不再吞当前视图。
const errMsg = e instanceof Error ? e.message : String(e) const errMsg = e instanceof Error ? e.message : String(e)
if (errMsg.includes('对话不存在')) { if (errMsg.includes('对话不存在')) {
// 程序化跳转(P3-7):目标已删不静默新建,留原视图由调用方就地反馈
if (opts?.noCreateFallback) {
console.warn('[AI] switchConversation: 会话不存在(已删除),保持原视图:', id)
return 'not_found'
}
console.warn('[AI] switchConversation 失败(对话不存在),创建新对话:', id) console.warn('[AI] switchConversation 失败(对话不存在),创建新对话:', id)
const created = await aiApi.createConversation() const created = await aiApi.createConversation()
if (mySwitchId !== _latestSwitchId) return if (mySwitchId !== _latestSwitchId) return 'ok'
void appSettings.set('df-ai-active-conv', created.id) void appSettings.set('df-ai-active-conv', created.id)
// 兜底置 activeId:此路径原只持久化不写 state.activeConversationId,旧(不存在)id 残留 // 兜底置 activeId:此路径原只持久化不写 state.activeConversationId,旧(不存在)id 残留
// 会让后续所有事件被 isCurrent 判为非当前而丢弃或误路由 // 会让后续所有事件被 isCurrent 判为非当前而丢弃或误路由
@@ -313,15 +335,15 @@ export async function switchConversation(id: string, force = false) {
setConvPendingApprovals(created.id, []) setConvPendingApprovals(created.id, [])
notifyConversationChanged() notifyConversationChanged()
scheduleConversationsRefresh() scheduleConversationsRefresh()
return return 'ok'
} }
// 瞬态失败:保留当前视图 + 错误气泡(复用会话操作失败气泡模式);过期响应丢弃。 // 瞬态失败:保留当前视图 + 错误气泡(复用会话操作失败气泡模式);过期响应丢弃。
// 保留 scheduleConversationsRefresh():陈旧 id(已被后端删除)在下一次列表刷新中消失。 // 保留 scheduleConversationsRefresh():陈旧 id(已被后端删除)在下一次列表刷新中消失。
console.error('[AI] switchConversation 失败(瞬态),保留当前视图:', e) console.error('[AI] switchConversation 失败(瞬态),保留当前视图:', e)
if (mySwitchId !== _latestSwitchId) return if (mySwitchId !== _latestSwitchId) return 'ok'
pushConvOpFail('switchConvFail') pushConvOpFail('switchConvFail')
scheduleConversationsRefresh() scheduleConversationsRefresh()
return return 'ok'
} finally { } finally {
// 切换结束(含失败/过期/新建会话路径)清切换中标记 + 统一收集并清空缓冲。 // 切换结束(含失败/过期/新建会话路径)清切换中标记 + 统一收集并清空缓冲。
// 成功提交路径在下方用 _switchBufferedText 恢复续显;失败/过期路径缓冲被丢弃, // 成功提交路径在下方用 _switchBufferedText 恢复续显;失败/过期路径缓冲被丢弃,
@@ -331,7 +353,7 @@ export async function switchConversation(id: string, force = false) {
setConvCurrentText(id, '') setConvCurrentText(id, '')
} }
// 过期响应丢弃(用户已切到别的对话,防 A 后返回覆盖 B) // 过期响应丢弃(用户已切到别的对话,防 A 后返回覆盖 B)
if (mySwitchId !== _latestSwitchId) return if (mySwitchId !== _latestSwitchId) return 'ok'
// 透传 has_more/earliest_seq 游标(前端滚顶加载更多历史)。 // 透传 has_more/earliest_seq 游标(前端滚顶加载更多历史)。
// 后端在 generating/空对话等场景返 false/null,此处默认兜底。 // 后端在 generating/空对话等场景返 false/null,此处默认兜底。
const switchDetailAny = detail as any const switchDetailAny = detail as any
@@ -343,8 +365,7 @@ export async function switchConversation(id: string, force = false) {
// (输入区 v-if=streaming)→ 点击停止会传 activeConversationId 发错会话。对齐新建会话的 // (输入区 v-if=streaming)→ 点击停止会传 activeConversationId 发错会话。对齐新建会话的
// 复位语义;目标在后台生成时保留 true(停止/流式显示正确)。读会话状态(枚举真相源)派生, // 复位语义;目标在后台生成时保留 true(停止/流式显示正确)。读会话状态(枚举真相源)派生,
// 桥接语义:非终止三态(generating/stopping/compressed)视为生成中。 // 桥接语义:非终止三态(generating/stopping/compressed)视为生成中。
const targetCs = getConvState(id) const targetGen = isConvGenerating(id)
const targetGen = targetCs === 'generating' || targetCs === 'stopping' || targetCs === 'compressed'
// 先 parse 成功替换 messages,成功后再置 activeConversationId(同一同步块内赋值, // 先 parse 成功替换 messages,成功后再置 activeConversationId(同一同步块内赋值,
// 无 await 间隙 → 无中间态渲染)。原顺序先置 active 后 parse,parse 失败时 active 已是新 id 而 // 无 await 间隙 → 无中间态渲染)。原顺序先置 active 后 parse,parse 失败时 active 已是新 id 而
@@ -356,7 +377,8 @@ export async function switchConversation(id: string, force = false) {
// 消息映射收敛进 parseConvMessages(切换 + 加载更多共用,过滤/映射语义一致)。 // 消息映射收敛进 parseConvMessages(切换 + 加载更多共用,过滤/映射语义一致)。
// 相比原内联逻辑唯一行为变化:id 由 `loaded-${i}` 改为优先后端真实消息 id // 相比原内联逻辑唯一行为变化:id 由 `loaded-${i}` 改为优先后端真实消息 id
// (DB 主键,prepend 时 v-for key 稳定不重建 DOM),缺失才兜底 `loaded-${i}`。 // (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 一致) // parse 成功才置 active(保证 active 与 messages 一致)
state.activeConversationId = id state.activeConversationId = id
// 切到 round0 进行中的会话时,后端该轮 per_conv 只有 user 消息(assistant 占位 // 切到 round0 进行中的会话时,后端该轮 per_conv 只有 user 消息(assistant 占位
@@ -393,12 +415,17 @@ export async function switchConversation(id: string, force = false) {
isError: true, isError: true,
timestamp: Date.now(), timestamp: Date.now(),
} as AiMessage) } as AiMessage)
return return 'ok'
} }
// 切换提交完成才持久化活跃会话 + 重算 streaming(active 已指向目标 conv) // 切换提交完成才持久化活跃会话 + 重算 streaming(active 已指向目标 conv)
void appSettings.set('df-ai-active-conv', id) void appSettings.set('df-ai-active-conv', id)
setStreaming(targetGen, { convId: id, reason: 'switchConversation-recompute' }) setStreaming(targetGen, { convId: id, reason: 'switchConversation-recompute' })
// P2-2 完成卡回补:重启/分离窗回收后内存卡清零,按父 conv 反查子代理轨迹重建。
// 启动恢复也走本函数(loadConversations 的 switchConversation(pending)),一处挂载全覆盖;
// fire-and-forget 不阻塞切换,幂等去重与静默降级见 backfillConvAgentFinished。
void backfillConvAgentFinished(id)
// 加载历史对话 token 总量(来自 DB summary);切换对话清空实时值 // 加载历史对话 token 总量(来自 DB summary);切换对话清空实时值
const conv = state.conversations.find(c => c.id === id) const conv = state.conversations.find(c => c.id === id)
if (conv && conv.prompt_tokens != null && conv.completion_tokens != null) { if (conv && conv.prompt_tokens != null && conv.completion_tokens != null) {
@@ -429,7 +456,7 @@ export async function switchConversation(id: string, force = false) {
try { try {
const pending = await aiApi.pendingToolCalls(id) const pending = await aiApi.pendingToolCalls(id)
// 第二 await 后二次比对:切换 A→B 期间避免用 A 的挂起覆写 B 的 pendingApprovals // 第二 await 后二次比对:切换 A→B 期间避免用 A 的挂起覆写 B 的 pendingApprovals
if (mySwitchId !== _latestSwitchId) return if (mySwitchId !== _latestSwitchId) return 'ok'
if (pending.length) { if (pending.length) {
// kind 索引:tool_call_id → kind('risk'/'path'),供恢复工具卡/挂起列表按类型渲染 // kind 索引:tool_call_id → kind('risk'/'path'),供恢复工具卡/挂起列表按类型渲染
const pendingKindMap = new Map(pending.map(p => [p.tool_call_id, p.kind])) 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 = [] state.pendingApprovals = []
} }
} }
return 'ok'
} }
/** 删除会话;若删的是当前活跃会话则清空消息+移除活跃 id 持久化。 /** 删除会话;若删的是当前活跃会话则清空消息+移除活跃 id 持久化。
@@ -541,13 +569,16 @@ async function deleteConversation(id: string) {
notifyConversationChanged({ deletedConvId: id }) 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 = type ConvOpFailKey =
| 'renameConvFail' | 'archiveConvFail' | 'pinConvFail' | 'renameConvFail' | 'archiveConvFail' | 'pinConvFail'
| 'newConvFail' | 'deleteConvFail' | 'switchConvFail' | 'newConvFail' | 'deleteConvFail' | 'switchConvFail'
| 'convDeleted'
/** UX走查 P2-7:rename/archive/pin 失败的侧栏就近通知(发布-订阅)。每次失败发布新对象, /** UX走查 P2-7:rename/archive/pin 失败的侧栏就近通知(发布-订阅)。每次失败发布新对象,
* ConversationSidebar watch 到即弹 toast;本模块不感知展示细节(翻译/计时归订阅方)。 */ * ConversationSidebar watch 到即弹 toast;本模块不感知展示细节(翻译/计时归订阅方)。
* convDeleted(P3-7/P1-1 分离窗路径)也走此通道:反馈须落在承接会话的窗口内。 */
export const convOpFailNotice = ref<{ export const convOpFailNotice = ref<{
key: ConvOpFailKey key: ConvOpFailKey
params?: Record<string, string | number> params?: Record<string, string | number>
@@ -657,17 +688,33 @@ function toggleSidebar() {
} }
/** 跨视图跳转会话(S4 TaskDrawer「关联会话」入口):AI 无路由,跳转 = 面板展开 + 切会话。 /** 跨视图跳转会话(S4 TaskDrawer「关联会话」入口):AI 无路由,跳转 = 面板展开 + 切会话。
* 主窗口面板未展开时先展开再切换;detached(分离窗口)下主窗口无面板,只切会话不动面板。 */ * 主窗口面板未展开时先展开再切换;detached(分离窗口)下主窗口无面板,emit 跨窗事件由
async function openConversation(id: string) { * 分离窗切会话并聚焦(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 写) // 已展开不重复写 persistUiState(省一次 debounced SQLite 写)
if (!state.detached && !state.panelOpen) { if (!state.panelOpen) {
state.panelOpen = true state.panelOpen = true
persistUiState() 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() { export function useAiConversations() {
// 跨窗切会话监听注册(幂等;label 非分离窗不实际挂 listener,主窗口为事件发起方)
registerDetachedOpenConvListener(handleDetachedOpenConversation)
return { return {
loadConversations, loadConversations,
scheduleConversationsRefresh, scheduleConversationsRefresh,
+85 -5
View File
@@ -12,6 +12,7 @@
//! useAiEvents() 返回 shape 保持不变(stores/ai.ts spread 形状不变 → 组件零改动)。 //! useAiEvents() 返回 shape 保持不变(stores/ai.ts spread 形状不变 → 组件零改动)。
import { listen, emit } from '@tauri-apps/api/event' import { listen, emit } from '@tauri-apps/api/event'
import { t } from '@/i18n/i18n-helpers'
import { aiApi } from '@/api' import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings' import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
@@ -19,9 +20,11 @@ import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState
import { resetStreamWatchdog, clearAllStreamWatchdogs, clearResumeAttempts } from './useAiStream' import { resetStreamWatchdog, clearAllStreamWatchdogs, clearResumeAttempts } from './useAiStream'
import { setStreaming } from './streamingGuard' import { setStreaming } from './streamingGuard'
import { scheduleConversationsRefresh } from './useAiConversations' import { scheduleConversationsRefresh } from './useAiConversations'
import { pendingTaskParked, pendingTaskProgress } from './useAiPendingState'
import { clearConvBoundaryCache } from './useTaskBoundary'
import { handleStreamingEvent } from './useAiStreamingEvents' import { handleStreamingEvent } from './useAiStreamingEvents'
import { handleToolEvent } from './useAiToolEvents' import { handleToolEvent } from './useAiToolEvents'
import { handleLifecycleEvent } from './useAiLifecycleEvents' import { handleLifecycleEvent, recordTaskParked } from './useAiLifecycleEvents'
import type { AiChatEvent, MessageId } from '@/api/types' import type { AiChatEvent, MessageId } from '@/api/types'
// 跨域共享 helper re-export(原为 useAiEvents 导出,拆分后从 aiShared/叶子透传,消费方 import 路径不变) // 跨域共享 helper re-export(原为 useAiEvents 导出,拆分后从 aiShared/叶子透传,消费方 import 路径不变)
@@ -40,9 +43,12 @@ let _startPromise: Promise<void> | null = null
const appSettings = useAppSettingsStore() const appSettings = useAppSettingsStore()
// 看门狗不重置的事件集合(审批等待/完成/错误/达 max/路径授权/求助由各自 case 内 clear)。 // 看门狗不重置的事件集合(审批等待/完成/错误/达 max/路径授权/求助/停靠由各自 case 内 clear)。
// 模块级 Set 复用,避免每事件(delta/token 高频)新建数组字面量做 includes。 // 模块级 Set 复用,避免每事件(delta/token 高频)新建数组字面量做 includes。
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired', 'AiHelpRequired']) const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired', 'AiHelpRequired', 'AiTaskParked'])
// 后台会话完成 toast 的摘要截长(轻提醒只够判断「做完了/值不值得切回去看」)
const AGENT_FINISHED_TOAST_SUMMARY_MAX = 40
/** 全局审批类事件:跨会话放行,不因非当前会话被过滤。审批挂起是等用户决策的跨会话资产, /** 全局审批类事件:跨会话放行,不因非当前会话被过滤。审批挂起是等用户决策的跨会话资产,
* 丢弃后用户在切回该会话前没有任何感知与处理入口;归口仍按 conversation_id 写 per-conv * 丢弃后用户在切回该会话前没有任何感知与处理入口;归口仍按 conversation_id 写 per-conv
@@ -73,6 +79,8 @@ function handleAgentFinishedEvent(event: AiChatEvent): boolean {
status: AgentFinishedStatus status: AgentFinishedStatus
rounds: number rounds: number
tokens: number tokens: number
rejected_tools?: string[] | null
stop_kind?: string | null
} }
const convId = event.conversation_id const convId = event.conversation_id
if (!convId || !ev.agent_id) return true if (!convId || !ev.agent_id) return true
@@ -82,13 +90,73 @@ function handleAgentFinishedEvent(event: AiChatEvent): boolean {
status: ev.status, status: ev.status,
rounds: ev.rounds, rounds: ev.rounds,
tokens: ev.tokens, 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 内等待):此前该事件穿透到 // 子代理执行是该会话的真实活跃信号(父 loop 在 spawn_agent 内等待):此前该事件穿透到
// 活跃事件分支会重置看门狗,现提前归集后显式补上,保持既有不误判停滞的语义。 // 活跃事件分支会重置看门狗,现提前归集后显式补上,保持既有不误判停滞的语义。
resetStreamWatchdog(convId) resetStreamWatchdog(convId)
return true 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)。 /** 跨端用户消息域:miniapp→桌面 user 气泡同步(仅处理 AiUserMessage)。
* 去重:末条已是同 content 的 user 则跳过(防本地乐观 push + 事件回灌双气泡)。 */ * 去重:末条已是同 content 的 user 则跳过(防本地乐观 push + 事件回灌双气泡)。 */
function handleUserMessageEvent(event: AiChatEvent): boolean { function handleUserMessageEvent(event: AiChatEvent): boolean {
@@ -128,6 +196,10 @@ export function handleEvent(event: AiChatEvent) {
if (handleUserMessageEvent(event)) return if (handleUserMessageEvent(event)) return
// S1 子代理完成卡归集:在 isCurrent 守卫之前处理(per-conv 落卡,后台会话不丢卡) // S1 子代理完成卡归集:在 isCurrent 守卫之前处理(per-conv 落卡,后台会话不丢卡)
if (handleAgentFinishedEvent(event)) return if (handleAgentFinishedEvent(event)) return
// 无人值守停靠卡归集:在 isCurrent 守卫之前处理(per-conv 落卡,后台会话不丢卡)
if (handleTaskParkedEvent(event)) return
// 无人值守进度快照归集:在 isCurrent 守卫之前处理(per-conv 落 Map,后台会话不丢进度)
if (handleTaskProgressEvent(event)) return
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏; // 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏;
// 全局审批类事件例外(isGlobalApprovalEvent),放行到下方按域分派 // 全局审批类事件例外(isGlobalApprovalEvent),放行到下方按域分派
const isCurrent = !convId || convId === state.activeConversationId const isCurrent = !convId || convId === state.activeConversationId
@@ -145,6 +217,8 @@ export function handleEvent(event: AiChatEvent) {
clearLastDelta(convId || '') clearLastDelta(convId || '')
discardConvPendingApprovals(convId || '') discardConvPendingApprovals(convId || '')
clearResumeAttempts(convId || undefined) clearResumeAttempts(convId || undefined)
// 终态即清进度快照(事件即逝态,会话已终止不留陈旧「进行中」)
pendingTaskProgress.delete(convId || '')
// FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。 // FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。
scheduleConversationsRefresh() scheduleConversationsRefresh()
// 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列) // 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列)
@@ -168,8 +242,10 @@ export function handleEvent(event: AiChatEvent) {
// 无目标会话(事件缺 convId 且当前无活跃会话):流式 delta 无处累积,跳过写防 null 键无意义写入 // 无目标会话(事件缺 convId 且当前无活跃会话):流式 delta 无处累积,跳过写防 null 键无意义写入
if (!convId && !state.activeConversationId && event.type === 'AiTextDelta') return if (!convId && !state.activeConversationId && event.type === 'AiTextDelta') return
// 活跃事件(delta/工具/新轮/审批结果)到达即标记该会话生成中(enum 兜底写入口; // 活跃事件(delta/工具/新轮/审批结果)到达即标记该会话生成中(enum 兜底写入口;
// 后端 AiConvStateChanged{generating} 已先到时同值 set 幂等覆盖,无副作用) // 后端 AiConvStateChanged{generating} 已先到时同值 set 幂等覆盖,无副作用)。
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError' && event.type !== 'AiHelpRequired') { // 终态家族(AiCompleted/AiError/AiHelpRequired/AiTaskParked)除外:标记生成中会与
// 收尾的删项收敛打架(停靠后端已复位 generating,不能被兜底写回)
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError' && event.type !== 'AiHelpRequired' && event.type !== 'AiTaskParked') {
setConvState(convId, 'generating') setConvState(convId, 'generating')
} }
// 流式看门狗:活跃事件重置;审批等待/完成/错误在 case 内 clear(per-conv,各会话独立计时) // 流式看门狗:活跃事件重置;审批等待/完成/错误在 case 内 clear(per-conv,各会话独立计时)
@@ -206,6 +282,10 @@ export async function startListener() {
// S1:会话删除连带清其完成卡(Map 不持已删会话条目;存在即清,不限当前视图) // S1:会话删除连带清其完成卡(Map 不持已删会话条目;存在即清,不限当前视图)
if (deleted) { if (deleted) {
clearConvAgentFinished(deleted) clearConvAgentFinished(deleted)
// 无人值守三件随会话同亡:停靠卡挂起 + 进度快照 + 边界镜像(边界生命周期=会话存续,设计 §二)
pendingTaskParked.delete(deleted)
pendingTaskProgress.delete(deleted)
clearConvBoundaryCache(deleted)
} }
}) })
_unlistenApprovalClear = await listen('ai-approval-clear-timers', () => { clearAllApprovalTimers() }) _unlistenApprovalClear = await listen('ai-approval-clear-timers', () => { clearAllApprovalTimers() })
+42 -5
View File
@@ -1,6 +1,6 @@
//! 生命周期事件域 — handleLifecycleEvent + cleanupTerminatedConversation //! 生命周期事件域 — handleLifecycleEvent + cleanupTerminatedConversation
//! //!
//! 处理:AiCompleted / AiError / AiHelpRequired + 上下文生命周期事件(AiCompressing/ //! 处理:AiCompleted / AiError / AiHelpRequired / AiTaskParked + 上下文生命周期事件(AiCompressing/
//! AiManualCompressed/AiAutoCompressed/AiContextCleared)。 //! AiManualCompressed/AiAutoCompressed/AiContextCleared)。
//! 依赖方向:本模块 → aiShared / useAiPendingState(叶子)/ useAiStream / streamingGuard / //! 依赖方向:本模块 → aiShared / useAiPendingState(叶子)/ useAiStream / streamingGuard /
//! useAiConversations,不导入任何同级子 composable。 //! useAiConversations,不导入任何同级子 composable。
@@ -10,7 +10,7 @@ import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
import { nextMsgId, convStates, clearApprovalTimer, flushCurrentText, clearAllToolSlowTimers, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, notifyQueueCleared } from './aiShared' 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 { clearStreamWatchdog, clearResumeAttempts } from './useAiStream'
import { setStreaming } from './streamingGuard' import { setStreaming } from './streamingGuard'
import type { AiChatEvent, AiMessage, MessageId } from '@/api/types' 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 * - AiCompleted: 调用后追加 incomplete 气泡 / token 用量 / 队列 drain
* - AiError: 调用后追加错误气泡 + 清 queue * - AiError: 调用后追加错误气泡 + 清 queue
* - AiHelpRequired: 调用后翻 pendingHelp 驱动求助卡 * - 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) clearStreamWatchdog(convId || undefined)
// 正常收尾路径清该会话的 delta 去重记录:flushCurrentText 只清活跃会话的,这里按 convId // 正常收尾路径清该会话的 delta 去重记录:flushCurrentText 只清活跃会话的,这里按 convId
// 显式清一遍兜住 convId 与 active 不一致或事件缺 convId 的时序,防残留记录误丢下轮同内容 delta // 显式清一遍兜住 convId 与 active 不一致或事件缺 convId 的时序,防残留记录误丢下轮同内容 delta
@@ -83,8 +85,16 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | '
pendingMaxRounds.value = null pendingMaxRounds.value = null
} }
if (pendingHelp.value && pendingHelp.value.conversationId === convId) { 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(终止只清本会话弹窗,不连累并发会话;无归属旧项保守保留) // 路径授权挂起仅清本 conv(终止只清本会话弹窗,不连累并发会话;无归属旧项保守保留)
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId !== convId) pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId !== convId)
// 审批收尾仅清目标 conv 的待审批项 + 对称清其超时计时器(其他会话的审批卡/计时器不受影响; // 审批收尾仅清目标 conv 的待审批项 + 对称清其超时计时器(其他会话的审批卡/计时器不受影响;
@@ -104,6 +114,21 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | '
localStorage.removeItem('df-ai-text') 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=已处理。 */ /** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError)。返回 true=已处理。 */
export function handleLifecycleEvent(event: AiChatEvent): boolean { export function handleLifecycleEvent(event: AiChatEvent): boolean {
switch (event.type) { switch (event.type) {
@@ -215,6 +240,18 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean {
return true 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': case 'AiCompressing':
_contextCallbacks.onCompressing?.() _contextCallbacks.onCompressing?.()
return true return true
+27 -1
View File
@@ -5,7 +5,7 @@
//! useAiEvents 拆分后,streaming/tool/lifecycle 三域与组件(ChatInput/MaxRoundsCard/ //! useAiEvents 拆分后,streaming/tool/lifecycle 三域与组件(ChatInput/MaxRoundsCard/
//! DirAuthDialog/HelpRequiredCard)统一从这里取这些跨域信号。 //! DirAuthDialog/HelpRequiredCard)统一从这里取这些跨域信号。
import { ref } from 'vue' import { reactive, ref } from 'vue'
import { useAppSettingsStore } from '@/stores/appSettings' import { useAppSettingsStore } from '@/stores/appSettings'
const appSettings = useAppSettingsStore() const appSettings = useAppSettingsStore()
@@ -75,3 +75,29 @@ export interface PendingHelp {
conversationId: string | null conversationId: string | null
} }
export const pendingHelp = ref<PendingHelp | null>(null) export const pendingHelp = ref<PendingHelp | null>(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<string, PendingTaskParked>())
// 无人值守进度快照(后端 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<string, PendingTaskProgress>())
+61
View File
@@ -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<string, TaskBoundary>())
/** 拉取失败会话集合(镜像 miss 的两种语义须区分:确认无边界 vs 拉取失败。
* 失败态配置卡禁「一次授权」,防默认空表单静默覆盖库中原授权) */
export const convBoundaryFetchFailed = reactive(new Set<string>())
/** 某会话边界最近一次拉取是否失败(失败态镜像内容不可信) */
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<void> {
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<void> {
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)
}
+79
View File
@@ -49,6 +49,8 @@ export default {
// conversation load/switch failure feedback (network jitter/IPC down → empty list/blank, was silent) // 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.', loadConvFail: 'Failed to load conversations. Please check your network or backend connection.',
switchConvFail: 'Failed to switch conversation. Please retry.', 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) // M30: rename/archive/pin IPC failure feedback (was no try/catch, exceptions leaked to unhandledrejection)
renameConvFail: 'Failed to rename. Please retry.', renameConvFail: 'Failed to rename. Please retry.',
archiveConvFail: 'Failed to {action}. Please retry.', archiveConvFail: 'Failed to {action}. Please retry.',
@@ -87,11 +89,23 @@ export default {
title: 'Sub-agent', title: 'Sub-agent',
completed: 'completed', completed: 'completed',
truncated: 'truncated', truncated: 'truncated',
cancelled: 'cancelled',
failed: 'failed', 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', meta: '{rounds} rounds · {tokens} tokens',
// S3: finished card is clickable to open the read-only trace (hover hint) // S3: finished card is clickable to open the read-only trace (hover hint)
viewHint: 'Click to view execution trace', 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, // S3 sub-agent trace overlay (opened from the finished card, read-only simple render,
// discarded on close) // discarded on close)
agentTrace: { agentTrace: {
@@ -102,6 +116,71 @@ export default {
roleAssistant: 'Assistant', roleAssistant: 'Assistant',
roleTool: 'Tool result', roleTool: 'Tool result',
roleSystem: 'System', 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',
}, },
}, },
} }
+3
View File
@@ -110,6 +110,7 @@ export default {
// ── Skill ── // ── Skill ──
clearSkill: 'Clear skill', clearSkill: 'Clear skill',
skillBadgeTip: 'Injected full skill playbook: {name}',
placeholderWithSkill: 'Skill selected. Type arguments and press Enter', placeholderWithSkill: 'Skill selected. Type arguments and press Enter',
placeholderDefault: "Type a message... (/ for skills, {'@'} to mention)", placeholderDefault: "Type a message... (/ for skills, {'@'} to mention)",
skillNoMatch: 'No matching skill', skillNoMatch: 'No matching skill',
@@ -141,6 +142,8 @@ export default {
imageTooLarge: 'Image too large (max 10MB), skipped', imageTooLarge: 'Image too large (max 10MB), skipped',
imageLimit: 'Up to {n} images, extras ignored', imageLimit: 'Up to {n} images, extras ignored',
imageReadFailed: 'Failed to read image', 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 ── // ── Confirm dialog ──
confirmDeleteConversation: 'Delete chat "{title}"? This cannot be undone.', confirmDeleteConversation: 'Delete chat "{title}"? This cannot be undone.',
+76
View File
@@ -49,6 +49,8 @@ export default {
// 对话加载/切换失败反馈(网络抖动/IPC 断开 → 列表空/切换空白,原静默无反馈) // 对话加载/切换失败反馈(网络抖动/IPC 断开 → 列表空/切换空白,原静默无反馈)
loadConvFail: '对话列表加载失败,请检查网络或后端连接', loadConvFail: '对话列表加载失败,请检查网络或后端连接',
switchConvFail: '对话切换失败,请重试', switchConvFail: '对话切换失败,请重试',
// UX走查 P3-7/P1-1: 关联会话跳转命中已删会话(原静默新建空会话/分离窗死点击)
convDeleted: '会话已删除',
// M30:重命名/归档/置顶 IPC 失败反馈(原无 try/catch 异常进 unhandledrejection,用户无感) // M30:重命名/归档/置顶 IPC 失败反馈(原无 try/catch 异常进 unhandledrejection,用户无感)
renameConvFail: '重命名失败,请重试', renameConvFail: '重命名失败,请重试',
archiveConvFail: '{action}失败,请重试', archiveConvFail: '{action}失败,请重试',
@@ -86,11 +88,23 @@ export default {
title: '子代理', title: '子代理',
completed: '已完成', completed: '已完成',
truncated: '已截断', truncated: '已截断',
cancelled: '已取消',
failed: '失败', failed: '失败',
// stop_kind 结构化终止原因细分(根治 summary 启发式;截断三态各自透出)
truncMaxRounds: '已达轮次上限',
truncWallClock: '超时截断',
truncTokenBudget: 'token 预算截断',
noReport: '未产出报告',
// 被拒工具 chip({tools} 为「工具名×次数」枚举,多枚前 2 枚 + N)
rejected: '被拒 {tools}',
// 启动失败型子代理未落库无轨迹,卡禁点(hover 说明替代 viewHint)
noTrace: '无轨迹(启动失败)',
meta: '{rounds} 轮 · {tokens} tokens', meta: '{rounds} 轮 · {tokens} tokens',
// S3:完成卡可点击查看只读轨迹(hover 提示) // S3:完成卡可点击查看只读轨迹(hover 提示)
viewHint: '点击查看执行轨迹', viewHint: '点击查看执行轨迹',
}, },
// P3-6 后台会话子代理完成轻提醒({status} 复用上方 agentFinished 状态词,{summary} 截前 40 字)
agentFinishedToast: '子代理{status}: {summary}',
// S3 子代理轨迹浮层(完成卡点击展开,只读简版渲染,关即弃) // S3 子代理轨迹浮层(完成卡点击展开,只读简版渲染,关即弃)
agentTrace: { agentTrace: {
title: '子代理执行轨迹', title: '子代理执行轨迹',
@@ -100,6 +114,68 @@ export default {
roleAssistant: '助手', roleAssistant: '助手',
roleTool: '工具结果', roleTool: '工具结果',
roleSystem: '系统', 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: '预算不限',
}, },
}, },
} }
+3
View File
@@ -110,6 +110,7 @@ export default {
// ── 技能 ── // ── 技能 ──
clearSkill: '取消技能', clearSkill: '取消技能',
skillBadgeTip: '已注入技能「{name}」的完整规程',
placeholderWithSkill: '已选技能,输入参数后回车', placeholderWithSkill: '已选技能,输入参数后回车',
placeholderDefault: "输入消息...(/ 触发技能,{'@'} 引用实体)", placeholderDefault: "输入消息...(/ 触发技能,{'@'} 引用实体)",
skillNoMatch: '无匹配技能', skillNoMatch: '无匹配技能',
@@ -142,6 +143,8 @@ export default {
imageTooLarge: '图片过大(上限 10MB),已跳过', imageTooLarge: '图片过大(上限 10MB),已跳过',
imageLimit: '最多 {n} 张图片,已忽略多余的', imageLimit: '最多 {n} 张图片,已忽略多余的',
imageReadFailed: '图片读取失败', imageReadFailed: '图片读取失败',
imageIgnoredWarn: '当前模型不支持图片,发送时图片将被忽略',
imageOnlyBlocked: '当前模型不支持图片,请改用视觉模型或补充文字说明',
// ── 确认弹层 ── // ── 确认弹层 ──
confirmDeleteConversation: '确定删除对话「{title}」?该操作不可恢复。', confirmDeleteConversation: '确定删除对话「{title}」?该操作不可恢复。',