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