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