重构: 后端编排拆解与DRY债收敛及命名纠偏

阶段1拆解: stream 系外移 stream_relay.rs,mod.rs 3048→2607,loop_body 502→83
技能收尾: regenerate/edit 回溯补技能,新增 read_skill 工具,项目级技能扫描
DRY: emit 双写 46 处收敛 event_bus 正典,KV 引号归一收口,LOCK-SLOW 33 处单行化
警示骨架 4→1 与压缩兜底 2→1,噪音目录档位收敛,truncate 同名陷阱消除
工具元数据落 ToolMeta 八维,7 张 match 表改查 registry,parity 测试锁等价
命名: is_noise_entry/teardown_round/probe_missed_terminal_event 等 6 项纠偏
This commit is contained in:
lxy
2026-10-01 19:35:19 +08:00
parent 3861e13b32
commit 75c5e23523
40 changed files with 2988 additions and 1964 deletions
@@ -10,12 +10,13 @@
use std::time::{Duration, SystemTime};
use tauri::{AppHandle, Emitter};
use tauri::AppHandle;
use crate::state::AppState;
use super::super::{
audit::audit_finalize, conversation::save_conversation, AiChatEvent,
audit::audit_finalize, conversation::save_conversation, event_bus::emit_ai_chat_event,
AiChatEvent,
};
/// 清理指定会话中超时的待审批。
@@ -81,14 +82,15 @@ pub async fn cleanup_expired_approvals(app: &AppHandle, state: &AppState, conv_i
.replace_tool_result_content(&tc_id, &format!("审批超时({}分钟)已自动取消,请改换其他方案", timeout_minutes));
}
// 3. emit AiApprovalResult(让前端清除 pending UI)
let ev = AiChatEvent::AiApprovalResult {
id: tc_id.clone(),
approved: false,
conversation_id: Some(conv_id.to_string()),
};
let _ = app.emit("ai-chat-event", ev.clone());
let _ = state.ai_event_bus.publish_event(ev);
// 3. emit AiApprovalResult(让前端清除 pending UI,emit+publish 双写经正典 helper)
emit_ai_chat_event(
app,
AiChatEvent::AiApprovalResult {
id: tc_id.clone(),
approved: false,
conversation_id: Some(conv_id.to_string()),
},
);
// 4. audit_finalize 记入审计表
audit_finalize(
@@ -32,17 +32,18 @@
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager};
use tauri::AppHandle;
use tokio::sync::Mutex;
use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary};
use df_ai::provider::ChatMessage;
use df_storage::models::AiProviderRecord;
use crate::state::{AppState, LlmConcurrency};
use crate::state::LlmConcurrency;
// compress_via_llm 位于 commands::ai::compress(mod.rs 的 super::compress)。
use super::super::compress::compress_via_llm;
use super::super::event_bus::emit_ai_chat_event;
use super::{AiChatEvent, AiSession};
/// 压缩保护区条数(对齐 clear/compress IPC 的 PROTECT_COUNT=6)。
@@ -109,13 +110,10 @@ pub(super) async fn maybe_auto_compress(
};
if should_compress {
// emit 压缩开始 + 置位防重入 + 读 active 克隆(不改 status)。
let ev = AiChatEvent::AiCompressing {
// emit 压缩开始(emit+publish 双写经 emit_ai_chat_event)+ 置位防重入 + 读 active 克隆(不改 status)。
emit_ai_chat_event(app_handle, AiChatEvent::AiCompressing {
conversation_id: Some(conv_id.to_string()),
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber透传 miniapp
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
});
let (mut active_msgs, lang) = {
let mut session = session_arc.lock().await;
let conv = session.conv(&conv_id);
@@ -220,30 +218,15 @@ pub(super) async fn maybe_auto_compress(
session.conv(&conv_id).messages.set_compressing(false);
}
}
if KEYWORD_FALLBACK_ENABLED {
// 同 LLM 失败兜底:标 compressed 释放 token + 关键词摘要塞首位(若非空)。
let inserted = {
let mut session = session_arc.lock().await;
if !session.per_conv.contains_key(&conv_id) {
return;
}
let conv = session.conv(&conv_id);
let _compressed = conv.messages.compress_old_messages(protect_start);
if !keyword_fallback.is_empty() {
conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback));
true
} else {
false
}
};
if inserted {
tracing::info!(
conv_id = %conv_id,
pre_tokens = pre_compress_tokens,
"[ai] 压缩 provider 重建失败兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)"
);
}
}
keyword_fallback_commit(
&session_arc,
&conv_id,
protect_start,
&keyword_fallback,
pre_compress_tokens,
"压缩 provider 重建失败",
)
.await;
return;
}
};
@@ -286,13 +269,10 @@ pub(super) async fn maybe_auto_compress(
// 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体),
// 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。
// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
let ev = AiChatEvent::AiAutoCompressed {
emit_ai_chat_event(&app_handle, AiChatEvent::AiAutoCompressed {
conversation_id: Some(conv_id.clone()),
summary: summary.nl_summary,
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber透传 miniapp
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
});
}
Ok(None) => {
// 保护区外无 active 可压缩(已全 compressed/archived)→ noop,仅复位 is_compressing。
@@ -329,30 +309,15 @@ pub(super) async fn maybe_auto_compress(
"[ai] 自动压缩失败(后台),降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
KEYWORD_FALLBACK_ENABLED,
);
if KEYWORD_FALLBACK_ENABLED {
// 标 compressed 释放 token + 关键词摘要塞首位(若非空)。
let inserted = {
let mut session = session_arc.lock().await;
if !session.per_conv.contains_key(&conv_id) {
return;
}
let conv = session.conv(&conv_id);
let _compressed = conv.messages.compress_old_messages(protect_start);
if !keyword_fallback.is_empty() {
conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback));
true
} else {
false
}
};
if inserted {
tracing::info!(
conv_id = %conv_id,
pre_tokens = pre_compress_tokens,
"[ai] 压缩失败兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)"
);
}
}
keyword_fallback_commit(
&session_arc,
&conv_id,
protect_start,
&keyword_fallback,
pre_compress_tokens,
"压缩失败",
)
.await;
}
}
});
@@ -360,3 +325,47 @@ pub(super) async fn maybe_auto_compress(
}
false
}
/// 关键词兜底提交单点(DRY #8 收敛,2026-10-01)。
///
/// 抽自 [`maybe_auto_compress`] 后台 task 内的逐字双胞胎块:压缩 provider 重建失败路径
/// 与压缩 LLM 失败路径共用「KEYWORD_FALLBACK_ENABLED 门控 → 锁内 check+mutate
/// (标 compressed 释放 token + 关键词摘要插首位,非空才插)→ 成功插入打 info」。
/// 两条失败路径只差日志正文,经 `log_head` 参数化(拼为
/// "[ai] {log_head}兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)")。
///
/// conv 已删(per_conv 无条目)→ 直接返回不兜底(对齐原 return 语义:防惰性重建污染新 conv)。
async fn keyword_fallback_commit(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
protect_start: usize,
keyword_fallback: &str,
pre_tokens: u32,
log_head: &str,
) {
if !KEYWORD_FALLBACK_ENABLED {
return;
}
let inserted = {
let mut session = session_arc.lock().await;
if !session.per_conv.contains_key(conv_id) {
return;
}
let conv = session.conv(conv_id);
let _compressed = conv.messages.compress_old_messages(protect_start);
if keyword_fallback.is_empty() {
false
} else {
conv.messages.insert_at(0, ChatMessage::system(keyword_fallback));
true
}
};
if inserted {
tracing::info!(
conv_id = %conv_id,
pre_tokens,
"[ai] {}兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)",
log_head
);
}
}
+7 -11
View File
@@ -11,8 +11,9 @@
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager};
use tauri::{AppHandle, Manager};
use crate::commands::ai::event_bus::emit_ai_chat_event;
use crate::commands::ai::AiChatEvent;
use super::conv_state::ConvState;
@@ -26,7 +27,7 @@ use super::conv_state::ConvState;
/// epoch 语义:`loop_epoch` 是 per_conv 的并发 owner token(每 spawn 新 loop 前 fetch_add(1));
/// `my_epoch` 是本 guard 对应 loop 捕获的 epoch。复位只认 owner(epoch 匹配),
/// 旧 loop 复位跳过(防 clobber 新 loop 状态)。
pub(super) struct GeneratingGuard {
pub(crate) struct GeneratingGuard {
conv_id: String,
done: bool,
/// per_conv.loop_epoch 引用(锁无关原子读,owner 判定用)。
@@ -37,7 +38,7 @@ pub(super) struct GeneratingGuard {
}
impl GeneratingGuard {
pub(super) fn new(
pub(crate) fn new(
conv_id: String,
loop_epoch: Arc<AtomicU64>,
my_epoch: u64,
@@ -58,7 +59,7 @@ impl GeneratingGuard {
///
/// 只认 owner:本 loop epoch 匹配当前 loop_epoch 才迁移 Idle;
/// 不匹配(旧 loop)则仅置 done(跳过迁移,防 clobber 新 loop 的 Generating)。
pub(super) async fn reset(&mut self) {
pub(crate) async fn reset(&mut self) {
if self.done {
return;
}
@@ -76,7 +77,7 @@ impl GeneratingGuard {
}
/// 解除 Drop 兜底(审批等待保持 Generating)。
pub(super) fn disarm(&mut self) {
pub(crate) fn disarm(&mut self) {
self.done = true;
}
@@ -102,12 +103,7 @@ impl GeneratingGuard {
}
};
if let Some(ev) = ev {
let _ = self.app_handle.emit("ai-chat-event", ev.clone());
let _ = self
.app_handle
.state::<crate::state::AppState>()
.ai_event_bus
.publish_event(ev);
emit_ai_chat_event(&self.app_handle, ev);
}
}
}
+69 -122
View File
@@ -1,14 +1,44 @@
//! 审批后循环恢复 — 从 agentic/mod.rs 拆分。
//! 原代码被嵌套在 run_agentic_loop 内部导致 pub(crate) 不可见。
use tauri::{AppHandle, Emitter, Manager};
use std::sync::Arc;
use tauri::AppHandle;
use tokio::sync::Mutex;
use df_ai::provider::ChatMessage;
use crate::state::AppState;
use super::approval_timeout;
use crate::commands::ai::event_bus::emit_ai_chat_event;
use crate::commands::ai::prompt::{build_system_prompt_parts, cache_enabled, get_active_provider};
use crate::commands::ai::knowledge_inject::{build_knowledge_context_for_conv, inject_knowledge_into_prompt};
use crate::commands::ai::{AiChatEvent, ErrorType, SessionState, GoalEntry};
use crate::commands::ai::{AiChatEvent, AiSession, ErrorType, SessionState, GoalEntry};
use super::conv_state::ConvState;
/// 警示 system 消息插入单点(DRY #7 收敛,2026-10-01)。
///
/// 抽自 4 处同构骨架(insert_destructive_loop_warning / insert_stall_warning /
/// insert_tool_call_over_limit_warning / audit::cache insert_listing_bypass_warning):
/// 「lock → per_conv.contains_key 守卫 → messages.insert_at(0, system) → tracing::info」。
/// conv 已删(per_conv 无条目)→ 跳过插入与日志(对齐原守卫语义)。
///
/// `log_tag` 为日志正文(不带 "[ai] " 前缀,helper 统一补);各站点特有字段
/// (iteration/loop_count 等)由调用方编入 tag 文本保留定位信息,helper 统一打 conv_id 字段。
pub(crate) async fn insert_system_notice(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
text: &str,
log_tag: &str,
) {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
conv.messages.insert_at(0, ChatMessage::system(text));
tracing::info!(conv_id = %conv_id, "[ai] {}", log_tag);
}
}
/// try_continue_agent_loop 续跑判定所需 session 字段的一次性快照。
struct ContinueSnapshot {
is_generating: bool,
@@ -59,17 +89,15 @@ pub async fn try_continue_agent_loop(
Some(cid) => cid,
None => conv_id.to_string(),
};
let ev = AiChatEvent::AiCompleted {
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
emit_ai_chat_event(app, AiChatEvent::AiCompleted {
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0,
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false,
incomplete: None,
conversation_id: Some(emit_conv_id),
pinned_goals: snap.pinned_goals_snapshot.clone(),
};
let _ = app.emit("ai-chat-event", ev.clone());
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
});
}
return;
}
@@ -81,12 +109,11 @@ pub async fn try_continue_agent_loop(
if let Err(e2) = state.conv_states.transition(conv_id, ConvState::Idle) {
tracing::warn!(conv_id = %conv_id, error = %e2, "conv_states→Idle 非法");
}
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
error: e.clone(), error_type: Some(ErrorType::ProviderConfig),
conversation_id: Some(conv_id.to_string()),
});
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(AiChatEvent::AiError {
error: e, error_type: None,
// emit/publish 原两端 error_type 不一致(emit=ProviderConfig / publish=None,
// 手抄漂移),收敛 emit_ai_chat_event 后统一 ProviderConfig(对齐 emit_fatal_error 先例)。
emit_ai_chat_event(app, AiChatEvent::AiError {
error: e,
error_type: Some(ErrorType::ProviderConfig),
conversation_id: Some(conv_id.to_string()),
});
return;
@@ -133,24 +160,19 @@ pub async fn try_continue_agent_loop(
// B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争,无需 session lock)。
let still_generating = state.conv_states.is_active(conv_id);
if !still_generating {
let ev = AiChatEvent::AiCompleted {
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
emit_ai_chat_event(app, AiChatEvent::AiCompleted {
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0,
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false,
incomplete: None,
conversation_id: Some(conv_id_owned.clone()),
pinned_goals: snap.pinned_goals_snapshot.clone(),
};
let _ = app.emit("ai-chat-event", ev.clone());
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
});
return;
}
let _ = app.emit("ai-chat-event", AiChatEvent::AiAgentRound {
round: 0, conversation_id: Some(conv_id_owned.clone()),
});
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(AiChatEvent::AiAgentRound {
emit_ai_chat_event(app, AiChatEvent::AiAgentRound {
round: 0, conversation_id: Some(conv_id_owned.clone()),
});
@@ -168,63 +190,19 @@ pub async fn try_continue_agent_loop(
/// 从 LLM 本轮调用的工具中推理目标描述(本轮可能多个,全部提取)。
/// 工具调用是结构化数据(工具名+参数),比从文本中提取更可靠。
///
/// DRY 债 #5 收敛:动词/参数键/取值风格/缺参兜底不再本文件手写 match,
/// 改查注册元数据(goal 维度,在 declare_tool! 注册点声明)。
/// 未声明 goal 的工具不产生目标描述(与旧 match 的 _ => None 分支一致)。
/// 旧表中的幽灵分支(edit_file,注册表无此工具)随 match 删除一并清理。
pub(crate) fn infer_goal_from_tool_calls(tool_calls: &std::collections::HashMap<u32, crate::commands::ai::ToolCallDraft>) -> Vec<String> {
if tool_calls.is_empty() { return vec![]; }
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
let mut goals: Vec<String> = Vec::new();
for tc in tool_calls.values() {
let goal = match tc.name.as_str() {
"patch_file" | "write_file" | "edit_file" | "append_file" => {
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
.and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| {
let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p);
format!("修改 {}", f)
}))
}
"read_file" | "file_info" => {
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
.and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| {
let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p);
format!("查看 {}", f)
}))
}
"search_files" | "grep" => {
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
.and_then(|args| args.get("pattern").or_else(|| args.get("query")).and_then(|v| v.as_str()).map(|q| {
let s = if q.len() > 25 { &q[..25] } else { q };
format!("搜索 {}", s)
}))
.or_else(|| Some("搜索文件".to_string()))
}
"run_command" => {
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
.and_then(|args| args.get("command").and_then(|v| v.as_str()).map(|c| {
let s = if c.len() > 25 { &c[..25] } else { c };
format!("执行 {}", s)
}))
.or_else(|| Some("执行命令".to_string()))
}
"create_project" => {
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
.and_then(|args| args.get("name").and_then(|v| v.as_str()).map(|n| format!("创建项目 {}", n)))
}
"create_idea" => {
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
.and_then(|args| args.get("title").and_then(|v| v.as_str()).map(|t| format!("捕获灵感 {}", t)))
}
"run_workflow" => Some("执行工作流".to_string()),
"delete_file" | "rename_file" => {
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
.and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| {
let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p);
format!("处理 {}", f)
}))
}
"list_directory" => {
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
.and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| format!("浏览 {}", p)))
}
_ => None,
};
let parsed = serde_json::from_str::<serde_json::Value>(&tc.args).ok();
let goal = df_ai::ai_tools::goal_of(&tc.name)
.and_then(|g| df_ai::ai_tools::goal_text(g, parsed.as_ref()));
if let Some(g) = goal { if !goals.contains(&g) { goals.push(g); } }
}
goals
@@ -259,56 +237,22 @@ fn arg_str(args: &serde_json::Value, key: &str) -> String {
/// 选「决定意图」参数(决定这次调用"去哪儿查什么"的参数),非决定参数(如 case_sensitive/
/// show_line_numbers/timeout 等开关/格式选项)忽略——它们变体不构成漂移。
///
/// 归一规则(参数从 args JSON 取,缺失用空串):
/// - grep/search_files/search: `pattern`(或 `query`)+ `path`(或 `glob`)
/// 归一规则(DRY 债 #5 收敛:参数组查注册元数据 sig 维度,在 declare_tool! 注册点声明):
/// - grep/search_files: `pattern`(或 `query` 回退)+ `path`(或 `glob` 回退)
/// — 同 path 换 pattern 是正常换词;同 pattern 同 path 才算重复。
/// - read_file: `path` + `offset` + `limit`
/// - read_file: `path` + `offset` + `limit`(后两为数值键,按 JSON 形态序列化)
/// — **同段反复才算重复**;不同 offset = 正常分段读大文件(不算)。
/// - run_command: `command`(整条命令,含参数)。
/// - list_dir/list_directory: `path`。
/// - http_request: `url` + `method`。
/// - fetch_url: `url`。
/// - 其他/兜底: `name` + args 全 JSON 序列化(无明确语义时保守全量,避免漏判)。
/// - list_directory: `path`。
/// - http_request: `url` + `method`;fetch_url: `url`。
/// - 其他/未声明: `name` + args 全 JSON 序列化(无明确语义时保守全量,避免漏判)。
///
/// 返回 `"name:k1=v1,k2=v2"` 形式。输入 args 通常来自 LLM 的 tool_call function.arguments
/// (JSON 字符串),调用方先 from_str 成 Value 再传入。
/// 旧表中的幽灵分支(search/list_dir,注册表无此工具)随 match 删除一并清理。
pub(crate) fn tool_call_signature(name: &str, args: &serde_json::Value) -> String {
let pair = |k: &str, v: &str| format!("{}={}", k, v);
let sig = match name {
"grep" | "search_files" | "search" => {
let q = if args.get("pattern").and_then(|v| v.as_str()).is_some() {
arg_str(args, "pattern")
} else {
arg_str(args, "query")
};
let p = if args.get("path").and_then(|v| v.as_str()).is_some() {
arg_str(args, "path")
} else {
arg_str(args, "glob")
};
format!("{},{}", pair("pattern", &q), pair("path", &p))
}
"read_file" => {
// offset/limit 数值字段:as_str 不通,先取再转字符串(缺失→"")。
let offset = args.get("offset").map(|v| v.to_string()).unwrap_or_default();
let limit = args.get("limit").map(|v| v.to_string()).unwrap_or_default();
let path = arg_str(args, "path");
format!("{},{},{}", pair("path", &path), pair("offset", &offset), pair("limit", &limit))
}
"run_command" => pair("command", &arg_str(args, "command")),
"list_dir" | "list_directory" => pair("path", &arg_str(args, "path")),
"http_request" => {
let url = arg_str(args, "url");
let method = arg_str(args, "method");
format!("{},{}", pair("url", &url), pair("method", &method))
}
"fetch_url" => pair("url", &arg_str(args, "url")),
_ => {
// 兜底:工具名 + args 全 JSON 序列化(保守,无明确语义时全量比对)。
format!("{},{}", pair("name", name), pair("args", &args.to_string()))
}
};
format!("{}:{}", name, sig)
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
df_ai::ai_tools::tool_signature(name, args)
}
/// G2 重复检测纯函数:判定最近 N 个工具调用签名是否构成「卡住反复」。
@@ -479,9 +423,12 @@ mod tests {
}
#[test]
fn sig_list_dir_takes_path() {
fn sig_list_directory_takes_path() {
let args = serde_json::json!({"path": "/tmp"});
assert_eq!(tool_call_signature("list_dir", &args), "list_dir:path=/tmp");
assert_eq!(tool_call_signature("list_directory", &args), "list_directory:path=/tmp");
// 幽灵名 list_dir 已删分支,走未注册全参兜底
let ghost = tool_call_signature("list_dir", &args);
assert!(ghost.starts_with("list_dir:name=list_dir,args="), "list_dir 应走兜底分支: {}", ghost);
}
#[test]
@@ -531,7 +478,7 @@ mod tests {
tool_call_signature("read_file", &serde_json::json!({"path": "b.rs", "offset": 0, "limit": 50})),
tool_call_signature("read_file", &serde_json::json!({"path": "c.rs", "offset": 0, "limit": 50})),
tool_call_signature("read_file", &serde_json::json!({"path": "d.rs", "offset": 100, "limit": 50})),
tool_call_signature("list_dir", &serde_json::json!({"path": "src-tauri"})),
tool_call_signature("list_directory", &serde_json::json!({"path": "src-tauri"})),
tool_call_signature("grep", &serde_json::json!({"pattern": "STALL", "path": "src"})),
tool_call_signature("read_file", &serde_json::json!({"path": "e.rs", "offset": 0, "limit": 50})),
];
@@ -562,7 +509,7 @@ mod tests {
fn scenario_repeat_same_chunk_repetitive() {
let same = tool_call_signature("read_file", &serde_json::json!({"path": "a.rs", "offset": 0, "limit": 50}));
let other1 = tool_call_signature("grep", &serde_json::json!({"pattern": "x", "path": "y"}));
let other2 = tool_call_signature("list_dir", &serde_json::json!({"path": "z"}));
let other2 = tool_call_signature("list_directory", &serde_json::json!({"path": "z"}));
let sigs = vec![same.clone(), same.clone(), same.clone(), same, other1, other2];
assert!(is_repetitive_exploration(&sigs), "反复读同段应判重复");
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,666 @@
//! 单轮流式中继 — candidate 链 fallback + 流式调用 + 流前重试 + MidStream 保文。
//!
//! 自 agentic/mod.rs 巨石拆解外移(2026-10-01,纯搬运语义等价,职责分组):
//! - `StreamOutcome` / `stream_one_provider` / `stream_with_retry`:单 candidate 流式调用
//! + 流前 InitFailed retryable 重试循环;
//! - `RoundOutcome` / `stream_round`:单轮流式自包含块(retry_deadline → candidate 链
//! 构建/降级 → candidate 循环 → epoch 后校验 → MidStream 保文);
//! - `emit_retry_attempt`:退避日志 + AiStreamRetry emit + sleep;
//! - `merge_volatile_tail`:Anthropic prompt caching 易变尾段追加到请求消息流末尾。
//!
//! 行为红线:session_arc 锁的获取/释放位置、guard.reset/disarm 时序、emit+publish 双写
//! 顺序、per-provider permit 取放范围均与拆解前逐字一致,本模块不改任何行为。
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use tauri::AppHandle;
use tokio::sync::Mutex;
// 复用 retry::backoff_delay(jitter 1s→2s→4s) + is_status_retryable(Fatal 分类)
// 实现流前失败重试退避对齐,避免重写退避逻辑。
use df_ai::retry;
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
// 智能路由 helper + TaskRequirements(主对话:needs_tool_use=true,当前仅 Text 模态)。
use df_ai::router::TaskRequirements;
use df_storage::db::Database;
use df_storage::models::AiProviderRecord;
use crate::state::{LlmConcurrency, ProviderAcquire};
use crate::commands::ai::conversation::{save_conversation, TokenAccumulator};
use crate::commands::ai::stream_recv::{stream_llm, StreamResult};
use crate::commands::ai::{AiChatEvent, AiSession, GoalEntry, ToolCallDraft};
use super::guard::GeneratingGuard;
use super::PROVIDER_SATURATION_DEMOTE_THRESHOLD;
use super::{check_retry_exhausted, resolve_model_with_override};
use super::{emit_fatal_error, teardown_round, tokens_snapshot, usage_delta_since};
use super::super::event_bus::emit_ai_chat_event;
// ============================================================
// 单 Provider 流式结果 + fallback 辅助
// ============================================================
/// 单 candidate provider 一次迭代的流式调用结果。
///
/// 显式区分三态,驱动外层 `for candidate` fallback 循环:
/// - `Success`:`Complete` 或 `Partial`(MidStream 保文)。携带该 provider 路由出的
/// `resolved_model`(调用方据此更新迭代级 resolved_model,供 push/save)。
/// - `InitFailedExhausted`:流前 `InitFailed{retryable=true}` 在本 provider 上重试
/// 耗尽(或预算 30s 耗尽)。retryable=true 即「瞬态错误,换 provider 可能成功」→
/// 外层 `continue` 切下一 candidate(重建 build_provider + resolved_model 重算)。
/// 携带 `error`(最后一条诊断文本),供外层「全 candidate 耗尽」时 emit 最终 AiError。
/// - `Fatal`:流前 `InitFailed{retryable=false}`(4xx 非429/鉴权/参数错)。立即放弃
/// 整个 fallback(对齐 retry.rs Fatal 分类 + provider_pool 文档「不可重试错误
/// 立即放弃不浪费备用 provider」)。携带 `error`,stream_one_provider 内 Fatal 分支
/// emit AiError(终态不切候选,无残留气泡风险),调用方仅做 guard.reset + return。
/// 空 key(build_provider_for Err)同样归 Fatal 语义——key 缺失/钥匙串损坏非瞬态,
/// 换 provider 无意义(但实际场景:外层已对 primary 做了启动 Auth 早失败,故此分支
/// 多见于 secondary 配置不一致,保守 Fatal 收敛)。
enum StreamOutcome {
Success {
text: String,
tool_calls: std::collections::HashMap<u32, ToolCallDraft>,
usage: df_ai::provider::TokenUsage,
incomplete: bool,
resolved_model: String,
/// DeepSeek thinking 模式推理内容(需回传到下一轮请求)
reasoning_content: Option<String>,
},
/// error 字段供外层全 candidate 耗尽时 emit 最终 AiError(单气泡聚合)。
InitFailedExhausted { error: String },
/// error 字段供 stream_one_provider 内 Fatal 分支 emit AiError。
Fatal { error: String },
}
/// 单 provider 流式调用 + 重试。
///
/// 封装一个 candidate 的:resolve_secret → ensure_resolved_key → build_provider
/// → select_model_id(resolved_model 在该 candidate.model_configs 上重算)
/// → 流式 stream_recv + 流前 InitFailed retryable 重试循环。
///
/// 与主链共享的退避/分类:`retry::backoff_delay`(1s→2s→4s+jitter) +
/// `StreamResult::InitFailed{retryable}`(镜像 retry::is_status_retryable) +
/// 30s 总挂钟预算(`retry_deadline` 由调用方传入,本轮 fallback 各 candidate 共享一个预算)。
///
/// **permit 责任**:本函数不取并发 permit——permit 持有期间多 provider 限流不重叠,
/// 由调用方在进 candidate 前取 global/per_conv(整轮迭代共享) + 本 candidate 的
/// per-provider permit(切换 candidate 时释放旧取新)。
///
/// 返回 [`StreamOutcome`]:Fatal 含已 emit AiError;Success 含保文或正常完成;
/// InitFailedExhausted 让调用方切下一 candidate。
async fn stream_one_provider(
candidate: &AiProviderRecord,
messages: &[ChatMessage],
tool_defs: &[df_ai::provider::ToolDefinition],
app_handle: &AppHandle,
stop_flag: &std::sync::atomic::AtomicBool,
notify: &tokio::sync::Notify,
conv_id: &str,
max_retries: usize,
retry_deadline: tokio::time::Instant,
model_override: &Option<String>,
agentic_req: &TaskRequirements,
last_reasoning_content: &Option<String>,
) -> StreamOutcome {
// resolve→ensure→build 三步(复用 secret::build_provider_for,DRY)。
// key 缺失/损坏 → Err → 归 Fatal(stream_llm Fatal 也是 key 类错,语义一致)。
// 注:primary 候选的启动 Auth 早失败已在外层 run_agentic_loop 顶部处理(emit + return),
// 本函数到达时 primary 的 key 已验证过;此处 Err 多见于 secondary 配置不一致,
// 保守归 Fatal 立即放弃(不浪费预算试下一 provider,因 key 错非瞬态)。
let provider: Arc<dyn LlmProvider> = match crate::commands::ai::secret::build_provider_for(candidate) {
Ok(p) => Arc::from(p),
Err(msg) => return StreamOutcome::Fatal { error: msg },
};
// resolved_model 在本 candidate.model_configs 上重算(provider 切换后
// 模型池不同,必须重选;否则拿主 provider 的 model_id 去打次 provider 会吃 400/404)。
// model_override 穿透:override 非空且在本 candidate 池中 → 用 override;
// 否则用 resolved_model(绝不因 override 致无模型)。
let resolved_model = resolve_model_with_override(agentic_req, candidate, model_override);
// 流前 InitFailed 重试循环。
// 重试逻辑抽至 stream_with_retry helper(扁平重构),本函数仅负责 provider 准备 + model 决议 + 委派。
stream_with_retry(
&provider, candidate, messages, tool_defs, app_handle, stop_flag, notify, conv_id,
max_retries, retry_deadline, &resolved_model, last_reasoning_content,
).await
}
// ── stream_with_retry: 流式 + 流前 InitFailed retryable 重试循环(扁平抽自原嵌套 5 层 match) ──
// 返回 StreamOutcome。内部含 retry_attempt 循环 + match StreamResult 三分支:
// Complete/Partial → Success;InitFailed Fatal → Fatal;InitFailed Retryable → 重试/耗尽。
// 嵌套 ≤ 3 层:for 内 match 内单层 if(早返回避免嵌套加深)。
async fn stream_with_retry(
provider: &Arc<dyn LlmProvider>,
candidate: &AiProviderRecord,
messages: &[ChatMessage],
tool_defs: &[df_ai::provider::ToolDefinition],
app_handle: &AppHandle,
stop_flag: &std::sync::atomic::AtomicBool,
notify: &tokio::sync::Notify,
conv_id: &str,
max_retries: usize,
retry_deadline: tokio::time::Instant,
resolved_model: &str,
last_reasoning_content: &Option<String>,
) -> StreamOutcome {
// 供应商级采样温度覆盖:provider config JSON 带 "temperature" 字段则用之,
// 治受限模型(如 Kimi 系经 u-switch 透传)仅接受 temperature=1、其它值 400 的问题;
// 未配置/非法 JSON 走默认 0.7。candidate 在重试循环内不变,解析提到循环外只做一次。
let temperature = candidate
.config
.as_deref()
.and_then(|cfg| serde_json::from_str::<serde_json::Value>(cfg).ok())
.and_then(|v| v.get("temperature").and_then(|t| t.as_f64()))
.map(|t| t as f32)
.or(Some(0.7));
for retry_attempt in 0..=max_retries {
let retry_request = CompletionRequest {
model: resolved_model.to_string(),
messages: messages.to_vec(),
temperature,
max_tokens: Some(8192),
stream: true,
tools: if tool_defs.is_empty() { None } else { Some(tool_defs.to_vec()) },
tool_choice: None,
reasoning_content: last_reasoning_content.clone(),
};
let result = stream_llm(
Arc::clone(provider), retry_request, app_handle, stop_flag, notify, conv_id,
).await;
match result {
StreamResult::Complete { text, tool_calls, usage, reasoning_content } => {
return StreamOutcome::Success {
text, tool_calls, usage, incomplete: false,
resolved_model: resolved_model.to_string(), reasoning_content,
};
}
StreamResult::Partial { text, tool_calls, usage, reasoning_content } => {
// MidStream 保文不重试。携带 incomplete=true 交调用方走保文路径。
tracing::warn!(
conv_id = %conv_id,
text_len = text.len(),
"[ai] 流中途失败(候选 {}),保文不重试(incomplete=true)",
candidate.name,
);
return StreamOutcome::Success {
text, tool_calls, usage, incomplete: true,
resolved_model: resolved_model.to_string(), reasoning_content,
};
}
StreamResult::InitFailed { retryable, error } => {
// Fatal(4xx 非429/鉴权/参数错)立即放弃整个 fallback。
if !retryable {
tracing::warn!(
conv_id = %conv_id,
provider = %candidate.name,
attempt = retry_attempt + 1,
"[ai] 候选 {} 流前失败 Fatal(4xx/鉴权),立即放弃 fallback",
candidate.name,
);
return StreamOutcome::Fatal { error };
}
// Retryable:判断是否重试预算耗尽
if let Some(exhausted_error) = check_retry_exhausted(
conv_id, candidate, retry_attempt, max_retries, retry_deadline, &error,
) {
return StreamOutcome::InitFailedExhausted { error: exhausted_error };
}
// 未耗尽:退避 + emit AiStreamRetry + sleep + continue
emit_retry_attempt(
app_handle, conv_id, retry_attempt, max_retries, retry_deadline,
).await;
}
}
}
// 仅 max_retries=0(无重试配置)且首试 InitFailed retryable=true 时到达此处:
// 0..=0 循环首趟即 is_last → 已在循环内 return InitFailedExhausted。此为防御兜底。
StreamOutcome::InitFailedExhausted {
error: "AI 调用失败:重试预算耗尽(防御兜底)".to_string(),
}
}
// ============================================================
// stream_round: 单轮流式调用(candidate 链 + stream_one_provider + MidStream 保文)
// ============================================================
/// `run_agentic_loop` for 循环体「单轮流式调用」自包含块的返回结果。
///
/// 抽自 L1505-L1756(retry_deadline → candidate_chain 构建/降级 → candidate 循环
/// (stream_one_provider + match 三态) → epoch 后校验 → MidStream 保文)。
/// 表达本轮的 4 种结束路径,供 loop 主框架据以 `return` 或继续后续处理。
///
/// **不在本函数范围**(loop 内其它段):
/// - 停止/收敛/审批等待/熔断这些在 process_tool_calls 之后,
/// 属「工具执行后路径」,与流式调用本身解耦,留 loop 主体。
/// - 正常完成(loop 外 L1957+):loop 退出后由 run_agentic_loop 直接收尾。
pub(crate) enum RoundOutcome {
/// 本轮 stream 成功(Complete,无 incomplete)。携带本轮输出供 loop 后续
/// push_assistant_message / save / 工具执行处理。
///
/// 注:reasoning_content 不在此 variant 携带——stream_round 内已通过
/// `*last_reasoning_content = rc` 写回 caller 的 loop 级 mut 变量,loop 后续
/// push_assistant_message 读 last_reasoning_content 即可(零行为变更)。
Continue {
full_text: String,
tool_calls: std::collections::HashMap<u32, ToolCallDraft>,
/// 本轮补全后 usage(prompt 估算兜底,round_usage_effective 单点派生),
/// loop 主体直接 add_usage/push,不再字面量重组。
round_usage: df_ai::provider::TokenUsage,
/// 本轮 prompt 是否估算兜底(= 进 stream_round 时的 round_usage.prompt_tokens == 0),
/// 供 loop 主体 push 消息级 is_estimated,与 round_usage 同点派生防漂移。
round_is_estimated: bool,
},
/// 流返回后 stale(epoch 已变,被新 loop 接管):guard 已 disarm,
/// loop 应直接 return(不再 push/save/执行工具)。
StaleAfterStream,
/// MidStream 保文路径已完整收尾(tokens.add + push partial + 系统提示 +
/// teardown_round save/reset/emit)→ loop 直接 return。
MidStreamSaved,
/// Fatal(候选 Fatal 或全候选耗尽):guard.reset + epoch 检查 + emit AiError +
/// save 已完成 → loop 直接 return。
Fatal,
}
/// 单轮流式调用:抽自 `run_agentic_loop` for 循环体的「单轮流式」自包含块。
///
/// 范围 = retry_deadline 计算 → candidate_chain 构建(G4.1 防抖降级)
/// → candidate 循环(try_acquire_for_provider + stream_one_provider + match 三态)
/// → epoch 后 stale 检查 → MidStream 保文路径。返回 [`RoundOutcome`] 表达本轮结果。
///
/// **逐字节等价**:原 L1505-L1756 内联块的 save/emit/guard.reset/permit 顺序、
/// token 累加、push_assistant_message 调用、teardown_round 退出序列全部保留。
/// 抽取仅改控制流(原各路径 `return` → `return RoundOutcome::Xxx`,loop 据返回值决定 return)。
///
/// **借用处理**:
/// - `tokens: &mut TokenAccumulator` / `saved_token_snapshot: &mut TokenUsage` /
/// `last_round_estimated: &mut bool`:loop 级累计状态,&mut 借用,函数内修改 caller 可见。
/// - `resolved_model: &mut String` / `provider_config: &mut AiProviderRecord` /
/// `last_reasoning_content: &mut Option<String>`:loop 级 mut 变量(被 candidate
/// 切换覆盖),&mut 借用,函数内赋新值后 loop 后续 push/save 用最新。
/// - `guard: &mut GeneratingGuard`:RAII 复位,&mut 借用,函数内 guard.reset()/disarm()
/// 直接作用于 caller 的 guard。
/// - 其它(session_arc/db/app_handle/conv_id/...):& 引用。
///
/// **permit**:per-provider permit 仍在 candidate 循环内取/释放(随作用域 Drop),
/// 与原实现一致;per_conv permit 由 loop 入口整 loop 持有,本函数不涉及。
#[allow(clippy::too_many_arguments)]
pub(crate) async fn stream_round(
session_arc: &Arc<Mutex<AiSession>>,
db: &Arc<Database>,
conv_id: &str,
messages: &[ChatMessage],
tool_defs: &[df_ai::provider::ToolDefinition],
app_handle: &AppHandle,
stop_flag: &std::sync::atomic::AtomicBool,
notify: &tokio::sync::Notify,
max_retries: usize,
model_override: &Option<String>,
agentic_req: &TaskRequirements,
estimated_prompt: u32,
// &mut loop 级状态(函数内修改,caller 可见)
last_round_estimated: &mut bool,
last_reasoning_content: &mut Option<String>,
resolved_model: &mut String,
provider_config: &mut AiProviderRecord,
provider_saturation: &mut HashMap<String, usize>,
tokens: &mut TokenAccumulator,
saved_token_snapshot: &mut df_ai::provider::TokenUsage,
guard: &mut GeneratingGuard,
// & 引用
candidates: &[AiProviderRecord],
llm_concurrency: &LlmConcurrency,
pinned_goals_snapshot: &[GoalEntry],
loop_epoch_arc: &std::sync::Arc<std::sync::atomic::AtomicU64>,
my_epoch: u64,
) -> RoundOutcome {
// 重试总预算(挂钟,含 sleep + 各次请求耗时),对齐 retry::MAX_TOTAL_BUDGET 30s。
// 本轮各 candidate 共享一个 30s 预算(切换 provider 不重置预算,
// 防多 provider 串行重试累加超过单轮总预算)。超预算直接放弃重试交最终错误/保文路径。
let retry_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30);
// 外层 for candidate 包裹 stream_one_provider。
// 顺序:[primary, ...candidates](空池兜底时仅 primary)。
// - Success → 用结果覆盖迭代级 resolved_model/provider_config(供 push/save/标题);
// 成功即 break。
// - InitFailedExhausted(retryable 耗尽)→ continue 切下一 candidate。
// - Fatal(4xx 非429/鉴权)→ stream_llm 已 emit AiError,guard.reset + return
// 放弃整轮(对齐 retry.rs Fatal + provider_pool 文档)。
// 全 candidate 耗尽 → 沿用单 provider 失败语义(guard.reset + return,AiError 已在
// 各 candidate 的 stream_llm 内 emit 最后一条)。
// 候选链(拥有 Vec):[primary, ...candidates]。预先 clone primary 入链,
// 避免借用 provider_config(切换成功后需写回 provider_config = candidate.clone())。
// candidates 空(单 provider)→ candidate_chain 仅 primary,循环跑一次,耗尽即 return。
let mut candidate_chain: Vec<AiProviderRecord> = Vec::with_capacity(1 + candidates.len());
candidate_chain.push(provider_config.clone());
candidate_chain.extend(candidates.iter().cloned());
// 防瞬时抖动(G4.1):连续 PROVIDER_SATURATION_DEMOTE_THRESHOLD 次饱和才把主 provider
// 降级到链尾。计数器在候选循环内维护(Exhausted +1 / Acquired 清零),跨迭代累计。
// 链首(当前主 provider)连续 N 次 Exhausted → 移到链尾让 fallback 先试;
// 恢复后 Acquired 清零 → 下轮回到链首。单次/瞬时饱和(N 次内)不降级,防跳过主 provider。
// first():链构建总是 push 至少 1 个(provider_config),但防御性取首防空链 panic。
if let Some(streak) = candidate_chain
.first()
.and_then(|c| provider_saturation.get(&c.id))
.copied()
.filter(|s| *s >= PROVIDER_SATURATION_DEMOTE_THRESHOLD)
{
let saturated = candidate_chain.remove(0);
candidate_chain.push(saturated.clone());
tracing::info!(
conv_id = %conv_id,
provider = %saturated.name,
streak = streak,
threshold = PROVIDER_SATURATION_DEMOTE_THRESHOLD,
"[ai] 主 provider 连续 {} 次 per-provider 饱和,降级到候选链尾(防抖动跳过)",
streak,
);
}
let (full_text, tool_calls_acc, round_usage, incomplete, round_reasoning_content) = {
// outcome 累积成功结果(Complete/Partial),InitFailed 不写入。
let mut outcome: Option<(String, std::collections::HashMap<u32, ToolCallDraft>, df_ai::provider::TokenUsage, bool, Option<String>)> = None;
// 追踪最后一个 Exhausted candidate 的诊断文本,供「全 candidate 耗尽」时 emit 最终 AiError。
// Fatal 分支即时 emit(终态不切候选),Exhausted 分支仅记录 error 不 emit(可能切下一 candidate 成功,
// emit 会留残留气泡)。
let mut last_exhausted_error: Option<String> = None;
'candidate: for candidate in &candidate_chain {
// per-provider permit(非阻塞三态,G4.1):
// NotConfigured(未配置,无限流)→ 无 permit 直接 proceed(单 provider 零变化);
// Acquired(permit)→ 持 permit stream,切换 candidate 时随作用域 Drop 释放;
// Exhausted(占满)→ 连续饱和计数 +1,跳下一 candidate(降级,不阻塞)。
// 旧 acquire_for_provider(阻塞 acquire_owned)在主 provider 信号量占满时整条
// fallback 链卡死(降级失效),已由本三态非阻塞版本取代。
let _provider_permit = match llm_concurrency.try_acquire_for_provider(&candidate.id).await {
ProviderAcquire::NotConfigured => None,
ProviderAcquire::Acquired(permit) => {
// 成功拿到 permit:该 provider 已恢复,连续饱和计数清零(跨迭代防抖)。
provider_saturation.insert(candidate.id.clone(), 0);
Some(permit)
}
ProviderAcquire::Exhausted => {
// 占满:连续饱和计数 +1;本次跳下一 candidate(非阻塞,不等待)。
// 跨迭代防抖:连续 N 次饱和后链首降级(见候选链构建处),防瞬时抖动跳过主 provider。
*provider_saturation.entry(candidate.id.clone()).or_insert(0) += 1;
tracing::debug!(
conv_id = %conv_id,
provider = %candidate.name,
streak = provider_saturation.get(&candidate.id).copied().unwrap_or(0),
"[ai] 候选 {} per-provider 信号量占满,跳过(非阻塞,防单 provider 429)",
candidate.name,
);
continue 'candidate;
}
};
let stream_outcome = stream_one_provider(
candidate,
messages,
tool_defs,
app_handle,
stop_flag,
notify,
conv_id,
max_retries,
retry_deadline,
model_override,
agentic_req,
last_reasoning_content,
).await;
match stream_outcome {
StreamOutcome::Success { text, tool_calls, usage, incomplete, resolved_model: m, reasoning_content: rc } => {
// 成功:更新迭代级 resolved_model + provider_config(供后续 push/save/标题)。
// mut 解构(已在顶部声明 mut):本轮后续 save/push 用「实际成功所用 provider」
// 而非主 candidate。compress 已在本轮 stream 之前用过 primary,不受影响。
*resolved_model = m;
*provider_config = candidate.clone();
*last_reasoning_content = rc.clone();
outcome = Some((text, tool_calls, usage, incomplete, last_reasoning_content.clone()));
break 'candidate;
}
StreamOutcome::InitFailedExhausted { error } => {
// 本 candidate 重试耗尽(retryable)。切下一 candidate 继续尝试。
// candidates 空(单 provider)时此即「耗尽 return」语义——
// 跳出循环后 outcome 仍 None,落入下方「全耗尽 return」兜底。
// 不在此 emit AiError(可能切下一 candidate 成功留残留气泡),
// 仅记录 error 供全耗尽兜底 emit 最后一条。
tracing::warn!(
conv_id = %conv_id,
provider = %candidate.name,
"[ai] 候选 {} 流前失败重试耗尽,切换下一 provider",
candidate.name,
);
last_exhausted_error = Some(error);
continue 'candidate;
}
StreamOutcome::Fatal { error } => {
// Fatal(4xx 非429/鉴权/参数错):立即放弃整轮 fallback。
// 统一 emit 最终错误气泡(单气泡聚合)。
guard.reset().await;
// F1:旧 loop(被新 loop 接管)不 emit 错误气泡(owner loop 负责呈现)。
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
return RoundOutcome::Fatal;
}
emit_fatal_error(app_handle, conv_id, &error).await;
// G1.1(2026-08-05):Fatal 退出也落库 user 消息(镜像下方 Exhausted 分支),
// 否则重启/切会话从 DB 恢复时末条 user 丢失(与 Exhausted 同根因不对称)。
// epoch 校验通过后、return 前补 save(owner loop 才写,防 owner 已转移仍写)。
save_conversation(session_arc, db, conv_id, None, Some(&*resolved_model), true).await;
return RoundOutcome::Fatal;
}
}
}
match outcome {
Some(r) => r,
None => {
// 全 candidate 耗尽(含单 provider 场景):沿用原单 provider 失败语义。
tracing::warn!(
conv_id = %conv_id,
candidates_tried = candidate_chain.len(),
"[ai] 全 provider 候选流前失败重试耗尽,放弃本轮",
);
guard.reset().await;
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
return RoundOutcome::Fatal;
}
let err_msg = last_exhausted_error
.unwrap_or_else(|| "AI 调用失败:所有候选 provider 重试耗尽".to_string());
emit_fatal_error(app_handle, conv_id, &err_msg).await;
// 全 provider 失败也需落库 user 消息(已 push 内存),
// 否则切换/重载从 DB 恢复时末条 user 丢失(实测压缩触发失败场景:DB 末条 tool 无后续 user)。
save_conversation(session_arc, db, conv_id, None, Some(&*resolved_model), true).await;
return RoundOutcome::Fatal;
}
}
};
// G4.3:本轮 token 用量是否估算值(provider 未报 prompt_tokens → estimated_prompt 兜底),
// 供 loop 内/loop 后各退出路径透传 AiCompleted(is_estimated)仅作展示标注。
// 语义修正:整 loop 只要任一轮估算即标估算(累计总量含估算成分),非仅末轮。
// 三真相源收口:本轮 usage 补全 + estimated 判定在此单点派生(round_usage_effective),
// MidStream 保文分支与 Continue 路径(loop 主体)均消费本结果,不再各自字面量重组。
let (round_usage_full, round_is_estimated) = round_usage_effective(&round_usage, estimated_prompt);
*last_round_estimated |= round_is_estimated;
// F1 并发 epoch:流返回后若已被新 loop 接管(force_send 等),旧 loop 不再 push 消息 /
// 保文 / 执行工具,立即退出(guard disarm 跳过复位,防 clobber 新 loop 的 Generating)。
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
guard.disarm();
return RoundOutcome::StaleAfterStream;
}
// CR-30-2 / UX-2025-04 / 决策 a1: MidStream 保文路径——partial_text 已接收,
// 入库为正常 assistant 消息 + emit AiCompleted(incomplete=true) + 追加系统提示消息。
// 不走 AiError(非异常中断,已有可用文本),不重试(决策 a1)。
// 并发时用户停优先:网络断同时用户点停 → stop_flag true 时不走保文路径,
// 落入下方 ~1508 stop_flag 检查走停止路径(用户意图优先)。
// partial 文本仍由下方 push_assistant_message(~1485)保文不丢。
if incomplete && !stop_flag.load(Ordering::SeqCst) {
// 本轮 usage 补全(prompt 估算兜底,含 cache/reasoning 分项)已由 round_usage_effective 单点派生
let usage = round_usage_full;
// 累加本轮全量 usage(含 cache/reasoning 分项)到 tokens 累加器
tokens.add_usage(&usage);
// 追加 partial assistant 消息(若无 tool_calls 且有文本)
// 退出校验改 conv 存在性 + push 改 per_conv.messages。
// conv_id 来源:run_agentic_loop 入参。
{
let mut session = session_arc.lock().await;
if !session.per_conv.contains_key(conv_id) {
tracing::warn!(
stale_conv = %conv_id,
"[ai] MidStream 保文后 conv 已删除,丢弃本轮 push"
);
return RoundOutcome::MidStreamSaved;
}
// 与 push_assistant_message 一致:trim 归一化,纯空白保文(网络中断只有空白)不落库
let full_text = full_text.trim();
if !full_text.is_empty() {
let conv = session.conv(conv_id);
let mut msg = ChatMessage::assistant(full_text);
msg.model = Some(resolved_model.clone());
// MidStream 保文也回填 reasoning_content
msg.reasoning_content = round_reasoning_content.clone();
// 消息级 token(对齐 push_assistant_message 双轨持久化):本轮 partial usage
// (prompt=round 或 estimated 兜底,completion=round)。系统提示消息无 token,不设。
// 分项 token(2026-08-02):cache/reasoning 透传自 round_usage。
msg.prompt_tokens = Some(usage.prompt_tokens);
msg.completion_tokens = Some(usage.completion_tokens);
msg.prompt_cache_hit_tokens = Some(usage.prompt_cache_hit_tokens);
msg.prompt_cache_miss_tokens = Some(usage.prompt_cache_miss_tokens);
msg.reasoning_tokens = Some(usage.reasoning_tokens);
// 消息级估算标记:本轮 prompt 是否 estimated 兜底,reload 逐条回显对齐 live 态
msg.is_estimated = Some(round_is_estimated);
conv.messages.push(msg);
// 追加系统提示消息:响应因网络中断不完整(对齐决策 a1 系统提示机制)
let mut notice = ChatMessage::system("⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。");
notice.model = Some(resolved_model.clone());
conv.messages.push(notice);
}
}
// 统一走 teardown_round 收尾(save + spawn_title + reset + emit)。
// 注意:partial 文本+系统提示已先 push(上方 block),此 save 落库含本轮 partial,幂等覆盖。
// save_usage 用增量(tokens 已累加本轮,减上次快照);emit_usage 用 tokens 快照累计。
// MidStream 分叉:emit_incomplete=Some(true)(前端标不完整),publish_incomplete=None(总线消费方),
// do_publish=true(publish 走总线)。spawn_title=true(后台标题,失败 extract 兜底)。
let save_usage = usage_delta_since(tokens, saved_token_snapshot);
let emit_usage = tokens_snapshot(tokens);
teardown_round(
session_arc, db, conv_id,
Some(&save_usage), Some(&*resolved_model),
true,
provider_config, llm_concurrency,
guard,
&emit_usage,
*last_round_estimated,
Some(true), None, true,
pinned_goals_snapshot,
app_handle,
loop_epoch_arc, my_epoch,
).await;
return RoundOutcome::MidStreamSaved;
}
// 正常完成本轮 stream(无 incomplete,或 incomplete 但用户停优先走 stop 路径)。
// 携带本轮输出交 loop 主体处理后续 push/save/工具执行。
// round_reasoning_content 已通过上方 `*last_reasoning_content = rc` 写回 caller,
// loop 后续 push_assistant_message 读 last_reasoning_content 即可。
let _ = round_reasoning_content; // 防未用 warning(MidStream 分支已消费)
RoundOutcome::Continue {
full_text,
tool_calls: tool_calls_acc,
round_usage: round_usage_full,
round_is_estimated,
}
}
// ── round_usage_effective: 本轮 usage 补全 + estimated 判定的单一派生点(三真相源收口) ──
//
// 收口前同一事实(「provider 未报 prompt_tokens → estimated_prompt 兜底」)在三处独立派生,
// 各持一份可漂移的重复逻辑:
// 1) MidStream 保文分支:TokenUsage 字面量重组(prompt/total 双条件分支)+ msg.is_estimated;
// 2) loop 主体 Continue 后:round_prompt + round_usage_full 同构字面量重组 + push 实参 estimated;
// 3) G4.3 累计标注:*last_round_estimated |= round_usage.prompt_tokens == 0。
// 收口后:此处一次求值返回 (补全 usage, is_estimated),三处消费方全部改为引用/派生,
// 逐字段等价(prompt=estimated 兜底 / total=estimated+completion / 分项透传),行为零变更。
fn round_usage_effective(
round_usage: &df_ai::provider::TokenUsage,
estimated_prompt: u32,
) -> (df_ai::provider::TokenUsage, bool) {
let is_estimated = round_usage.prompt_tokens == 0;
let usage = df_ai::provider::TokenUsage {
prompt_tokens: if is_estimated { estimated_prompt } else { round_usage.prompt_tokens },
completion_tokens: round_usage.completion_tokens,
total_tokens: if is_estimated {
estimated_prompt.saturating_add(round_usage.completion_tokens)
} else {
round_usage.total_tokens
},
// 分项 token(2026-08-02):cache/reasoning 透传自 round_usage,落库 + 累加器都需
prompt_cache_hit_tokens: round_usage.prompt_cache_hit_tokens,
prompt_cache_miss_tokens: round_usage.prompt_cache_miss_tokens,
reasoning_tokens: round_usage.reasoning_tokens,
};
(usage, is_estimated)
}
// ── emit_retry_attempt: 退避日志 + emit AiStreamRetry + sleep ──
// 前置:未耗尽重试预算(check_retry_exhausted 已返 None)。本函数完成退避后返回。
async fn emit_retry_attempt(
app_handle: &AppHandle,
conv_id: &str,
retry_attempt: usize,
max_retries: usize,
retry_deadline: tokio::time::Instant,
) {
let now = tokio::time::Instant::now();
let delay = retry::backoff_delay((retry_attempt + 1) as u32)
.min(retry_deadline.saturating_duration_since(now));
let total_retry = retry_attempt + 1;
tracing::warn!(
conv_id = %conv_id,
attempt = total_retry,
max_attempts = max_retries + 1,
delay_ms = delay.as_millis() as u64,
"[ai] 流前失败(Retryable),{}ms 后重试 ({}/{})",
delay.as_millis(), total_retry, max_retries + 1,
);
let ev = AiChatEvent::AiStreamRetry {
attempt: total_retry as u32,
max_attempts: (max_retries + 1) as u32,
conversation_id: Some(conv_id.to_string()),
};
emit_ai_chat_event(app_handle, ev);
tokio::time::sleep(delay).await;
}
/// 把 Anthropic prompt caching 易变尾段追加到请求消息流**末尾**(一条 user 消息)。
///
/// 多场景命中率设计要点:
/// - **放消息流末尾而非 system**:任何位于 system 之后的消息级缓存断点(含未来「多轮对话缓存」
/// 在末条 user 上打断点)都在易变段之前——易变内容(日期/清单/augmentation/知识)每日/每请求
/// 变化时,只影响其自身及之后(无),不失效已缓存的 system/历史前缀。若放首条 user,跨天日期
/// 变化会失效其后全部历史缓存。
/// - **用 user 角色而非 system**:anthropic_compat::convert_request 会把 System-role 消息抽到
/// 顶层 system(受 cache_control 缓存),易变段若为 system 会随请求变化失效整段 system 缓存。
/// 必须 user 承载,convert_request 对连续 user 做 merge_consecutive_users,追加安全。
/// - **不持久化**:仅注入本轮请求的 messages clone,per_conv.messages 不含易变段 → 每轮重建
/// 恰好一条,无重复。
pub(crate) fn merge_volatile_tail(messages: &mut Vec<ChatMessage>, tail: &str) {
if tail.is_empty() {
return;
}
messages.push(ChatMessage::user(tail));
}
+6 -9
View File
@@ -7,7 +7,7 @@
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager};
use tauri::AppHandle;
use tokio::sync::Mutex;
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
@@ -16,6 +16,7 @@ use df_storage::crud::AiToolExecutionRepo;
use df_storage::db::Database;
use super::cache::{cache_hit_warning, find_cached_high_risk_result, pending_placeholder_for};
use super::super::event_bus::emit_ai_chat_event;
use super::diff::build_write_file_diff;
use super::reason::build_approval_reason;
use super::record::audit_tool_call;
@@ -180,8 +181,7 @@ pub(super) async fn insert_pending_approval(
result: serde_json::Value::String(skip_msg.clone()),
conversation_id: Some(conv_id.to_string()),
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app_handle, ev);
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"), current_message_id).await;
return;
}
@@ -214,8 +214,7 @@ pub(super) async fn insert_pending_approval(
diff: approval_diff,
conversation_id: Some(conv_id.to_string()),
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app_handle, ev);
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id).await;
}
@@ -322,8 +321,7 @@ pub(super) async fn handle_approval_tool(
dir: dir_label.clone(),
conversation_id: Some(conv_id.to_string()),
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app_handle, ev);
// 收集后循环外 spawn 执行,对齐 Low risk 不持锁(原注释 L593 声称"锁外"但代码持锁,
// run_command 慢命令会阻塞同会话所有触 state.ai_session 的 IPC,CR-51 修此)
trust_hits.push((draft, args, dir_label, risk_level));
@@ -357,8 +355,7 @@ pub(super) async fn handle_approval_tool(
result: serde_json::Value::String(warned_content.clone()),
conversation_id: Some(conv_id.to_string()),
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app_handle, ev);
// 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failed,SW-260618-16;decided_by=auto_dedup),不进 pending
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(warned_content), Some("auto_dedup"), current_message_id).await;
return;
+33 -34
View File
@@ -56,8 +56,12 @@ pub(crate) fn is_pending_placeholder(content: &str) -> bool {
/// AC-2 ②:list_directory 目录级去重(cache key 只取 path,同目录换参数也命中)后,
/// 通用文案「换参数或换工具」对同目录无效(换 max_depth 也仍命中),故目录类工具
/// 用专用文案:明确告知「目录已列过 + 内容未变」,引导换 grep/search_files 定向搜索。
///
/// 目录类判定查注册元数据(cache_path_key 维度,DRY 债 #5 收敛),与
/// readonly_cache_args_key 的目录级 key 同一判据,不再各自手写工具名 match。
pub(crate) fn cache_hit_warning(tool_name: &str, hit_count: u32) -> String {
if matches!(tool_name, "list_directory" | "list_dir") {
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
if df_ai::ai_tools::cache_path_key_only(tool_name) {
format!(
"[重复调用拦截] 目录 {} 此前已列过(第 {} 次命中缓存),目录内容未变,以下为缓存结果。不要重复列同一目录,如需查找文件请改用 grep/search_files 定向搜索,或换其他目录。\n\n",
tool_name, hit_count
@@ -212,14 +216,16 @@ fn canonical_args_key(args: &serde_json::Value) -> String {
///
/// list_directory 同目录换参数(recursive/max_depth/skip_noise_dirs 不同)也判重复——
/// 目录内容没变,换深度/递归参数重列是重复探索(实证 09e7abfa:同一目录列 3 次)。
/// 故 list_directory 的缓存 key 只取 path(忽略其他参数),同目录即命中缓存,
/// 故声明 cache_path_key 的工具(list_directory,DRY 债 #5 收敛后查注册元数据)
/// 缓存 key 只取 path(忽略其他参数),同目录即命中缓存,
/// 后续命中返回缓存 + 专用警告(cache_hit_warning 目录分支)。
/// 其他只读工具仍走全参 JSON 归一(键序无关,见 canonical_args_key)。
///
/// pub(crate):供 audit/mod.rs 轮内去重复用同一 key 口径(同源同 key,
/// 跨轮缓存 + 轮内去重判定一致,防两处 key 漂移致去重误判)。
pub(crate) fn readonly_cache_args_key(tool_name: &str, args: &serde_json::Value) -> String {
if matches!(tool_name, "list_directory" | "list_dir") {
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
if df_ai::ai_tools::cache_path_key_only(tool_name) {
args.get("path")
.and_then(|v| v.as_str())
.unwrap_or_default()
@@ -241,9 +247,9 @@ pub(crate) fn readonly_cache_args_key(tool_name: &str, args: &serde_json::Value)
/// 跳过真实执行(也跳过 emit Started/Completed——直接走缓存回填路径)。
///
/// **安全边界**(对照 find_cached_high_risk_result 严格收紧):
/// - 仅匹配白名单只读幂等工具(READONLY_CACHE_TOOLS):read_file/list_directory/
/// search_files/grep/read_symbol。写工具(write_file/patch_file/run_command 等)
/// 永不缓存,避免误伤。
/// - 仅匹配只读幂等工具(readonly 元数据维度,DRY 债 #5 收敛后查注册元数据,
/// 现 read_file/read_symbol/list_directory/search_files/grep 声明)。
/// 写工具(write_file/patch_file/run_command 等)未声明,永不缓存,避免误伤。
/// - 仅匹配 **status=completed** 的成功结果(失败/拒绝不缓存,可能重试成功)。
/// - 仅匹配 **非占位** 结果(排除 pending_placeholder 占位)。
/// - args 走 JSON 规范化比较(键序无关),与 find_cached_high_risk_result 一致。
@@ -260,8 +266,9 @@ pub(crate) async fn find_cached_readonly_result(
) -> Option<(String, u32)> {
use df_ai::provider::MessageRole;
// 白名单:只读幂等工具才缓存。写工具/有副作用工具永不缓存。
if !READONLY_CACHE_TOOLS.contains(&tool_name) {
// 只读幂等工具才缓存。写工具/有副作用工具永不缓存。
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
if !df_ai::ai_tools::is_readonly(tool_name) {
return None;
}
@@ -332,17 +339,8 @@ pub(crate) async fn find_cached_readonly_result(
Some((content, hit_count))
}
/// 可缓存的只读幂等工具白名单。
/// 仅含纯读取/查询类工具:同参多次调用结果一致,缓存命中安全。
/// 写工具/有副作用工具(write_file/patch_file/run_command/create_*/update_*/delete_* 等)
/// 永不进此白名单——它们每次执行都可能改变状态,缓存会掩盖变化。
const READONLY_CACHE_TOOLS: &[&str] = &[
"read_file", // 读文件内容(同 path+offset+limit+search 参数 → 同结果)
"read_symbol", // AST 符号读取(同 path+symbol+full → 同结果)
"list_directory", // 列目录(AC-2 ②:缓存 key 只取 path,同目录换 recursive/max_depth 也判重复)
"search_files", // 搜文件名(同 path+pattern+offset+limit → 同结果)
"grep", // 搜文件内容(同 path+pattern+mode → 同结果)
];
/// 只读缓存白名单已收敛为注册元数据(readonly 维度,DRY 债 #5):
/// find_cached_readonly_result 改查 df_ai::ai_tools::is_readonly,本文件不再持名单。
/// 递归对 JSON 对象的键做字典序排序(就地),数组成员也递归排序。
fn sort_object_keys(v: &mut serde_json::Value) {
@@ -421,15 +419,18 @@ fn contains_listing_command(command: &str) -> bool {
|| contains_word(&lower, "ls")
}
/// 扫描会话历史,取所有 list_directory 已列过的目录路径(去重,保留原始大小写)。
/// 扫描会话历史,取所有目录级缓存工具(cache_path_key 声明者,现即 list_directory)
/// 已列过的目录路径(去重,保留原始大小写)。
///
/// 与 find_cached_readonly_result 同源读 assistant tool_calls;仅取 list_directory
/// 的 args.path,忽略递归/深度参数(② 同语义:同目录即算已列过)。
/// 与 find_cached_readonly_result 同源读 assistant tool_calls;仅取 args.path,
/// 忽略递归/深度参数(② 同语义:同目录即算已列过)。工具判定查注册元数据
/// (cache_path_key 维度,DRY 债 #5 收敛),与目录级缓存 key 同一判据。
async fn listed_directory_paths(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
) -> Vec<String> {
use df_ai::provider::MessageRole;
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
let session = session_arc.lock().await;
let Some(conv) = session.conv_read(conv_id) else { return Vec::new() };
let mut dirs: Vec<String> = Vec::new();
@@ -440,7 +441,7 @@ async fn listed_directory_paths(
}
let Some(tcs) = m.tool_calls.as_ref() else { continue };
for tc in tcs {
if tc.function.name != "list_directory" {
if !df_ai::ai_tools::cache_path_key_only(&tc.function.name) {
continue;
}
let Ok(args) = serde_json::from_str::<serde_json::Value>(&tc.function.arguments) else {
@@ -494,9 +495,10 @@ pub(crate) async fn detect_listing_bypass(
None
}
/// AC-2 ③:目录列举绕行警告注入(system 消息首位,对齐 G2/AC-2① 软提示模式)。
/// AC-2 ③:目录列举绕行警告注入(system 消息首位)。
///
/// 只警告不硬拒:命令照常执行/审批,误报最多多一条提示,不阻断工作流。
/// 插入骨架经 agentic::helpers::insert_system_notice 复用(DRY #7,与 G2/AC-2① 同一实现)。
pub(crate) async fn insert_listing_bypass_warning(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
@@ -506,16 +508,13 @@ pub(crate) async fn insert_listing_bypass_warning(
"⚠ 检测到用 run_command 列目录({}) 绕行 list_directory 去重。列目录请用 list_directory 工具,如需查找文件请用 grep/search_files 定向搜索。",
dir
);
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
conv.messages.insert_at(0, ChatMessage::system(&warn_text));
tracing::info!(
conv_id = %conv_id,
dir = %dir,
"[ai] AC-2 目录列举绕行:注入警告(只警告不硬拒)"
);
}
crate::commands::ai::agentic::helpers::insert_system_notice(
session_arc,
conv_id,
&warn_text,
&format!("AC-2 目录列举绕行:注入警告(只警告不硬拒) dir={}", dir),
)
.await;
}
#[cfg(test)]
+6 -26
View File
@@ -11,33 +11,13 @@ use tauri::{AppHandle, Emitter};
/// AR-11(方案A):工具名 → (entity, action) 映射,用于数据变更联动刷新。
///
/// 仅数据变更类工具返回 Some,其余工具返回 None(不 emit df-data-changed)。
/// 映射规则:
/// - create_project/create_idea/create_task → (project/idea/task, create)
/// - update_project/update_task/update_idea → (..., update)
/// - delete_project/delete_task/delete_idea/purge_project → (..., delete)
/// - restore_project → (project, update)(恢复等同状态变更,前端走 update 刷新)
/// - bind_directory → (project, update)(绑定目录属项目字段更新)
/// 映射查注册元数据(entity_action 维度,DRY 债 #5 收敛),在 declare_tool! 注册点声明:
/// create_*(project/idea/task, create)、update_*(..., update)、delete_*/purge_project
/// (..., delete)、restore_project/bind_directory → (project, update)、advance_task →
/// (task, update)(状态推进对齐 update_task)、write_file/patch_file → (file, update)。
pub(super) fn data_change_for_tool(name: &str) -> Option<(&'static str, &'static str)> {
let (entity, action) = match name {
"create_project" => ("project", "create"),
"create_task" => ("task", "create"),
"create_idea" => ("idea", "create"),
"update_project" => ("project", "update"),
"update_task" => ("task", "update"),
"update_idea" => ("idea", "update"),
// advance_task 改任务状态 = 任务数据变更,MCP 走 HTTP 形态时让 GUI 前端
// listen df-data-changed 刷新任务列表(此前不在映射 → 状态推进后 GUI 不刷新)。
// 数据变更机制是 entity 级(无 id),映射到 (task, update) 对齐 update_task。
"advance_task" => ("task", "update"),
"delete_project" | "purge_project" => ("project", "delete"),
"delete_task" => ("task", "delete"),
"delete_idea" => ("idea", "delete"),
"restore_project" => ("project", "update"),
"bind_directory" => ("project", "update"),
"write_file" | "patch_file" => ("file", "update"),
_ => return None,
};
Some((entity, action))
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
df_ai::ai_tools::entity_action(name)
}
/// AR-11(方案A):工具执行成功后按映射 emit "df-data-changed" { entity, action }。
+28 -65
View File
@@ -3,7 +3,7 @@
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager};
use tauri::{AppHandle, Manager};
use tokio::sync::Mutex;
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
@@ -11,6 +11,8 @@ use df_ai::provider::ChatMessage;
use df_storage::crud::AiToolExecutionRepo;
use df_storage::db::Database;
use super::event_bus::emit_ai_chat_event;
use crate::state::{SettingsNorm, log_slow_lock};
use super::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApproval, ToolCallDraft};
use super::tools::entity_resolve;
@@ -142,8 +144,7 @@ async fn execute_with_heartbeat(
break;
}
let ev = AiChatEvent::AiHeartbeat { conversation_id: Some(conv.clone()) };
let _ = app_h.emit("ai-chat-event", ev.clone());
let _ = app_h.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app_h, ev);
}
});
// RAII:execute 无论 Ok/Err/panic(unwind),_guard drop 自动 stop+abort,无心跳 task 泄漏。
@@ -233,10 +234,7 @@ pub(crate) async fn process_tool_calls(
let session = session_arc.lock().await;
let __id = session.conv_read(conv_id)
.and_then(|c| c.messages.last_assistant_message_id());
let __hold = __lock_t.elapsed();
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] process_tool_calls:192 持锁 {:?} (含 lock 等待)", __hold);
}
log_slow_lock(__lock_t, "process_tool_calls:192");
__id
},
).await {
@@ -260,8 +258,7 @@ pub(crate) async fn process_tool_calls(
args: args.clone(),
conversation_id: Some(conv_id.to_string()),
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app_handle, ev);
(idx, draft, args)
})
.collect();
@@ -283,18 +280,14 @@ pub(crate) async fn process_tool_calls(
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, &err_payload));
let __hold = __lock_t.elapsed();
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] process_tool_calls:268 持锁 {:?} (含 lock 等待)", __hold);
}
log_slow_lock(__lock_t, "process_tool_calls:268");
}
let ev = AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(err_payload.clone()),
conversation_id: Some(conv_id.to_string()),
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app_handle, ev);
let risk = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"failed", risk, Some(err_payload.clone()), Some("auto_resolve_fail"), current_message_id).await;
@@ -306,16 +299,12 @@ pub(crate) async fn process_tool_calls(
// 自动执行范围三档读取(前置到路径授权预校验之前:两处门控共用)。
// KV df-ai-auto-execute-mode 取值 low/medium/all,默认 low;
// low 仅 Low 自动(等价旧行为),medium Low+Medium,all 全自动无审批(完全 AI 接管)。
// get_string_norm 归一去引号(前端 JSON.stringify 落库带引号),不归一则 medium/all 档静默失效全走审批。
let auto_exec_mode = app_handle
.state::<crate::state::AppState>()
.settings
.get("df-ai-auto-execute-mode")
.get_string_norm("df-ai-auto-execute-mode")
.await
.ok()
.flatten()
// 前端 appSettings.set 以 JSON.stringify 落库(字符串值带引号,"all" != all),
// 不归一则 medium/all 档静默失效全走审批(同 prompt.rs custom_prompt 的 trim 先例)。
.map(|v| v.trim_matches('"').to_string())
.unwrap_or_else(|| "low".to_string());
// ── 文件工具路径授权预校验 ──
@@ -334,16 +323,12 @@ pub(crate) async fn process_tool_calls(
// 「信任所有目录」总开关(KV df-ai-path-auth-bypass,默认关):开启后白名单外路径不再挂起
// 授权弹窗(NeedsAuth 时直接放行),黑名单拦截也一并放行(产品拍板:体验优先,含敏感目录)。
// 读取失败按关处理(fail-safe,默认行为不变);前端设置页改后下一轮 process_tool_calls 即生效。
// get_bool_norm 归一比对(前端 JSON.stringify 落库带引号),默认 false 由调用方显式传参。
let path_auth_bypass = app_handle
.state::<crate::state::AppState>()
.settings
.get(PATH_AUTH_BYPASS_KEY)
.await
.ok()
.flatten()
// 前端 appSettings.set 以 JSON.stringify 落库(带引号),归一后比对(同 df-ai-trace-approve 先例)
.map(|v| { let v = v.trim_matches('"'); v == "true" || v == "1" })
.unwrap_or(false);
.get_bool_norm(PATH_AUTH_BYPASS_KEY, false)
.await;
// 同步进程级黑名单放行标志:validate_path(tool_registry)无 bypass 参数,靠此标志放行黑名单;
// 每轮工具执行前刷新,与下方 check_file_tool_auth 的入参 bypass 同源同值,两处拦截点语义一致。
path_auth::set_blacklist_bypass(path_auth_bypass);
@@ -385,10 +370,7 @@ pub(crate) async fn process_tool_calls(
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, &err_msg));
let __hold = __lock_t.elapsed();
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] process_tool_calls:262 持锁 {:?} (含 lock 等待)", __hold);
}
log_slow_lock(__lock_t, "process_tool_calls:262");
}
// L3 emit 双写:路径黑名单拒绝 emit Completed 双路发布。
let ev = AiChatEvent::AiToolCallCompleted {
@@ -396,8 +378,7 @@ pub(crate) async fn process_tool_calls(
result: serde_json::Value::String(err_msg.clone()),
conversation_id: Some(conv_id.to_string()),
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app_handle, ev);
// 审计:路径黑名单拒绝(decided_by=auto_blacklist),留痕可追溯
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
audit_tool_call(
@@ -433,18 +414,14 @@ pub(crate) async fn process_tool_calls(
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, &skip_msg));
let __hold = __lock_t.elapsed();
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] process_tool_calls:305 持锁 {:?} (含 lock 等待)", __hold);
}
log_slow_lock(__lock_t, "process_tool_calls:305");
}
let ev = AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(skip_msg.clone()),
conversation_id: Some(conv_id.to_string()),
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app_handle, ev);
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
audit_tool_call(
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
@@ -473,10 +450,7 @@ pub(crate) async fn process_tool_calls(
);
// 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
let __hold = __lock_t.elapsed();
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] process_tool_calls:325 持锁 {:?} (含 lock 等待)", __hold);
}
log_slow_lock(__lock_t, "process_tool_calls:325");
}
tracing::debug!(target: "ai_dirauth", conv = %conv_id, tool = %draft.name, tc_id = %draft.id, "emit AiDirAuthRequired(路径授权挂起,等 ai_authorize_dir 恢复)");
let ev = AiChatEvent::AiDirAuthRequired {
@@ -486,8 +460,7 @@ pub(crate) async fn process_tool_calls(
dir: dir_str,
conversation_id: Some(conv_id.to_string()),
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app_handle, ev);
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
audit_tool_call(
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
@@ -507,16 +480,12 @@ pub(crate) async fn process_tool_calls(
// 也进此向量,需透传真实等级,审计方可追溯「all 模式执行了多少高危命令」。
let mut low_risk: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::new();
// F-#97 诊断:用户报「选 all 仍审批」时辅助定位 KV 是否真落库。
// df-ai-trace-approve 开关控制(默认开)。常见误以为已生效但实际未点确认按钮致 KV 未落。
// df-ai-trace-approve 开关控制(默认开,get_bool_norm 默认值调用方传参)。常见误以为已生效但实际未点确认按钮致 KV 未落。
let trace_on = app_handle
.state::<crate::state::AppState>()
.settings
.get("df-ai-trace-approve")
.await
.ok()
.flatten()
.map(|v| { let v = v.trim_matches('"'); v == "true" || v == "1" })
.unwrap_or(true);
.get_bool_norm("df-ai-trace-approve", true)
.await;
if trace_on {
tracing::info!(
conv_id = %conv_id,
@@ -570,8 +539,7 @@ pub(crate) async fn process_tool_calls(
result: serde_json::Value::String(content.clone()),
conversation_id: Some(conv_clone),
};
let _ = app_clone.emit("ai-chat-event", ev.clone());
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app_clone, ev);
// 数据变更类工具执行成功后 emit df-data-changed 联动刷新
// (write_file/run_command 不在 data_change_for_tool 映射内,emit_data_changed 内部 None 即 noop)
emit_data_changed(&app_clone, &draft.name);
@@ -585,8 +553,7 @@ pub(crate) async fn process_tool_calls(
result: serde_json::Value::String(content.clone()),
conversation_id: Some(conv_clone),
};
let _ = app_clone.emit("ai-chat-event", ev.clone());
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app_clone, ev);
(draft, risk_level, Err(content))
}
}
@@ -668,8 +635,7 @@ pub(crate) async fn process_tool_calls(
result: serde_json::Value::String(warned_content.clone()),
conversation_id: Some(conv_id.to_string()),
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app_handle, ev);
// 短 lock 段:push tool_result(纯写,无 await)
{
let mut session = session_arc.lock().await;
@@ -709,8 +675,7 @@ pub(crate) async fn process_tool_calls(
result: val.clone(),
conversation_id: Some(conv_clone),
};
let _ = app_clone.emit("ai-chat-event", ev.clone());
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app_clone, ev);
// AR-11(方案A):数据变更工具执行成功后 emit df-data-changed,
// 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。
emit_data_changed(&app_clone, &draft.name);
@@ -729,8 +694,7 @@ pub(crate) async fn process_tool_calls(
result: serde_json::Value::String(err_msg.clone()),
conversation_id: Some(conv_clone),
};
let _ = app_clone.emit("ai-chat-event", ev.clone());
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app_clone, ev);
(draft, serde_json::Value::Null, risk_level, Err(err_msg))
}
}
@@ -831,8 +795,7 @@ pub(crate) async fn process_tool_calls(
result: serde_json::Value::String(warned_content.clone()),
conversation_id: Some(conv_id.to_string()),
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app_handle, ev);
// 短 lock 段:push tool_result(纯写,无 await)
{
let mut session = session_arc.lock().await;
+8 -22
View File
@@ -40,28 +40,13 @@ pub(crate) static BLACKLIST_BYPASS_TEST_LOCK: std::sync::Mutex<()> = std::sync::
/// 提取文件工具的路径参数(用于路径授权预校验)。
///
/// 仅对走 resolve_workspace_path 校验的文件工具返回路径;非文件工具返回空 Vec(不预校验)。
/// - 单路径工具(read_file/write_file/list_directory/patch_file/file_info/append_file/delete_file/search_files/grep)
/// 取 args["path"]
/// - rename_file 双路径取 args["from"] + args["to"](两个都需授权)
/// - run_command 不返回(它只走 validate_path 黑名单,不限定 workspace,High risk 靠人工审批)
/// 路径参数键查注册元数据(path_args 维度,DRY 债 #5 收敛):单路径工具(read_file/
/// write_file/list_directory/patch_file/file_info/append_file/delete_file/search_files/grep)
/// 声明 ["path"],rename_file 声明 ["from","to"](两个都需授权),其余工具默认空(不预校验)。
/// run_command 不声明(只走 validate_path 黑名单,不限定 workspace,High risk 靠人工审批)。
pub(crate) fn extract_file_tool_paths(tool_name: &str, args: &serde_json::Value) -> Vec<String> {
match tool_name {
"rename_file" => {
let mut v = Vec::new();
if let Some(from) = args.get("from").and_then(|x| x.as_str()) {
v.push(from.to_string());
}
if let Some(to) = args.get("to").and_then(|x| x.as_str()) {
v.push(to.to_string());
}
v
}
"read_file" | "write_file" | "list_directory" | "patch_file" | "file_info"
| "append_file" | "delete_file" | "search_files" | "grep" => {
args.get("path").and_then(|x| x.as_str()).map(|s| vec![s.to_string()]).unwrap_or_default()
}
_ => Vec::new(),
}
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
df_ai::ai_tools::extract_paths(df_ai::ai_tools::path_arg_keys(tool_name), args)
}
/// 路径授权预校验结果。
@@ -124,10 +109,11 @@ pub(crate) fn check_file_tool_auth(
}
if pending_dirs.is_empty() {
FileToolAuthOutcome::Authorized
} else if tool_name == "search_files" {
} else if df_ai::ai_tools::denies_unauthorized(tool_name) {
// 核心设计5:search_files 兜底 — 盲搜语义应拒不应询。
// search_files 仅在已授权目录可用;用户已通过 @项目 提供项目上下文,无需搜索文件系统。
// (黑名单已在上方 for 循环内短路返 Denied,走到此处均为 NeedsAuthorization 场景。)
// 拒绝名单查注册元数据(deny_unauthorized 维度,DRY 债 #5 收敛,现仅 search_files 声明);
// 其他文件工具保持原 NeedsAuth 弹窗逻辑完全不变。
FileToolAuthOutcome::Denied(
"search_files 仅在已授权目录可用;请用 @[项目] 引用或提供绝对路径,不要盲搜文件系统。".to_string(),
+3 -3
View File
@@ -27,8 +27,9 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex as StdMutex;
use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager};
use tauri::AppHandle;
use super::event_bus::emit_ai_chat_event;
use super::AiChatEvent;
use df_execute::shell::StreamKind;
@@ -172,8 +173,7 @@ impl CommandSink {
line: line.to_string(),
conversation_id: conversation_id.clone(),
};
let _ = app.emit("ai-chat-event", ev.clone());
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(app, ev);
}
}
+141 -186
View File
@@ -13,21 +13,22 @@
use std::sync::atomic::Ordering;
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, State};
use tauri::{AppHandle, Manager, State};
// F-260801 Phase2:anthropic 协议 url→base64 预拉(base64 内嵌编码,generate_image.rs 同款用法)。
use base64::{engine::general_purpose::STANDARD, Engine as _};
use df_ai::provider::{ChatMessage, ContentPart, MessageStatus};
use df_storage::models::AiProviderRecord;
use df_types::augmentation::{MentionRef, MentionSpanDto};
use df_types::augmentation::{Augmentation, MentionRef, MentionSpanDto, ResolveError};
use df_types::types::new_id;
use crate::state::AppState;
use crate::state::{AppState, log_slow_lock};
use crate::commands::{err_str, now_millis};
// chat.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。
use super::super::agentic::{run_agentic_loop, try_continue_agent_loop};
use super::super::event_bus::emit_ai_chat_event;
#[cfg(test)]
use super::super::agentic::GOAL_MAX_CHARS;
// ConvState 枚举变体引用(Idle 等),用于 B-Phase2 写侧 conv_states.transition 调用。
@@ -152,10 +153,12 @@ fn find_mention_close(chars: &[char], open: usize, kinds: &[&str]) -> Option<usi
///
/// 空结果返 `""`,调用方据之跳过拼接(不污染 prompt)。
///
/// 注:regenerate / edit 路径当前传 `skill=&None, spans=&None`(无新注入诉求),
/// 若后续需从历史末条 user 消息的 mentionSpans resolve,改这两处调用即可。
/// 注:regenerate / edit 路径经 [`backtrack_leading_skill`] 回溯末条 user 前导 /技能名后传入,
/// spans 仍传 `&None`(重生成不重放历史 @ mention)。
async fn resolve_and_inject(
app: &AppHandle,
state: &AppState,
conv_id: &str,
provider: &AiProviderRecord,
skill: &Option<String>,
spans: &Option<Vec<MentionSpanDto>>,
@@ -188,9 +191,78 @@ async fn resolve_and_inject(
errors
);
}
// 注入反馈闭环:技能 resolve 成功/失败 emit 前端事件(轻 toast 用)。仅在 resolvers
// 聚合点(此函数)发,这里恰有 AppHandle 且同时看得到成功(augs)与失败(errors)两侧;
// project/task/idea mention 无用户显式调用语义,不发反馈。
for aug in &augs {
if let Augmentation::Skill { name, .. } = aug {
emit_skill_feedback(app, conv_id, name, true);
}
}
for err in &errors {
let name = match err {
ResolveError::NotFound { kind, ref_id } if kind == "skill" => Some(ref_id),
ResolveError::Skip { kind, ref_id, .. } if kind == "skill" => Some(ref_id),
_ => None,
};
if let Some(name) = name {
emit_skill_feedback(app, conv_id, name, false);
}
}
build_augmentation_segment(&augs, lang)
}
/// 技能注入成功/失败反馈事件(双写经 [`emit_ai_chat_event`])。
///
/// 前端 AiChat.vue listen 'ai-chat-event' 过滤本变体做轻 toast(成功「已注入技能《name》」/
/// 失败「技能 name 未找到」,i18n 中英成对)。fire-and-forget,emit 失败仅丢弃不阻断注入。
fn emit_skill_feedback(app: &AppHandle, conv_id: &str, name: &str, ok: bool) {
let ev = AiChatEvent::AiSkillInjected {
name: name.to_string(),
ok,
conversation_id: Some(conv_id.to_string()),
};
emit_ai_chat_event(app, ev);
}
/// 回溯末条 active user 消息的前导 `/技能名`(regenerate/edit 补技能注入)。
///
/// 背景:send 路径入口已 parse_leading_skill;regenerate/edit 此前写死 `&None` 丢技能。
/// 用户打字 `/name ...` 的原文落库在 user 消息 content 里,反向扫出末条 active user 后
/// 复用同一纯函数 [`parse_leading_skill`] 回溯(不新写解析逻辑),解析不到返 None(无注入)。
/// 注:edit 路径调用时末条 user 已被替换为编辑后文本,回溯对象即新文本,语义一致。
async fn backtrack_leading_skill(state: &AppState, conv_id: &str) -> Option<String> {
let text = {
let session = state.ai_session.lock().await;
session
.conv_read(conv_id)
.map(|c| c.messages.all_messages_clone())
.unwrap_or_default()
.into_iter()
.rev()
.find(|m| matches!(m.role, df_ai::provider::MessageRole::User) && m.is_active())
.map(|m| m.content)
.unwrap_or_default()
};
if text.is_empty() {
return None;
}
let available = super::super::skills::skills_cached()
.await
.iter()
.map(|s| s.name.clone())
.collect::<Vec<String>>();
let hit = parse_leading_skill(&text, &available);
if hit.is_some() {
tracing::info!(
conv_id = %conv_id,
skill = ?hit,
"[ai] regenerate/edit 回溯末条 user 前导 /技能名命中,补技能注入"
);
}
hit
}
/// 单条 [`MentionSpanDto`] → [`MentionRef`] 转换(kind 驱动)。
///
/// 未知 kind / 空 ref_id 返 None(静默跳过,不中断整批注入——与 resolve_all 失败收集语义一致)。
@@ -336,9 +408,10 @@ pub async fn ai_regenerate(
system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await;
}
}
// Augmentation 注入:regenerate 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。
// 若未来需从末条 user 消息的 mentionSpans resolve(验证 #8),改此处传入即可。
let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await;
// Augmentation 注入:regenerate 回溯末条 user 前导 /技能名(原文在 user 消息里,复用
// parse_leading_skill);@ mention spans 不重放,传 None 走空路径(不污染 prompt)。
let skill = backtrack_leading_skill(&state, &conv_id).await;
let aug_seg = resolve_and_inject(&app, &state, &conv_id, &provider_config, &skill, &None, &lang).await;
if !aug_seg.is_empty() {
if cache_on {
volatile = append_volatile(volatile, &aug_seg);
@@ -716,7 +789,7 @@ pub async fn ai_chat_send(
build_system_prompt_parts(&state, &lang, &excl_proj, &excl_task, cache_on).await;
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影后拼到 system prompt 前。
// 隔离标注(build_augmentation_segment 头尾包裹,FR-S4 风格)防 prompt injection 与用户指令/行为准则混淆。
let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await;
let aug_seg = resolve_and_inject(&app, &state, &conv_id, &provider_config, &skill, &mention_spans, &lang).await;
if !aug_seg.is_empty() {
if cache_on {
volatile = append_volatile(volatile, &aug_seg);
@@ -814,8 +887,7 @@ pub async fn ai_approve(
approved: false,
conversation_id: rec.conversation_id.clone(),
};
let _ = app.emit("ai-chat-event", ev.clone());
let _ = state.ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app, ev);
return Ok(format!("已处理({})", rec.status));
}
rec.status // 其他状态(如 pending/interrupted/skipped_retry)继续走未找到报错
@@ -863,10 +935,8 @@ pub async fn ai_approve(
approved: false,
conversation_id: conv_id.clone(),
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp;publish 内 EVENT_BUS_ENABLED 门控,
// 无消费者空转。app.emit 前端 + publish_event tunnel 两路独立,emit 保留不删。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
// app.emit 前端 + publish_event tunnel 两路独立,emit 保留不删(双写收敛进 emit_ai_chat_event)。
emit_ai_chat_event(&app, ev);
drop(session);
// 拒绝结果立即落库(含 recovered 积压审批)——switch 时已 restore_from_messages 载完整历史,
// messages 非空,save 不会污染老对话;原 if !recovered 守卫前提不成立已移除。
@@ -885,10 +955,7 @@ pub async fn ai_approve(
.and_then(|c| session.conv_read(c))
.map(|c| c.iteration_used)
.unwrap_or(0);
let __hold610 = __lock_t610.elapsed();
if __hold610 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_approve:610 持锁 {:?} (含 lock 等待)", __hold610);
}
log_slow_lock(__lock_t610, "ai_approve:610");
v
};
// 所有待审批处理完毕后恢复 agentic 循环
@@ -921,10 +988,7 @@ pub async fn ai_approve(
);
}
}
let __hold534 = __lock_t534.elapsed();
if __hold534 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_approve:534 持锁 {:?} (含 lock 等待)", __hold534);
}
log_slow_lock(__lock_t534, "ai_approve:534");
drop(session); // 释放锁后再执行
// B-260617-01 决策 a(方案 a2 后端分支):run_workflow 特殊处理。
@@ -1002,21 +1066,14 @@ pub async fn ai_approve(
result: result_val.clone(),
conversation_id: conv_id.clone(),
};
let _ = app.emit("ai-chat-event", ev_completed.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_completed);
emit_ai_chat_event(&app, ev_completed);
let ev_approval = AiChatEvent::AiApprovalResult {
id,
approved: true,
conversation_id: conv_id.clone(),
};
let _ = app.emit("ai-chat-event", ev_approval.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_approval);
let __hold704 = __lock_t704.elapsed();
if __hold704 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_approve:704 持锁 {:?} (含 lock 等待)", __hold704);
}
emit_ai_chat_event(&app, ev_approval);
log_slow_lock(__lock_t704, "ai_approve:704");
drop(session);
// F-260619-04 P2(方案 B): create_idea source 消息级溯源补全。
@@ -1032,10 +1089,7 @@ pub async fn ai_approve(
let v = conv_id.as_deref().and_then(|cid| {
session.conv_read(cid).and_then(|c| c.messages.last_assistant_message_id())
});
let __hold735 = __lock_t735.elapsed();
if __hold735 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_approve:735 持锁 {:?} (含 lock 等待)", __hold735);
}
log_slow_lock(__lock_t735, "ai_approve:735");
v
};
super::super::audit::maybe_fill_idea_source(
@@ -1062,10 +1116,7 @@ pub async fn ai_approve(
let __lock_t761 = std::time::Instant::now();
let session = state.ai_session.lock().await;
let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0);
let __hold761 = __lock_t761.elapsed();
if __hold761 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_approve:761 持锁 {:?} (含 lock 等待)", __hold761);
}
log_slow_lock(__lock_t761, "ai_approve:761");
v
};
// 所有待审批处理完毕后恢复 agentic 循环(recovered 无 live loop,try_continue 因 generating=false 自然不续)
@@ -1187,8 +1238,7 @@ pub async fn ai_authorize_dir(
approved: false,
conversation_id: rec.conversation_id.clone(),
};
let _ = app.emit("ai-chat-event", ev.clone());
let _ = state.ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app, ev);
return Ok(format!("已处理({})", rec.status));
}
_ => {}
@@ -1196,10 +1246,7 @@ pub async fn ai_authorize_dir(
return Err(format!("未找到路径授权挂起: {}", tool_call_id));
}
};
let __hold864 = __lock_t864.elapsed();
if __hold864 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_authorize_dir:864 持锁 {:?} (含 lock 等待)", __hold864);
}
log_slow_lock(__lock_t864, "ai_authorize_dir:864");
v
};
// 阶段3a 决策层分离:ai_authorize_dir 只消费 kind==Path(路径授权挂起,once/always/deny)。
@@ -1213,10 +1260,7 @@ pub async fn ai_authorize_dir(
let __lock_t878 = std::time::Instant::now();
let mut session = state.ai_session.lock().await;
session.pending_approvals.insert(tool_call_id.clone(), approval);
let __hold878 = __lock_t878.elapsed();
if __hold878 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_authorize_dir:878 持锁 {:?} (含 lock 等待)", __hold878);
}
log_slow_lock(__lock_t878, "ai_authorize_dir:878");
return Err(format!(
"tool_call_id={} 非路径授权挂起(普通审批请用 ai_approve)",
tool_call_id
@@ -1245,27 +1289,20 @@ pub async fn ai_authorize_dir(
if let Some(ref cid) = conv_id {
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &err_msg);
}
let __hold903 = __lock_t903.elapsed();
if __hold903 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_authorize_dir:903 持锁 {:?} (含 lock 等待)", __hold903);
}
log_slow_lock(__lock_t903, "ai_authorize_dir:903");
}
let ev_completed = AiChatEvent::AiToolCallCompleted {
id: tool_call_id.clone(),
result: serde_json::Value::String(err_msg.clone()),
conversation_id: conv_id.clone(),
};
let _ = app.emit("ai-chat-event", ev_completed.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_completed);
emit_ai_chat_event(&app, ev_completed);
let ev_approval = AiChatEvent::AiApprovalResult {
id: tool_call_id.clone(),
approved: false,
conversation_id: conv_id.clone(),
};
let _ = app.emit("ai-chat-event", ev_approval.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_approval);
emit_ai_chat_event(&app, ev_approval);
audit_finalize(&state, &tool_call_id, "rejected", Some(err_msg)).await;
if let Some(ref cid) = conv_id {
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
@@ -1275,10 +1312,7 @@ pub async fn ai_authorize_dir(
let __lock_t930 = std::time::Instant::now();
let session = state.ai_session.lock().await;
let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0);
let __hold930 = __lock_t930.elapsed();
if __hold930 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_authorize_dir:930 持锁 {:?} (含 lock 等待)", __hold930);
}
log_slow_lock(__lock_t930, "ai_authorize_dir:930");
v
};
try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
@@ -1371,27 +1405,20 @@ pub async fn ai_authorize_dir(
if let Some(ref cid) = conv_id {
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &result_val.to_string());
}
let __hold1018 = __lock_t1018.elapsed();
if __hold1018 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_authorize_dir:1018 持锁 {:?} (含 lock 等待)", __hold1018);
}
log_slow_lock(__lock_t1018, "ai_authorize_dir:1018");
}
let ev_completed = AiChatEvent::AiToolCallCompleted {
id: tool_call_id.clone(),
result: result_val.clone(),
conversation_id: conv_id.clone(),
};
let _ = app.emit("ai-chat-event", ev_completed.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_completed);
emit_ai_chat_event(&app, ev_completed);
let ev_approval = AiChatEvent::AiApprovalResult {
id: tool_call_id.clone(),
approved: true,
conversation_id: conv_id.clone(),
};
let _ = app.emit("ai-chat-event", ev_approval.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_approval);
emit_ai_chat_event(&app, ev_approval);
if let Some(ref cid) = conv_id {
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
}
@@ -1400,10 +1427,7 @@ pub async fn ai_authorize_dir(
let __lock_t1044 = std::time::Instant::now();
let session = state.ai_session.lock().await;
let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0);
let __hold1044 = __lock_t1044.elapsed();
if __hold1044 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_authorize_dir:1044 持锁 {:?} (含 lock 等待)", __hold1044);
}
log_slow_lock(__lock_t1044, "ai_authorize_dir:1044");
v
};
try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
@@ -1428,18 +1452,18 @@ pub struct PendingToolCallInfo {
#[tauri::command]
pub async fn ai_pending_tool_calls(
state: State<'_, AppState>,
conv_id: String,
conversation_id: String,
) -> Result<Vec<PendingToolCallInfo>, String> {
let __lock_t1071 = std::time::Instant::now();
let session = state.ai_session.lock().await;
// 阶段3a 单真相源合并:单 pending_approvals 表按 conv_id 过滤,kind 字段透传给前端。
// 阶段3a 单真相源合并:单 pending_approvals 表按 conversation_id 过滤,kind 字段透传给前端。
// 阶段4:返 kind 字段,前端 switchConversation 恢复 pendingApprovals 时按 kind 渲染
// (risk→approve/reject,path→once/always/deny),对齐阶段3b 统一审批模型。
// (阶段3a 时前端仅恢复 kind==Risk 走 toolCard;阶段3b 前端切后 path 类也归一渲染,后端两 kind 都返。)
let list = session
.pending_approvals
.values()
.filter(|a| a.conversation_id.as_deref() == Some(conv_id.as_str()))
.filter(|a| a.conversation_id.as_deref() == Some(conversation_id.as_str()))
.map(|a| PendingToolCallInfo {
tool_call_id: a.tool_call_id.clone(),
conversation_id: a.conversation_id.clone(),
@@ -1449,10 +1473,7 @@ pub async fn ai_pending_tool_calls(
},
})
.collect();
let __hold1071 = __lock_t1071.elapsed();
if __hold1071 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_pending_tool_calls:1071 持锁 {:?} (含 lock 等待)", __hold1071);
}
log_slow_lock(__lock_t1071, "ai_pending_tool_calls:1071");
Ok(list)
}
@@ -1469,7 +1490,7 @@ pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
let __lock_t1095 = std::time::Instant::now();
// ── ① 先停 loop 防并发 save ──
// 取活跃对话 id 后释放锁(避免持 session 锁调 DB)。stop_flag 置位 + notify 唤醒:
// 旧 loop 下一轮边界检测到 stop_flag 即走 finish_round_exit 退出;conv_states→Idle
// 旧 loop 下一轮边界检测到 stop_flag 即走 teardown_round 退出;conv_states→Idle
// 让读侧 is_active 立即归零(Idle 自环幂等,guard.reset 不冲突)。
let active_id = {
let mut session = state.ai_session.lock().await;
@@ -1488,10 +1509,7 @@ pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
);
}
}
let __hold1095 = __lock_t1095.elapsed();
if __hold1095 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_clear:1095 持锁 {:?} (含 lock 等待)", __hold1095);
}
log_slow_lock(__lock_t1095, "ai_chat_clear:1095");
active_id
};
// ── ② DB 单事务清空(clear_messages + delete_range 一次 transaction 原子)──
@@ -1551,9 +1569,7 @@ pub async fn ai_chat_clear_context(
let ev = AiChatEvent::AiContextCleared {
conversation_id: Some(conversation_id.clone()),
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app, ev);
return Ok(());
}
// 对保护区外 active 消息标 archived_segment(messages_mut 直接改 status)。
@@ -1569,10 +1585,7 @@ pub async fn ai_chat_clear_context(
if dirtied {
conv.messages.mark_needs_full_rewrite();
}
let __hold1145 = __lock_t1145.elapsed();
if __hold1145 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_clear_context:1145 持锁 {:?} (含 lock 等待)", __hold1145);
}
log_slow_lock(__lock_t1145, "ai_chat_clear_context:1145");
drop(session);
conversation_id
};
@@ -1581,9 +1594,7 @@ pub async fn ai_chat_clear_context(
let ev = AiChatEvent::AiContextCleared {
conversation_id: Some(conv_id),
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app, ev);
Ok(())
}
@@ -1622,9 +1633,7 @@ pub async fn ai_chat_compress_context(
let ev = AiChatEvent::AiCompressing {
conversation_id: Some(conversation_id.clone()),
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app, ev);
conv.messages.set_compressing(true);
let protect_start = conv.messages.len().saturating_sub(PROTECT_COUNT);
@@ -1638,9 +1647,7 @@ pub async fn ai_chat_compress_context(
conversation_id: cid,
summary: String::new(),
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app, ev);
return Ok(());
}
// 读 active 克隆(不改 status):compress_old_messages 留到 LLM 成功后才调
@@ -1652,10 +1659,7 @@ pub async fn ai_chat_compress_context(
.map(|t| t.message.clone())
.collect();
let cid = session.active_conversation_id.clone().unwrap_or_else(|| conversation_id.clone());
let __hold1205 = __lock_t1205.elapsed();
if __hold1205 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1205 持锁 {:?} (含 lock 等待)", __hold1205);
}
log_slow_lock(__lock_t1205, "ai_chat_compress_context:1205");
(cid, active_msgs, protect_start)
};
@@ -1664,19 +1668,14 @@ pub async fn ai_chat_compress_context(
let __lock_t1250 = std::time::Instant::now();
let mut session = state.ai_session.lock().await;
session.conv(&conv_id).messages.set_compressing(false);
let __hold1250 = __lock_t1250.elapsed();
if __hold1250 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1250 持锁 {:?} (含 lock 等待)", __hold1250);
}
log_slow_lock(__lock_t1250, "ai_chat_compress_context:1250");
drop(session);
// 治 Task#1:手动 IPC 降级(无 active 可压缩)用 AiManualCompressed(前端弹 toast)。
let ev = AiChatEvent::AiManualCompressed {
conversation_id: Some(conv_id),
summary: String::new(),
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app, ev);
return Ok(());
}
@@ -1688,19 +1687,14 @@ pub async fn ai_chat_compress_context(
let __lock_t1269 = std::time::Instant::now();
let mut session = state.ai_session.lock().await;
session.conv(&conv_id).messages.set_compressing(false);
let __hold1269 = __lock_t1269.elapsed();
if __hold1269 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1269 持锁 {:?} (含 lock 等待)", __hold1269);
}
log_slow_lock(__lock_t1269, "ai_chat_compress_context:1269");
drop(session);
let ev = AiChatEvent::AiError {
error: format!("压缩失败: {}", e),
error_type: Some(super::super::ErrorType::Auth),
conversation_id: Some(conv_id),
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app, ev);
return Err(format!("压缩失败: {}", e));
}
};
@@ -1719,19 +1713,14 @@ pub async fn ai_chat_compress_context(
let __lock_t1295 = std::time::Instant::now();
let mut session = state.ai_session.lock().await;
session.conv(&conv_id).messages.set_compressing(false);
let __hold1295 = __lock_t1295.elapsed();
if __hold1295 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1295 持锁 {:?} (含 lock 等待)", __hold1295);
}
log_slow_lock(__lock_t1295, "ai_chat_compress_context:1295");
drop(session);
let ev = AiChatEvent::AiError {
error: format!("压缩失败: {}", e),
error_type: Some(super::super::ErrorType::Unknown),
conversation_id: Some(conv_id),
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app, ev);
return Err(format!("压缩失败: {}", e));
}
};
@@ -1744,10 +1733,7 @@ pub async fn ai_chat_compress_context(
let _compressed = conv.messages.compress_old_messages(compress_end);
conv.messages.insert_at(0, ChatMessage::system(&summary));
conv.messages.set_compressing(false);
let __hold1312 = __lock_t1312.elapsed();
if __hold1312 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1312 持锁 {:?} (含 lock 等待)", __hold1312);
}
log_slow_lock(__lock_t1312, "ai_chat_compress_context:1312");
}
save_conversation(&state.ai_session, &state.db, &conv_id, None, None, false).await;
// 治 Task#1:手动 IPC 成功主路径用 AiManualCompressed(Task#1 期望保留 toast+刷新)。
@@ -1755,9 +1741,7 @@ pub async fn ai_chat_compress_context(
conversation_id: Some(conv_id),
summary,
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app, ev);
Ok(())
}
@@ -1831,8 +1815,10 @@ pub async fn ai_chat_edit(
system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await;
}
}
// Augmentation 注入:edit 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。
let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await;
// Augmentation 注入:edit 回溯末条 user(已被 replace_last_active_user_content 写为新文本)
// 前导 /技能名,复用 parse_leading_skill;@ mention spans 不重放,传 None 走空路径。
let skill = backtrack_leading_skill(&state, &conv_id).await;
let aug_seg = resolve_and_inject(&app, &state, &conv_id, &provider_config, &skill, &None, &lang).await;
if !aug_seg.is_empty() {
if cache_on {
volatile = append_volatile(volatile, &aug_seg);
@@ -2026,10 +2012,7 @@ pub async fn ai_chat_force_send(
// F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。
let user_msg_id = conv.messages.last_user_message_id();
// G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(提取方式同 ai_chat_send)
let __hold1473 = __lock_t1473.elapsed();
if __hold1473 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_force_send:1473 持锁 {:?} (含 lock 等待)", __hold1473);
}
log_slow_lock(__lock_t1473, "ai_chat_force_send:1473");
(was_gen.then_some(target.clone()), target, user_msg_id, conv.pinned_goals.clone(), new_epoch)
};
@@ -2059,9 +2042,7 @@ pub async fn ai_chat_force_send(
conversation_id: Some(cid.clone()),
pinned_goals: old_pinned_goals.clone(),
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app, ev);
}
// system prompt 构建(照 ai_chat_send):augmentation 注入 + 知识注入顺序一致。
@@ -2074,7 +2055,7 @@ pub async fn ai_chat_force_send(
let (mut system_prompt, mut volatile) =
build_system_prompt_parts(&state, &lang, &excl_proj, &excl_task, cache_on).await;
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影(语义同 ai_chat_send)。
let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await;
let aug_seg = resolve_and_inject(&app, &state, &conv_id, &provider_config, &skill, &mention_spans, &lang).await;
if !aug_seg.is_empty() {
if cache_on {
volatile = append_volatile(volatile, &aug_seg);
@@ -2144,10 +2125,7 @@ pub async fn ai_chat_stop(
.unwrap_or_default();
if target.is_empty() {
// 无目标 conv 且无 active:无任何 conv 在跑(批4 顶层 generating 已退役),直接返回。
let __hold1611 = __lock_t1611.elapsed();
if __hold1611 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611);
}
log_slow_lock(__lock_t1611, "ai_chat_stop:1611");
drop(session);
return Ok(());
}
@@ -2178,10 +2156,7 @@ pub async fn ai_chat_stop(
}
conv.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启
let pinned_goals = conv.pinned_goals.clone();
let __hold1611 = __lock_t1611.elapsed();
if __hold1611 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611);
}
log_slow_lock(__lock_t1611, "ai_chat_stop:1611");
drop(session);
let ev = AiChatEvent::AiCompleted {
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
@@ -2192,9 +2167,7 @@ pub async fn ai_chat_stop(
conversation_id: Some(target),
pinned_goals,
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app, ev);
return Ok(());
}
// 流式生成中:置位让目标 conv 的 loop 自行收尾
@@ -2208,10 +2181,7 @@ pub async fn ai_chat_stop(
// Notify 仅承载「即时唤醒」,停止真值仍由 stop_flag 决定(stream_llm 唤醒后再判 flag)。
notify.notify_one();
let conv_id = Some(target);
let __hold1611 = __lock_t1611.elapsed();
if __hold1611 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611);
}
log_slow_lock(__lock_t1611, "ai_chat_stop:1611");
drop(session);
// B-260615-13 兜底任务(F2 加固,2026-08-05):loop 若 panic/异常退出漏发收尾,stop_flag 无人读,
@@ -2302,9 +2272,8 @@ pub async fn ai_chat_stop(
conversation_id: conv_id.clone(),
pinned_goals,
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。闭包内 app_handle 仍可访问 AppState。
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
// 闭包内 app_handle 仍可访问 AppState(双写收敛进 emit_ai_chat_event)。
emit_ai_chat_event(&app_handle, ev);
return;
}
});
@@ -2336,10 +2305,7 @@ pub async fn ai_continue_loop(
// (含切走后的后台 conv),移除 active 一致性校验(真并发下后台 conv 也应可续跑)。
// B-Phase3:读侧切无锁 conv_states。
if !state.conv_states.is_active(&conversation_id) {
let __hold1744 = __lock_t1744.elapsed();
if __hold1744 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_continue_loop:1744 持锁 {:?} (含 lock 等待)", __hold1744);
}
log_slow_lock(__lock_t1744, "ai_continue_loop:1744");
return Err("AI 未在暂停态,无需继续".to_string());
}
let conv = session.conv(&conversation_id);
@@ -2348,10 +2314,7 @@ pub async fn ai_continue_loop(
// F-260616-11 决策 a: 达 max 续跑重计 iteration(F-260616-03 决策 a,用户点继续=授权重来)。
// 审批续跑(ai_approve)累计不重置见另路径;本路径 start_iteration 传 0,loop 从头计数。
conv.iteration_used = 0;
let __hold1744 = __lock_t1744.elapsed();
if __hold1744 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_continue_loop:1744 持锁 {:?} (含 lock 等待)", __hold1744);
}
log_slow_lock(__lock_t1744, "ai_continue_loop:1744");
}
// 复用审批恢复续 loop 入口(不重写 loop),其内部 spawn run_agentic_loop
// F-09 B 批2:传 conv_id(IPC 参数 conversation_id)。
@@ -2381,10 +2344,7 @@ pub async fn ai_stop_loop(
// (真并发下后台 conv 也应可停止)。
// B-Phase3:读侧切无锁 conv_states。
if !state.conv_states.is_active(&conversation_id) {
let __hold1782 = __lock_t1782.elapsed();
if __hold1782 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_stop_loop:1782 持锁 {:?} (含 lock 等待)", __hold1782);
}
log_slow_lock(__lock_t1782, "ai_stop_loop:1782");
return Err("AI 未在暂停态,无需停止".to_string());
}
let conv = session.conv(&conversation_id);
@@ -2399,10 +2359,7 @@ pub async fn ai_stop_loop(
);
}
pinned_goals = conv.pinned_goals.clone();
let __hold1782 = __lock_t1782.elapsed();
if __hold1782 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_stop_loop:1782 持锁 {:?} (含 lock 等待)", __hold1782);
}
log_slow_lock(__lock_t1782, "ai_stop_loop:1782");
}
// 暂停态进入前已 save_conversation,此处零 token 上报仅作收敛信号(与 try_continue 补发 AiCompleted 一致)
let ev = AiChatEvent::AiCompleted {
@@ -2418,9 +2375,7 @@ pub async fn ai_stop_loop(
conversation_id: Some(conversation_id),
pinned_goals,
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
emit_ai_chat_event(&app, ev);
Ok("ok".to_string())
}
+15 -5
View File
@@ -1,8 +1,10 @@
//! skills + config 域 IPC — 5 个 `#[tauri::command]` 函数
//!
//! 原 `commands/ai/commands/mod.rs` 收敛到 re-export only 后,本文件承接最后一批 IPC:
//! - `ai_list_skills` — 列出本机 Claude 技能(skills + commands + plugins),供前端 `/` 联想
//! - `ai_reload_skills` — 热重载技能列表(核心设计6:invalidate + 重扫,不重启生效)
//! - `ai_list_skills` — 列出本机 Claude 技能(project + skills + commands + plugins,可选
//! project_path 启用项目级扫描),供前端 `/` 联想
//! - `ai_reload_skills` — 热重载技能列表(核心设计6:invalidate + 重扫,不重启生效;
//! 项目级按已设 PROJECT_SKILLS_ROOT 一并重扫)
//! - `ai_set_concurrency_config` — LLM 并发上限运行时调整(global/per-conv,立即生效)
//! - `ai_set_agent_max_iterations` — Agentic 循环最大轮次(下次发消息生效)
//! - `ai_set_agent_max_retries` — 流式对话失败自动重试次数(下次发消息生效)
@@ -28,10 +30,18 @@ use df_ai::ai_tools::RiskLevel;
use crate::state::AppState;
/// 列出本机 Claude 技能(skills + commands + plugins 三类),供前端 `/` 联想
/// 列出本机 Claude 技能(project + skills + commands + plugins),供前端 `/` 联想。
///
/// `project_path`(可选):当前项目根目录,Some 时扫 `<project_path>/.claude/skills/*/SKILL.md`
/// (source="project",同名覆盖 home 级)。变更才 invalidate 触发重扫(同值幂等零开销);
/// None/空 = 沿用已设 root 不清除(remote_bridge 零参调用与 FE 无项目语境时不传均安全)。
#[tauri::command]
pub async fn ai_list_skills() -> Result<Vec<SkillInfo>, String> {
// 命中进程内缓存(skills_cached 已 owned Vec,直接返回)
pub async fn ai_list_skills(project_path: Option<String>) -> Result<Vec<SkillInfo>, String> {
// 项目根变更才置缓存 None,skills_cached 慢路径重扫(含新项目级技能);
// 命中进程内缓存时零扫盘(skills_cached 已 owned Vec,直接返回)。
if super::super::skills::set_project_skills_root(project_path) {
invalidate_skills();
}
Ok(skills_cached().await)
}
+19
View File
@@ -36,6 +36,7 @@
//! (仅 `WorkflowEvent`),本总线是面向 AI 域的通用 pub-sub(透传 AiChatEvent 全量 Value)。
//! AppState 分两个字段:`event_bus`(工作流)+ `ai_event_bus`(AI 域),互不干扰。
use tauri::{AppHandle, Emitter, Manager};
use tokio::sync::broadcast;
use super::AiChatEvent;
@@ -69,6 +70,24 @@ pub const EVENT_BUS_ENABLED: bool = true;
#[allow(dead_code)]
pub const DEFAULT_BUS_CAPACITY: usize = 256;
/// emit+publish 双写:AI 事件对外发布的唯一出口(2026-10-01 DRY #1 收敛,正典自 stream_recv.rs)。
///
/// 两路通道受众不同,缺一不可:
/// - `app.emit("ai-chat-event")`:桌面前端(前端只 listen 这一路)
/// - `ai_event_bus.publish_event`:总线订阅者(tunnel → miniapp 跨端透传 / 跨模块订阅)
///
/// **为什么必须双写**(BUG-260805-02 教训):桌面前端只 listen `app.emit('ai-chat-event')`,
/// 只 publish_event 桌面前端收不到(事故:微信端发的 user 消息桌面不可见);反之只 emit 不
/// publish 则 tunnel/miniapp 订阅者收不到。**禁止**为省事手抄单路 emit 或单路 publish 替代本 helper,
/// 也**禁止**再手抄两行式(emit + publish)——历史 50+ 处手抄已漂移出 error_type 两端不一致等 bug。
///
/// 事件值语义:emit 与 publish 收到**同一份事件值**(内部 clone),对齐原手抄 `ev.clone()` 两行式。
/// 消费 ev(调用方无需再 clone)。
pub(crate) fn emit_ai_chat_event(app: &AppHandle, ev: AiChatEvent) {
let _ = app.emit("ai-chat-event", ev.clone());
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
}
// ============================================================
// EventBus — 基于 tokio::sync::broadcast 的 pub-sub 总线(载荷 serde_json::Value)
//
+10 -9
View File
@@ -224,7 +224,7 @@ async fn fetch_static(
markdown = cleanup_markdown(&markdown);
// ── 截断(按 char 边界) ──
let (markdown, truncated) = truncate_chars(&markdown, max_length);
let (markdown, truncated) = truncate_markdown(&markdown, max_length);
Ok(json!({
"url": final_url,
@@ -481,7 +481,7 @@ fn build_obscura_json(
) -> Value {
let html_bytes_est = markdown_raw.len(); // obscura 不给原始 HTML 字节数,用 markdown 长度近似(仅信息字段)
// 截断(按 char 边界,与静态一致)
let (markdown, truncated) = truncate_chars(markdown_raw, max_length);
let (markdown, truncated) = truncate_markdown(markdown_raw, max_length);
let elapsed_ms = started.elapsed().as_millis() as u64;
let title = extract_title_from_markdown(&markdown);
// scheme:obscura 路径下重新 validate_url 拿 scheme(已在入口校验过,这里仅取字段值,失败兜底 https)。
@@ -652,7 +652,8 @@ fn cleanup_markdown(md: &str) -> String {
/// 按 char 边界截断 markdown。返回 (截断后 String, 是否截断)。
/// 不切多字节 UTF-8 中间(char_indices 保证落在 char 边界)。
fn truncate_chars(md: &str, max: usize) -> (String, bool) {
/// 与 audit 的 `truncate_chars`(补 … 正典)语义不同,改名消同名陷阱(DRY #11)。
fn truncate_markdown(md: &str, max: usize) -> (String, bool) {
let char_count = md.chars().count();
if char_count <= max {
return (md.to_string(), false);
@@ -678,7 +679,7 @@ fn truncate_chars(md: &str, max: usize) -> (String, bool) {
// ============================================================
// 单元测试
//
// 覆盖纯函数(extract_title / cleanup_markdown / truncate_chars),确定性零网络。
// 覆盖纯函数(extract_title / cleanup_markdown / truncate_markdown),确定性零网络。
// handler 集成层走真实网络 #[ignore](对齐 http.rs 测试策略)。
// ============================================================
@@ -792,12 +793,12 @@ mod tests {
assert_eq!(extract_title_from_markdown(" \n\n "), None);
}
// ── truncate_chars ──
// ── truncate_markdown ──
#[test]
fn test_truncate_under_limit_unchanged() {
let md = "short content";
let (out, trunc) = truncate_chars(md, 1000);
let (out, trunc) = truncate_markdown(md, 1000);
assert!(!trunc);
assert_eq!(out, "short content");
}
@@ -805,7 +806,7 @@ mod tests {
#[test]
fn test_truncate_over_limit_marks_truncated() {
let md = "abcdefghij".repeat(1000); // 10000 chars
let (out, trunc) = truncate_chars(&md, 100);
let (out, trunc) = truncate_markdown(&md, 100);
assert!(trunc);
assert!(out.contains("[markdown 已截断"));
assert!(out.contains("原文 10000 chars"));
@@ -816,7 +817,7 @@ mod tests {
fn test_truncate_respects_char_boundary_unicode() {
// 中文每字 3 字节,截 5 char 应得 15 字节(不在中间切)
let md = "你好世界测试内容"; // 8 chars,每字 3 字节
let (out, trunc) = truncate_chars(md, 5);
let (out, trunc) = truncate_markdown(md, 5);
assert!(trunc);
// 截断点应在 "你好世界测" 后(5 chars = 15 bytes),无半个字
assert!(out.starts_with("你好世界测\n\n"));
@@ -961,7 +962,7 @@ mod tests {
#[test]
fn test_build_obscura_json_truncation_applies() {
// build_obscura_json 内部应走 truncate_chars:超长 markdown 截断标记
// build_obscura_json 内部应走 truncate_markdown:超长 markdown 截断标记
let md: String = "abcdefghij".repeat(2000); // 20000 chars
let started = Instant::now();
let v = build_obscura_json("https://example.com/", &md, 1000, started, "obscura", None);
+5 -3
View File
@@ -49,6 +49,7 @@ use df_storage::db::Database;
use df_storage::models::AiProviderRecord;
use df_storage::secret::resolve_provider_secret;
use crate::commands::ai::audit::truncate_chars;
use crate::commands::ai::http::{
build_client, execute_with_redirects, resolve_and_check_host, validate_url, MAX_REDIRECTS,
};
@@ -181,9 +182,9 @@ pub(crate) async fn execute_generate_image(
.map_err(|e| anyhow::anyhow!("调用图像生成端点失败 ({}): {}", endpoint, e))?;
let status = resp.status();
if !status.is_success() {
// 错误响应读 body 摘要(截断 500 chars)帮助定位
// 错误响应读 body 摘要(截断 500 chars,截断时尾部补 …)帮助定位
let err_text = resp.text().await.unwrap_or_default();
let snippet: String = err_text.chars().take(500).collect();
let snippet: String = truncate_chars(&err_text, 500);
anyhow::bail!(
"图像生成端点返回 HTTP {}: {}",
status.as_u16(),
@@ -455,9 +456,10 @@ async fn write_atomic(output_path: &str, data: &[u8]) -> anyhow::Result<()> {
}
/// 响应 body 截断为最多 500 chars 的字符串(用于错误信息),供定位用。
/// 走 audit truncate_chars 正典(截断时尾部补 …);错误文案无硬长度约束,允许多 1 字符。
fn body_snippet(v: &Value) -> String {
let s = v.to_string();
s.chars().take(500).collect()
truncate_chars(&s, 500)
}
// ============================================================
+8 -19
View File
@@ -19,8 +19,9 @@ use df_storage::models::{AiProviderRecord, KnowledgeRecord};
use df_types::types::new_id;
use crate::state::{AppState, ExtractTrigger, LlmConcurrency};
use crate::state::{AppState, ExtractTrigger, LlmConcurrency, log_slow_lock};
use crate::commands::ai::audit::truncate_chars;
use crate::commands::err_str;
use super::{AiSession};
@@ -560,8 +561,8 @@ pub(crate) async fn build_knowledge_context(
for r in &results {
let kind = r.kind.clone();
let title = r.title.clone();
// 截断 content 防膨胀(注入侧最多 500 字符)
let snippet: String = r.content.chars().take(500).collect();
// 截断 content 防膨胀(注入侧最多 500 字符;走 audit 正典,截断时尾部补 … 提示有省略)
let snippet: String = truncate_chars(&r.content, 500);
out.push_str(&format!("- [{}] {}: {} (复用 {} 次)\n", kind, title, snippet, r.reuse_count));
}
out
@@ -588,10 +589,7 @@ pub(crate) async fn build_knowledge_context_for_conv(
.iter()
.rev()
.find(|m| matches!(m.role, MessageRole::User) && m.is_active());
let __hold = __lock_t.elapsed();
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] build_knowledge_context_for_conv 持锁 {:?} (含 lock 等待)", __hold);
}
log_slow_lock(__lock_t, "build_knowledge_context_for_conv:582");
match found {
Some(m) => (m.content.clone(), m.id.clone()),
None => (String::new(), None),
@@ -688,10 +686,7 @@ pub(crate) async fn maybe_spawn_extraction(
}
};
if blocked {
let __hold = __lock_t.elapsed();
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] maybe_spawn_extraction 持锁 {:?} (含 lock 等待)", __hold);
}
log_slow_lock(__lock_t, "maybe_spawn_extraction:692");
tracing::debug!(
conv_id = %conv_id,
"[knowledge] 跳过自动提炼:提炼中或距上次提炼新增用户消息不足 {} 条(如需立即重提炼用手动按钮)",
@@ -701,10 +696,7 @@ pub(crate) async fn maybe_spawn_extraction(
}
// 守卫 2:消息数。conv_read 未建返 0(< min_messages 自然跳过,语义=空对话不注入知识)。
if msg_count < config.min_messages as usize {
let __hold = __lock_t.elapsed();
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] maybe_spawn_extraction 持锁 {:?} (含 lock 等待)", __hold);
}
log_slow_lock(__lock_t, "maybe_spawn_extraction:705");
return Ok(());
}
// 原子预置位(TOCTOU 核心):释放锁前先占提炼槽位,杜绝并发窗口内重复 spawn。
@@ -713,10 +705,7 @@ pub(crate) async fn maybe_spawn_extraction(
.lock()
.expect("提炼水位互斥锁中毒")
.insert(conv_id.to_string(), ExtractWatermark::InFlight);
let __hold = __lock_t.elapsed();
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] maybe_spawn_extraction 持锁 {:?} (含 lock 等待)", __hold);
}
log_slow_lock(__lock_t, "maybe_spawn_extraction:717");
}
let session_arc = session_arc.clone();
+7
View File
@@ -143,6 +143,13 @@ pub enum AiChatEvent {
/// 与 AiToolCallStarted/Completed 正交——这三个事件描述一次信任放行调用生命周期:
/// Started → AutoApproved(toast 提示用户已自动放行)→ Completed。
AiToolAutoApproved { id: String, tool: String, dir: String, conversation_id: Option<String> },
/// 技能注入反馈闭环(/ 技能 resolve 成功/失败通知,前端轻 toast)。
///
/// 触发:chat.rs `resolve_and_inject` 聚合点(SkillResolver 成功入 augs / 失败收集进
/// errors,均在此可见)双写 app.emit + ai_event_bus。`ok=false` 涵盖 skills.rs
/// read_skill_content_stripped 的两类失败(缓存未命中 / 文件读失败,均归 NotFound)。
/// 前端 AiChat.vue 过滤本变体弹「已注入技能《name》」/「技能 name 未找到」。
AiSkillInjected { name: String, ok: bool, conversation_id: Option<String> },
/// 需要人工审批
/// diff(AE-2025-03,路径 B):仅 write_file 注入(旧文件 vs 新内容行级 unified diff),
/// 供审批卡即时预览。旧文件不存在(新建)或非 write_file 工具为 None。
+9 -8
View File
@@ -7,7 +7,7 @@ use df_storage::db::Database;
use df_storage::models::AiProviderRecord;
use crate::commands::err_str;
use crate::state::AppState;
use crate::state::{AppState, SettingsNorm};
/// 获取当前活跃提供商配置
pub(crate) async fn get_active_provider(state: &AppState) -> Result<AiProviderRecord, String> {
@@ -486,13 +486,14 @@ async fn build_context_lists(
/// 自定义提示词段(设置 custom_prompt,追加到 system prompt 末尾)。无/空返回空串。
async fn build_custom_section(state: &AppState) -> String {
if let Ok(Some(custom)) = state.settings.get("custom_prompt").await {
if !custom.is_empty() {
let clean = custom.trim().trim_matches('"');
if !clean.is_empty() {
return format!("\n## 自定义指令\n{}\n", clean);
}
}
// get_string_norm 归一去引号(前端 JSON.stringify 落库带引号),再 trim 空白;空返回空段。
let clean = state
.settings
.get_string_norm("custom_prompt")
.await
.unwrap_or_default();
if !clean.trim().is_empty() {
return format!("\n## 自定义指令\n{}\n", clean.trim());
}
String::new()
}
+12 -13
View File
@@ -65,8 +65,8 @@ use crate::state::AppState;
use crate::commands::ai::{
ai_approve, ai_authorize_dir, ai_chat_clear_context, ai_chat_compress_context, ai_chat_edit,
ai_chat_force_send, ai_chat_send, ai_chat_stop, ai_conversation_rename, ai_continue_loop,
ai_list_skills, ai_regenerate, ai_stop_loop, record_to_message, AiChatEvent, ApprovalKind,
ConvSummary, ModelInfo, PendingApproval,
ai_list_skills, ai_regenerate, ai_stop_loop, event_bus::emit_ai_chat_event,
record_to_message, AiChatEvent, ApprovalKind, ConvSummary, ModelInfo, PendingApproval,
};
// list_models 路由:get_active_provider(prompt.rs 已 pub(crate))读活跃 provider。
// 不走 commands 模块(provider 获取是 prompt 子模块职责,对齐 chat.rs 同源调用路径)。
@@ -581,8 +581,8 @@ async fn route_sync_pending(state: &State<'_, AppState>, conversation_id: String
/// `list_skills` 路由 —— 读本机 Claude 技能进程内缓存,跨端推回 miniapp。
///
/// 流程:
/// 1. `ai_list_skills().await`(零参,直接读 `skills_cached()` 进程内缓存,不重复扫盘)
/// → `Vec<SkillInfo>`(已按 skills>commands>plugins 优先级去重)
/// 1. `ai_list_skills(None).await`(直接读 `skills_cached()` 进程内缓存,不重复扫盘)
/// → `Vec<SkillInfo>`(已按 project>skills>commands>plugins 优先级去重)
/// 2. `state.ai_event_bus.publish_event(AiSkillList)` 跨端透传
/// (经 EventBus→tunnel subscriber→relay→miniapp,非 app.emit 仅桌面前端)
///
@@ -591,10 +591,11 @@ async fn route_sync_pending(state: &State<'_, AppState>, conversation_id: String
/// 错误兜底:ai_list_skills 返 Err(skills_cached 内部理论上不失败,但签名返 Result 兜底)
/// → publish 空 Vec(对齐 route_list_conversations 失败推空列表语义)。
///
/// 注:ai_list_skills 是 `#[tauri::command]` 零参函数(不需要 app/state),此处直接 async 调,
/// 注:ai_list_skills 是 `#[tauri::command]` 函数(不需要 app/state),此处直接 async 调,
/// 不经 IPC invoke(对齐 route_send_message 调 ai_chat_send 的直接调用模式)。
async fn route_list_skills(state: &State<'_, AppState>) {
let skills: Vec<SkillInfo> = match ai_list_skills().await {
// project_path 传 None:沿用已设项目根(桌面端 ai_list_skills 已设则项目级技能一并透传)
let skills: Vec<SkillInfo> = match ai_list_skills(None).await {
Ok(s) => s,
Err(e) => {
tracing::warn!(error = %e, "[remote_bridge] list_skills 读技能缓存失败,推送空列表");
@@ -840,16 +841,15 @@ async fn route_send_message(app: &AppHandle, state: &State<'_, AppState>, args:
// 注:publish 走事件通道,device 端不会把此事件当入站命令回灌(命令/事件分轨)。
// 前端 handleEvent 去重防双气泡(末条已是同 content user 跳过)。
// BUG-260805-02 双写修复:桌面端只 listen app.emit('ai-chat-event'),publish_event(ai_event_bus)
// 桌面前端收不到 → 微信 user 消息桌面不可见。补 emit 双写(桌面收),保留 publish(微信自回灌去重 +
// tunnel 透传其他 miniapp 订阅者)。
// 桌面前端收不到 → 微信 user 消息桌面不可见。emit+publish 双写两行式已收敛为
// emit_ai_chat_event(2026-10-01 DRY,两通道各收同一份事件值,语义不变)。
let user_msg_ev = AiChatEvent::AiUserMessage {
message: message.clone(),
// 用 resolve 后真实 conv_id(新会话是强制新建 id,旧会话是入参/active),
// 桌面端才能正确归属用户气泡到会话(此前广播原始 null 致无法归属)。
conversation_id: resolved_conv_id.clone(),
};
let _ = app.emit("ai-chat-event", user_msg_ev.clone());
let _ = state.ai_event_bus.publish_event(user_msg_ev);
emit_ai_chat_event(app, user_msg_ev);
let result = ai_chat_send(
app.clone(),
state.clone(),
@@ -924,13 +924,12 @@ async fn route_force_send(app: &AppHandle, state: &State<'_, AppState>, args: Va
// 广播 AiUserMessage(同 route_send_message:force_send 追加新 user 消息,
// 桌面端需据此补 user 气泡,防孤立 assistant 响应)。
// BUG-260805-02 双写:桌面端只 listen app.emit,补 emit 让桌面收到(对齐 route_send_message)。
// BUG-260805-02 双写:emit+publish 两行式已收敛 emit_ai_chat_event(对齐 route_send_message)。
let user_msg_ev = AiChatEvent::AiUserMessage {
message: message.clone(),
conversation_id: conversation_id.clone(),
};
let _ = app.emit("ai-chat-event", user_msg_ev.clone());
let _ = state.ai_event_bus.publish_event(user_msg_ev);
emit_ai_chat_event(app, user_msg_ev);
let result = ai_chat_force_send(
app.clone(),
state.clone(),
+71 -6
View File
@@ -6,7 +6,8 @@
//! - `OnceLock<Vec>` → `RwLock<Option<Vec>>` + 快路径(读锁命中 clone)/ 慢路径(重扫)
//! + `invalidate_skills` 写锁置 None,支持进程内 `ai_reload_skills` 热重载
//! - `scan_skills` 返 `ScanResult{skills, conflicts}`:同名收集所有 path 入 conflicts,
//! 保留首份(优先级 skills>commands>plugins);`SkillInfo.duplicates` 仅冲突时填。
//! 保留首份(优先级 project>skills>commands>plugins,项目级经 `ai_list_skills(project_path)`
//! 设 `PROJECT_SKILLS_ROOT` 后生效);`SkillInfo.duplicates` 仅冲突时填。
use std::collections::HashSet;
use std::fs;
@@ -15,6 +16,8 @@ use std::sync::{RwLock, RwLockReadGuard};
use serde::Serialize;
use crate::commands::ai::tool_registry::{is_noise_entry, NoiseDirTier};
/// 技能元信息(前端 `/` 联想 + 后端注入用)
#[derive(Debug, Clone, Serialize)]
pub struct SkillInfo {
@@ -51,6 +54,58 @@ fn claude_home() -> Option<PathBuf> {
.map(|h| h.join(".claude"))
}
/// 项目级技能根目录(进程内,前端经 `ai_list_skills(project_path)` 设置)。
///
/// Some(root) 时 `scan_skills` 额外扫 `<root>/.claude/skills/*/SKILL.md`(source="project"),
/// 与 home 级三类一起参与去重/冲突;项目级优先级最高(同名覆盖 home,对齐 Claude Code
/// 项目技能语义)。None = 仅 home 级(与旧行为一致)。
/// 更新入口 `set_project_skills_root`(变更才触发重扫),`ai_list_skills` IPC 调用。
static PROJECT_SKILLS_ROOT: RwLock<Option<PathBuf>> = RwLock::new(None);
/// 更新项目级技能根目录。返回是否发生变更(调用方据 true 调 `invalidate_skills` 触发重扫)。
///
/// `None`/空串 = 不改动当前值(`ai_list_skills` 不传参沿用已设 root,remote_bridge 零参
/// 调用不受影响);重复传同值幂等(不触发重扫)。
pub(crate) fn set_project_skills_root(path: Option<String>) -> bool {
let new_root = path
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(PathBuf::from);
// 锁中毒:写不进就当"无变更"返回(下次 IPC 重试自愈,不 panic)。
let mut g = match RwLock::write(&PROJECT_SKILLS_ROOT) {
Ok(g) => g,
Err(_) => {
tracing::error!("PROJECT_SKILLS_ROOT 写锁中毒,set_project_skills_root 跳过");
return false;
}
};
if *g == new_root {
return false;
}
*g = new_root;
true
}
/// 读当前项目根快照(spawn_blocking 的 scan_skills 内同步读,clone 出避免持锁跨扫盘)。
fn project_skills_root_snapshot() -> Option<PathBuf> {
// 锁中毒降级 None(仅丢项目级技能,home 级不受影响)。
RwLock::read(&PROJECT_SKILLS_ROOT)
.ok()
.and_then(|g| g.clone())
}
/// 扫描项目级技能目录 `<root>/.claude/skills/*/SKILL.md`(单层,与 home skills 同构)。
fn scan_project_skills(root: &Path, out: &mut Vec<SkillInfo>) {
if let Ok(entries) = fs::read_dir(root.join(".claude").join("skills")) {
for entry in entries.flatten() {
if let Some(info) = parse_skill_file(&entry.path().join("SKILL.md"), "project") {
out.push(info);
}
}
}
}
/// 剥离 markdown 首个 `---...---` frontmatter 块,返回正文。
///
/// 状态机:用行索引推进;首个 `---`(trim 后)进入 in_fm,遇到下个 `---` 退出,余为正文。
@@ -159,9 +214,9 @@ fn collect_skill_files(dir: &Path, out: &mut Vec<PathBuf>) {
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() {
// 跳过依赖/版本目录,避免递归爆炸
// 跳过依赖/VCS 目录,避免递归爆炸(SkillScan 最小集档,名单统一走 is_noise_entry)
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name == "node_modules" || name == ".git" {
if is_noise_entry(name, NoiseDirTier::SkillScan) {
continue;
}
collect_skill_files(&p, out);
@@ -172,7 +227,10 @@ fn collect_skill_files(dir: &Path, out: &mut Vec<PathBuf>) {
}
}
/// 扫描三类来源,按 name 去重(skills 优先 > commands > plugins)。
/// 扫描全部来源,按 name 去重(project 优先 > skills > commands > plugins)。
///
/// 项目级:`PROJECT_SKILLS_ROOT` 已设时额外扫 `<root>/.claude/skills/*/SKILL.md`
/// (source="project"),排在首位(项目级同名覆盖 home 级,对齐 Claude Code 项目技能语义)。
///
/// 返 `ScanResult`:`skills` 为去重后首份(优先级保留),`conflicts` 收集所有同名 path
/// (仅当同名 > 1 时入列,前端提示用)。
@@ -187,8 +245,15 @@ fn scan_skills() -> ScanResult {
}
};
// 三类来源按优先级顺序收集(skills > commands > plugins)
let mut all: Vec<Vec<SkillInfo>> = Vec::with_capacity(3);
// 各来源按优先级顺序收集(project > skills > commands > plugins)
let mut all: Vec<Vec<SkillInfo>> = Vec::with_capacity(4);
// 0. <project>/.claude/skills/*/SKILL.md(项目级,PROJECT_SKILLS_ROOT 已设才扫)
let mut batch_project = Vec::new();
if let Some(root) = project_skills_root_snapshot() {
scan_project_skills(&root, &mut batch_project);
}
all.push(batch_project);
// 1. ~/.claude/skills/*/SKILL.md
let mut batch_skill = Vec::new();
+2 -7
View File
@@ -5,11 +5,12 @@ use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager};
use tauri::AppHandle;
use tracing::warn;
use df_ai::provider::{CompletionRequest, LlmProvider};
use super::event_bus::emit_ai_chat_event;
use super::{AiChatEvent, ToolCallDraft};
/// 从 anyhow 错误中尽力提取 HTTP 状态码/错误分类,供诊断拼接。
@@ -148,12 +149,6 @@ fn accumulate_tool_calls(
}
}
/// 双写 emit: Tauri 事件 + ai_event_bus 总线(跨端透传)。消费 ev。
fn emit_ai_chat_event(app_handle: &AppHandle, ev: AiChatEvent) {
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
}
/// flush 合批的 AiTextDelta(双写 emit + event_bus)。空 buffer 不 emit。
fn flush_delta(app_handle: &AppHandle, conv_id: &str, pending_delta: &mut String) {
if pending_delta.is_empty() {
+352 -111
View File
@@ -526,9 +526,19 @@ pub fn build_ai_tool_registry(
register_generate_image_tool(&mut registry, db, allowed_dirs);
register_get_app_config_tool(&mut registry, get_app_config_ctx);
register_local_proxy_tools(&mut registry, local_proxy_ctx);
register_read_skill_tool(&mut registry);
registry
}
/// read_skill AI 工具注册(1 个:按名读本机 Claude 技能规程正文,只读)— thin 委托。
///
/// 工具职责:LLM 主动调 read_skill(name) 获取技能 SKILL.md 剥 frontmatter 正文,
/// 与 / 技能注入链共用 skills.rs 进程内缓存(同一真相源)。未命中返回明确错误文案。
/// 风险:Low(纯只读)。声明式注册在 tools/read_skill.rs。
fn register_read_skill_tool(registry: &mut AiToolRegistry) {
super::tools::read_skill::register(registry);
}
/// 网络层 AI 工具注册(1 个:http_request)— 结构化 HTTP(GET/POST/PUT/PATCH/DELETE)。
/// 不持 db,纯 reqwest 调用。SSRF 防护 + 重定向每跳校验见 commands/ai/http.rs。
/// 风险:GET=Medium(只读但触发外发)/ 写方法=High(须人工批准,对齐 audit.rs risk 分流)。
@@ -869,7 +879,7 @@ pub(crate) fn list_dir_recursive<'a>(
}
let name = entry.file_name().to_string_lossy().to_string();
// 噪音目录(.git/node_modules/target 等)既不深入、也不作 entry 返回(避免污染 AI 工具上下文)
if skip_noise && is_noise_dir(&name) {
if skip_noise && is_noise_entry(&name, NoiseDirTier::AiContext) {
continue;
}
// 临时文件(.bak/.tmp-write 等)也不返回:write_file 原子写过程的副产物,
@@ -889,7 +899,7 @@ pub(crate) fn list_dir_recursive<'a>(
"depth": depth,
}));
// 仅递归真实目录(symlink 不递归,防符号链接逃逸到 workspace 外)
if recursive && is_dir && depth < max_depth && !(skip_noise && is_noise_dir(&name)) {
if recursive && is_dir && depth < max_depth && !(skip_noise && is_noise_entry(&name, NoiseDirTier::AiContext)) {
let child_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned();
let truncated = list_dir_recursive(&child_path, true, depth + 1, max_depth, max_entries, skip_noise, result).await?;
if truncated {
@@ -906,72 +916,85 @@ pub(crate) fn list_dir_recursive<'a>(
/// 返回 Option<(&str, &[&str])>:(动作前缀模板, 关键参数名列表)。
/// 模板中 `{}` 占位符由 build_approval_reason 按参数列表从 args 取值填充。
/// 未注册的工具返回 None,由调用方 fallback 到通用风险提示。
///
/// DRY 债 #5 收敛:模板不再本文件手写 match,改查注册元数据(approval_hint 维度),
/// 模板在 declare_tool! 注册点(tools/project.rs 等)声明。
pub(crate) fn display_hint_for_tool(name: &str) -> Option<(&'static str, &'static [&'static str])> {
let (template, keys): (&str, &[&str]) = match name {
"delete_project" => ("删除项目{}", &["id"]),
"restore_project" => ("从回收站恢复项目{}", &["id"]),
"purge_project" => ("永久删除项目及关联数据,不可恢复{}", &["id"]),
"update_project" => ("修改项目{}字段「{}」", &["id", "field"]),
"bind_directory" => ("绑定目录:{}(项目{})", &["path", "id"]),
"create_task" => ("创建任务:{}(项目{})", &["title", "project_id"]),
"create_project" => ("创建项目:「{}」", &["name"]),
"create_idea" => ("捕获灵感:{}", &["title"]),
"patch_file" => ("修改文件:{}", &["path"]),
"delete_file" => ("删除文件:{}({})", &["path", "permanent"]),
"rename_file" => ("重命名/移动:{} → {}", &["from", "to"]),
// F-03 收口:推进链工具审批文案(advance_task/run_workflow)
"advance_task" => ("推进任务状态:{} → {}", &["id", "target_status"]),
"run_workflow" => ("触发工作流:任务{} 推进到 {}", &["task_id", "target_status"]),
// http_request 审批文案:method + url(看是什么请求打到哪)
"http_request" => ("HTTP 请求:{} {}", &["method", "url"]),
_ => return None,
};
Some((template, keys))
ensure_tool_meta_seeded();
df_ai::ai_tools::approval_hint(name)
}
/// 工具名 → 中文动作前缀(轻量映射,供 build_approval_reason 快速取可读标签)。
///
/// 仅返回动作动词+对象名(如 "创建任务"、"写入文件"),不含参数细节。
/// 未注册的工具返回 None,由调用方 fallback 到 display_hint_for_tool 模板填充或硬编码。
///
/// DRY 债 #5 收敛:标签不再本文件手写 match,改查注册元数据(display_verb 维度)。
/// advance_task 刻意无标签:轻量命中会丢 target_status 详情,审批文案须走模板,与旧表一致。
pub(crate) fn tool_display_hint(name: &str) -> Option<&'static str> {
match name {
"create_task" => Some("创建任务"),
"write_file" => Some("写入文件"),
"read_file" => Some("读取文件"),
"list_directory" => Some("列出目录"),
"search_files" => Some("搜索文件"),
"grep" => Some("搜索代码内容"),
"create_project" => Some("创建项目"),
"update_project" => Some("修改项目"),
"delete_project" => Some("删除项目"),
"restore_project" => Some("恢复项目"),
"purge_project" => Some("永久删除项目"),
"bind_directory" => Some("绑定目录"),
"create_idea" => Some("捕获灵感"),
"update_task" => Some("修改任务"),
// advance_task 不在此轻量映射:让 build_approval_reason 走 display_hint_for_tool
// 模板(含 target_status 详情),否则轻量命中"推进任务状态"丢失目标态信息
"run_workflow" => Some("触发工作流"),
"delete_task" => Some("删除任务"),
"update_idea" => Some("修改灵感"),
"delete_idea" => Some("删除灵感"),
"patch_file" => Some("修改文件"),
"append_file" => Some("追加写入"),
"delete_file" => Some("删除文件"),
"rename_file" => Some("重命名/移动"),
"http_request" => Some("发起 HTTP 请求"),
_ => None,
}
ensure_tool_meta_seeded();
df_ai::ai_tools::display_verb(name)
}
/// 判断是否为不应深入递归的噪音目录(构建产物/依赖/缓存等)
pub(crate) fn is_noise_dir(name: &str) -> bool {
const NOISE_DIRS: &[&str] = &[
/// 元数据种子(仅测试构建):元数据查询依赖注册期写入的全局表。
///
/// 生产 build_ai_tool_registry 先于任何查询执行,表必非空,本函数恒空操作;
/// 单测可能不经 build 直接调消费方自由函数(extract_file_tool_paths/tool_call_signature 等),
/// 表尚空时用 in-memory db 构建一次真实注册表,让 declare_tool! 注册点把元数据写入全局表。
/// Once 保证并发测试只种一次;表已非空(任一测试已 build)则跳过。
#[cfg(test)]
pub(crate) fn ensure_tool_meta_seeded() {
static SEED: std::sync::Once = std::sync::Once::new();
SEED.call_once(|| {
if !df_ai::ai_tools::meta_table_is_empty() {
return;
}
let rt = tokio::runtime::Runtime::new().expect("元数据种子 runtime");
rt.block_on(async {
let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败"));
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
let _ = build_ai_tool_registry(
&db,
&allowed_dirs,
PathBuf::from(""),
make_test_get_app_config_ctx(&db),
make_test_local_proxy_ctx(),
);
});
});
}
/// 生产恒空(注册先于查询,无种子需求)。
#[cfg(not(test))]
pub(crate) fn ensure_tool_meta_seeded() {}
/// 噪音目录判定档位:不同消费面"哪些目录算噪音"的口径不同,差异用档位在此显式表达,
/// 各消费方不再自持名单(DRY #4:全仓唯一名单入口)。
pub(crate) enum NoiseDirTier {
/// AI 工具上下文(list_directory/grep 递归):全量名单,构建产物/依赖/缓存/IDE 元数据/回收站不进上下文
AiContext,
/// 前端文件树/工程扫描:全量名单放行 `build` 与 `venv`(文件树面向用户浏览要见构建产物;
/// venv 历史上未过滤,行为保持)。点开头目录(.git/.vite 等)由调用方的隐藏文件过滤兜住
FileTree,
/// 技能扫描递归剪枝:最小集(仅依赖/VCS)。扩大名单属行为变更(build/ 下若有 SKILL.md 会漏扫)
SkillScan,
}
/// 判断是否为不应深入递归/展示的噪音条目(目录/文件名皆可传入,构建产物/依赖/缓存等)。
/// 全仓唯一名单入口。命中判定短路返回,技能扫描等热路径可放心逐条目调用。
pub(crate) fn is_noise_entry(name: &str, tier: NoiseDirTier) -> bool {
const FULL: &[&str] = &[
".git", "node_modules", "target", "dist", "build",
".next", ".cache", "__pycache__", ".venv", "venv", ".idea",
".trash", // delete_file 软删除回收站,不污染 list_directory
];
NOISE_DIRS.contains(&name)
match tier {
NoiseDirTier::AiContext => FULL.contains(&name),
// 文件树口径 = 全量名单放行 build(用户要看构建产物)与 venv(历史未过滤,行为保持)
NoiseDirTier::FileTree => FULL.contains(&name) && !matches!(name, "build" | "venv"),
// 技能扫描最小集:只防依赖/VCS 递归爆炸,与历史两项一致
NoiseDirTier::SkillScan => matches!(name, "node_modules" | ".git"),
}
}
/// 判断是否为不应返回给 AI 的噪音文件(临时/备份/编辑器产物)。
@@ -1062,7 +1085,7 @@ pub(crate) fn compile_glob_to_regex(glob: &str) -> anyhow::Result<regex::Regex>
/// 递归跨文件内容搜索(grep -rn 模式)
///
/// 遍历 `dir`(深度 `depth`,上限 `max_depth`)下所有文件:
/// - 跳过噪音目录(is_noise_dir)/噪音文件(is_noise_file)/symlink(file_type 不跟随)
/// - 跳过噪音目录(is_noise_entry)/噪音文件(is_noise_file)/symlink(file_type 不跟随)
/// - glob 过滤器(可选):命中文件名才读内容
/// - 二进制文件跳过:前 8KB 含 \0 视为二进制(对齐 read_file/list_directory)
/// - 单文件 1MB 上限跳过(对齐 read_file,防读超大文件撑爆)
@@ -1110,7 +1133,7 @@ pub(crate) fn grep_recursive<'a>(
}
if file_type.is_dir() {
// 噪音目录(.git/node_modules/target/.trash 等)不深入
if is_noise_dir(&file_name) {
if is_noise_entry(&file_name, NoiseDirTier::AiContext) {
continue;
}
if depth < max_depth {
@@ -1281,40 +1304,46 @@ pub(crate) fn search_files_recursive<'a>(
})
}
// get_app_config baseline 测试辅助:构造 GetAppConfigCtx(与 AppState init 同款默认值,
// 仅用于 build_ai_tool_registry 注册阶段,handler 不实际执行故无需真实配置)。
// 提到 tests 模块外(file 级 cfg(test)):ensure_tool_meta_seeded 种子路径也要用。
#[cfg(test)]
use std::sync::atomic::{AtomicU64, AtomicUsize};
#[cfg(test)]
use crate::state::LlmConcurrency;
/// 测试用 GetAppConfigCtx 构造器(默认值对齐 AppState::init:iter=10/retries=3/timeout=15/concurrency 3,2)。
///
/// build_ai_tool_registry 第 4 参,5 处测试调用共用,避免每处内联 5 行重复。
/// Arc 句柄独立于 AppState(测试无 AppState),仅满足注册期类型签名,handler 不执行。
#[cfg(test)]
fn make_test_get_app_config_ctx(db: &Arc<Database>) -> crate::commands::ai::get_app_config::GetAppConfigCtx {
crate::commands::ai::get_app_config::GetAppConfigCtx {
db: db.clone(),
agent_max_iterations: Arc::new(AtomicUsize::new(
crate::commands::ai::agentic::DEFAULT_MAX_AGENT_ITERATIONS,
)),
agent_max_retries: Arc::new(AtomicUsize::new(
crate::commands::ai::agentic::DEFAULT_MAX_AGENT_RETRIES,
)),
approval_timeout_minutes: Arc::new(AtomicU64::new(15)),
llm_concurrency: LlmConcurrency::new(3, 2),
}
}
/// build_ai_tool_registry 第 5 参(local_proxy 工具代理 ctx),测试调用共用。
/// gateway_url 用默认本地网关即可,handler 不实际执行无需真实代理。
#[cfg(test)]
fn make_test_local_proxy_ctx() -> crate::commands::ai::tools::local_proxy::LocalProxyCtx {
crate::commands::ai::tools::local_proxy::LocalProxyCtx {
gateway_url: "http://127.0.0.1:8899".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
// get_app_config baseline 测试辅助:构造 GetAppConfigCtx(与 AppState init 同款默认值,
// 仅用于 build_ai_tool_registry 注册阶段定义完整性断言,handler 不实际执行故无需真实配置)。
use std::sync::atomic::{AtomicU64, AtomicUsize};
use crate::state::LlmConcurrency;
/// 测试用 GetAppConfigCtx 构造器(默认值对齐 AppState::init:iter=10/retries=3/timeout=15/concurrency 3,2)。
///
/// build_ai_tool_registry 第 4 参,5 处测试调用共用,避免每处内联 5 行重复。
/// Arc 句柄独立于 AppState(测试无 AppState),仅满足注册期类型签名,handler 不执行。
fn make_test_get_app_config_ctx(db: &Arc<Database>) -> crate::commands::ai::get_app_config::GetAppConfigCtx {
crate::commands::ai::get_app_config::GetAppConfigCtx {
db: db.clone(),
agent_max_iterations: Arc::new(AtomicUsize::new(
crate::commands::ai::agentic::DEFAULT_MAX_AGENT_ITERATIONS,
)),
agent_max_retries: Arc::new(AtomicUsize::new(
crate::commands::ai::agentic::DEFAULT_MAX_AGENT_RETRIES,
)),
approval_timeout_minutes: Arc::new(AtomicU64::new(15)),
llm_concurrency: LlmConcurrency::new(3, 2),
}
}
/// build_ai_tool_registry 第 5 参(local_proxy 工具代理 ctx),测试调用共用。
/// gateway_url 用默认本地网关即可,handler 不实际执行无需真实代理。
fn make_test_local_proxy_ctx() -> crate::commands::ai::tools::local_proxy::LocalProxyCtx {
crate::commands::ai::tools::local_proxy::LocalProxyCtx {
gateway_url: "http://127.0.0.1:8899".to_string(),
}
}
// ============================================================
// 工具注册基线测试(SMELL-P0-2 拆分防护)
@@ -1324,7 +1353,7 @@ mod tests {
// 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。
// ============================================================
/// build_ai_tool_registry 应注册恰好 76 个工具(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy),且工具名集合稳定。
/// build_ai_tool_registry 应注册恰好 77 个工具(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill),且工具名集合稳定。
///
/// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db,
// 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。
@@ -1395,10 +1424,14 @@ mod tests {
// knowledge_promote(2026-10-01): data 层 44→45(新增候选知识显式转正工具,
// candidate→published 单向,补齐自动晋升默认关后的对话内审核通道,Medium)。
// 76 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。
// read_skill(2026-10-01): read_skill 层 0→1(按名读本机 Claude 技能规程正文,只读 Low,
// 与 / 技能注入链共用 skills.rs 进程内缓存;未命中返明确错误,LLM 可向用户澄清。
// 声明式注册在 tools/read_skill.rs,register_read_skill_tool thin 委托)。
// 77 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill。
assert_eq!(
registry.len(),
76,
"工具总数应为 76(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy),实际 {}", registry.len()
77,
"工具总数应为 77(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill),实际 {}", registry.len()
);
// 工具名集合基线:防 rename / 漏注册 / 误删除。
@@ -1463,6 +1496,9 @@ mod tests {
"ssh_exec", "mongo_find", "mongo_insert",
"mongo_update", "mongo_delete", "redis_get",
"redis_set", "redis_del",
// ── read_skill 层 (1) ──(按名读本机 Claude 技能规程正文,只读 Low,
// 与 / 技能注入链共用 skills.rs 进程内缓存,声明式注册 tools/read_skill.rs)
"read_skill",
];
expected.sort_unstable();
@@ -1476,39 +1512,57 @@ mod tests {
);
}
/// is_noise_dir 纯函数:覆盖 .git/.gitignore 区分(目录是噪音,.gitignore 文件名不是)
/// is_noise_entry 纯函数:覆盖 .git/.gitignore 区分(目录是噪音,.gitignore 文件名不是)
#[test]
fn test_is_noise_dir_distinguishes_dir_and_gitignore_file() {
fn test_is_noise_entry_distinguishes_dir_and_gitignore_file() {
// 噪音目录命中
assert!(is_noise_dir(".git"));
assert!(is_noise_dir("node_modules"));
assert!(is_noise_dir("target"));
assert!(is_noise_dir("dist"));
assert!(is_noise_dir("build"));
assert!(is_noise_entry(".git", NoiseDirTier::AiContext));
assert!(is_noise_entry("node_modules", NoiseDirTier::AiContext));
assert!(is_noise_entry("target", NoiseDirTier::AiContext));
assert!(is_noise_entry("dist", NoiseDirTier::AiContext));
assert!(is_noise_entry("build", NoiseDirTier::AiContext));
// .gitignore / .gitattributes 等文件名不应命中(只匹配纯目录名 .git)
assert!(!is_noise_dir(".gitignore"));
assert!(!is_noise_dir(".gitattributes"));
assert!(!is_noise_dir(".gitkeep"));
assert!(!is_noise_entry(".gitignore", NoiseDirTier::AiContext));
assert!(!is_noise_entry(".gitattributes", NoiseDirTier::AiContext));
assert!(!is_noise_entry(".gitkeep", NoiseDirTier::AiContext));
}
/// is_noise_dir 纯函数:普通目录不命中
/// is_noise_entry 纯函数:普通目录不命中
#[test]
fn test_is_noise_dir_normal_dirs_not_matched() {
assert!(!is_noise_dir("src"));
assert!(!is_noise_dir("tests"));
assert!(!is_noise_dir("docs"));
assert!(!is_noise_dir("main.rs"));
assert!(!is_noise_dir(""));
fn test_is_noise_entry_normal_dirs_not_matched() {
assert!(!is_noise_entry("src", NoiseDirTier::AiContext));
assert!(!is_noise_entry("tests", NoiseDirTier::AiContext));
assert!(!is_noise_entry("docs", NoiseDirTier::AiContext));
assert!(!is_noise_entry("main.rs", NoiseDirTier::AiContext));
assert!(!is_noise_entry("", NoiseDirTier::AiContext));
}
/// is_noise_dir 纯函数:大小写敏感(不靠 to_lowercase 误命中 Target)
/// is_noise_entry 纯函数:大小写敏感(不靠 to_lowercase 误命中 Target)
#[test]
fn test_is_noise_dir_case_sensitive() {
fn test_is_noise_entry_case_sensitive() {
// 文件系统在 Windows 上大小写不敏感,但函数本身用精确匹配;
// 锁定当前语义:大写变体不命中(避免后续误改 to_lowercase 引入行为变化)
assert!(!is_noise_dir("Target"));
assert!(!is_noise_dir("DIST"));
assert!(!is_noise_dir("NODE_MODULES"));
assert!(!is_noise_entry("Target", NoiseDirTier::AiContext));
assert!(!is_noise_entry("DIST", NoiseDirTier::AiContext));
assert!(!is_noise_entry("NODE_MODULES", NoiseDirTier::AiContext));
}
/// is_noise_entry 档位差异锁定:文件树放行 build/venv,技能扫描仅两项
#[test]
fn test_is_noise_entry_tier_deltas() {
// FileTree:全量名单放行 build 与 venv(用户浏览要见构建产物),其余与 AiContext 一致
assert!(!is_noise_entry("build", NoiseDirTier::FileTree));
assert!(!is_noise_entry("venv", NoiseDirTier::FileTree));
assert!(is_noise_entry("node_modules", NoiseDirTier::FileTree));
assert!(is_noise_entry("target", NoiseDirTier::FileTree));
assert!(is_noise_entry("dist", NoiseDirTier::FileTree));
assert!(is_noise_entry("__pycache__", NoiseDirTier::FileTree));
assert!(!is_noise_entry("src", NoiseDirTier::FileTree));
// SkillScan:最小集,仅依赖/VCS
assert!(is_noise_entry("node_modules", NoiseDirTier::SkillScan));
assert!(is_noise_entry(".git", NoiseDirTier::SkillScan));
assert!(!is_noise_entry("target", NoiseDirTier::SkillScan));
assert!(!is_noise_entry("build", NoiseDirTier::SkillScan));
}
/// is_noise_file 纯函数:write_file 副产物 / 编辑器临时文件命中
@@ -2381,4 +2435,191 @@ mod tests {
.unwrap();
assert_eq!(staging["total"], 0);
}
// ============================================================
// DRY 债 #5 对照验证:5 处旧 match 表收敛到注册元数据后逐工具比对。
//
// 旧表值在本测试内固化为期望数据(收敛前从各 match 表逐行抄录):
// path_auth 路径参数键 / cache 只读白名单 + 目录级 key / data_change entity-action /
// tool_registry 审批模板 + 轻量标签 / helpers 目标描述 + 漂移签名(按样例 args 比对输出)。
// 任何一维漂移此测试立即红。
// ============================================================
#[tokio::test]
async fn test_tool_meta_parity_with_legacy_tables() {
// 真实构建一次注册表(条目与全局表一致性断言用)
let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败"));
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
let registry = build_ai_tool_registry(
&db, &allowed_dirs, PathBuf::from(""),
make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(),
);
ensure_tool_meta_seeded();
// ── 旧 path_auth::extract_file_tool_paths 表(name → 路径参数键)──
let legacy_path_args: &[(&str, &[&str])] = &[
("rename_file", &["from", "to"]),
("read_file", &["path"]), ("write_file", &["path"]), ("list_directory", &["path"]),
("patch_file", &["path"]), ("file_info", &["path"]), ("append_file", &["path"]),
("delete_file", &["path"]), ("search_files", &["path"]), ("grep", &["path"]),
];
for (name, keys) in legacy_path_args {
assert_eq!(df_ai::ai_tools::path_arg_keys(name), *keys, "path_args 漂移: {}", name);
}
// 旧 default:非文件工具返回空(不预校验)
for name in ["run_command", "http_request", "create_task", "git_status", "read_symbol", "download_file", "diff_files"] {
assert!(df_ai::ai_tools::path_arg_keys(name).is_empty(), "非文件工具不应有 path_args: {}", name);
}
// ── 旧 cache.rs READONLY_CACHE_TOOLS 白名单 ──
for name in ["read_file", "read_symbol", "list_directory", "search_files", "grep"] {
assert!(df_ai::ai_tools::is_readonly(name), "readonly 漂移: {}", name);
}
for name in ["write_file", "patch_file", "run_command", "delete_file", "rename_file", "append_file", "file_info", "http_request", "git_commit", "mysql_query"] {
assert!(!df_ai::ai_tools::is_readonly(name), "非只读工具误标 readonly: {}", name);
}
// ── 旧 cache.rs 目录级 key / 目录专用警告判据(旧匹配 list_directory;幽灵 list_dir 已删)──
assert!(df_ai::ai_tools::cache_path_key_only("list_directory"));
for name in ["read_file", "grep", "search_files", "list_dir"] {
assert!(!df_ai::ai_tools::cache_path_key_only(name), "cache_path_key 误标: {}", name);
}
// ── 旧 path_auth search_files 盲搜硬拒 ──
assert!(df_ai::ai_tools::denies_unauthorized("search_files"));
for name in ["grep", "read_file", "list_directory", "run_command"] {
assert!(!df_ai::ai_tools::denies_unauthorized(name), "deny_unauthorized 误标: {}", name);
}
// ── 旧 data_change::data_change_for_tool 表(name, entity, action)──
let legacy_entity_action: &[(&str, &str, &str)] = &[
("create_project", "project", "create"),
("create_task", "task", "create"),
("create_idea", "idea", "create"),
("update_project", "project", "update"),
("update_task", "task", "update"),
("update_idea", "idea", "update"),
("advance_task", "task", "update"),
("delete_project", "project", "delete"),
("purge_project", "project", "delete"),
("delete_task", "task", "delete"),
("delete_idea", "idea", "delete"),
("restore_project", "project", "update"),
("bind_directory", "project", "update"),
("write_file", "file", "update"),
("patch_file", "file", "update"),
];
for (name, entity, action) in legacy_entity_action {
assert_eq!(
df_ai::ai_tools::entity_action(name),
Some((*entity, *action)),
"entity_action 漂移: {}", name
);
}
for name in ["list_tasks", "run_workflow", "read_file", "git_status"] {
assert_eq!(df_ai::ai_tools::entity_action(name), None, "entity_action 应为 None: {}", name);
}
// ── 旧 tool_registry display_hint_for_tool 表(name, 模板, 键)──
let legacy_approval: &[(&str, &str, &[&str])] = &[
("delete_project", "删除项目{}", &["id"]),
("restore_project", "从回收站恢复项目{}", &["id"]),
("purge_project", "永久删除项目及关联数据,不可恢复{}", &["id"]),
("update_project", "修改项目{}字段「{}」", &["id", "field"]),
("bind_directory", "绑定目录:{}(项目{})", &["path", "id"]),
("create_task", "创建任务:{}(项目{})", &["title", "project_id"]),
("create_project", "创建项目:「{}」", &["name"]),
("create_idea", "捕获灵感:{}", &["title"]),
("patch_file", "修改文件:{}", &["path"]),
("delete_file", "删除文件:{}({})", &["path", "permanent"]),
("rename_file", "重命名/移动:{} → {}", &["from", "to"]),
("advance_task", "推进任务状态:{} → {}", &["id", "target_status"]),
("run_workflow", "触发工作流:任务{} 推进到 {}", &["task_id", "target_status"]),
("http_request", "HTTP 请求:{} {}", &["method", "url"]),
];
for (name, template, keys) in legacy_approval {
assert_eq!(
df_ai::ai_tools::approval_hint(name),
Some((*template, *keys)),
"approval_hint 漂移: {}", name
);
}
assert_eq!(df_ai::ai_tools::approval_hint("read_file"), None);
// ── 旧 tool_registry tool_display_hint 表(advance_task 刻意不在表内)──
let legacy_verb: &[(&str, &str)] = &[
("create_task", "创建任务"), ("write_file", "写入文件"), ("read_file", "读取文件"),
("list_directory", "列出目录"), ("search_files", "搜索文件"), ("grep", "搜索代码内容"),
("create_project", "创建项目"), ("update_project", "修改项目"), ("delete_project", "删除项目"),
("restore_project", "恢复项目"), ("purge_project", "永久删除项目"), ("bind_directory", "绑定目录"),
("create_idea", "捕获灵感"), ("update_task", "修改任务"), ("run_workflow", "触发工作流"),
("delete_task", "删除任务"), ("update_idea", "修改灵感"), ("delete_idea", "删除灵感"),
("patch_file", "修改文件"), ("append_file", "追加写入"), ("delete_file", "删除文件"),
("rename_file", "重命名/移动"), ("http_request", "发起 HTTP 请求"),
];
for (name, verb) in legacy_verb {
assert_eq!(df_ai::ai_tools::display_verb(name), Some(*verb), "display_verb 漂移: {}", name);
}
assert_eq!(df_ai::ai_tools::display_verb("advance_task"), None, "advance_task 无轻量标签(走模板),与旧表一致");
assert_eq!(df_ai::ai_tools::display_verb("read_symbol"), None);
// ── 旧 helpers::infer_goal_from_tool_calls(样例 args 逐字比对输出)──
let goal_of = |name: &str, args: Option<serde_json::Value>| -> Option<String> {
df_ai::ai_tools::goal_of(name).and_then(|g| df_ai::ai_tools::goal_text(g, args.as_ref()))
};
// FileName 风格:按旧实现取末个 / 段(纯反斜杠路径旧 or_else 分支恒死,保持原样)
assert_eq!(goal_of("patch_file", Some(serde_json::json!({"path": "a/b/c.rs"}))), Some("修改 c.rs".to_string()));
assert_eq!(goal_of("write_file", Some(serde_json::json!({"path": "docs/dry.md"}))), Some("修改 dry.md".to_string()));
assert_eq!(goal_of("append_file", Some(serde_json::json!({"path": "log.txt"}))), Some("修改 log.txt".to_string()));
assert_eq!(goal_of("read_file", Some(serde_json::json!({"path": "src/lib.rs"}))), Some("查看 lib.rs".to_string()));
assert_eq!(goal_of("file_info", Some(serde_json::json!({"path": "Cargo.toml"}))), Some("查看 Cargo.toml".to_string()));
assert_eq!(goal_of("delete_file", Some(serde_json::json!({"path": "a/b.txt"}))), Some("处理 b.txt".to_string()));
assert_eq!(goal_of("rename_file", Some(serde_json::json!({"path": "a/b.txt"}))), Some("处理 b.txt".to_string()));
// 反斜杠路径:旧实现 rsplit('/') 取整串(or_else 恒不触发),新实现保持同值
assert_eq!(goal_of("write_file", Some(serde_json::json!({"path": "x\\y\\n.txt"}))), Some("修改 x\\y\\n.txt".to_string()));
// Raw 风格
assert_eq!(goal_of("list_directory", Some(serde_json::json!({"path": "/tmp/logs"}))), Some("浏览 /tmp/logs".to_string()));
assert_eq!(goal_of("create_project", Some(serde_json::json!({"name": "moyu"}))), Some("创建项目 moyu".to_string()));
assert_eq!(goal_of("create_idea", Some(serde_json::json!({"title": "灵感一闪"}))), Some("捕获灵感 灵感一闪".to_string()));
// Truncate 风格 + 键回退 + 缺参兜底
let long_cmd = "abcdefghijklmnopqrstuvwxyz0123456789";
assert_eq!(goal_of("run_command", Some(serde_json::json!({"command": long_cmd}))), Some(format!("执行 {}", &long_cmd[..25])));
assert_eq!(goal_of("search_files", Some(serde_json::json!({"pattern": long_cmd}))), Some(format!("搜索 {}", &long_cmd[..25])));
assert_eq!(goal_of("grep", Some(serde_json::json!({"query": "todo"}))), Some("搜索 todo".to_string()));
assert_eq!(goal_of("search_files", Some(serde_json::json!({}))), Some("搜索文件".to_string()));
assert_eq!(goal_of("run_command", Some(serde_json::json!({}))), Some("执行命令".to_string()));
// 无键工具恒返兜底(旧 run_workflow 无条件 Some)
assert_eq!(goal_of("run_workflow", Some(serde_json::json!({"task_id": "t1"}))), Some("执行工作流".to_string()));
assert_eq!(goal_of("run_workflow", None), Some("执行工作流".to_string()));
// 键缺失且无兜底返 None(旧表 _ => None 分支)
assert_eq!(goal_of("read_file", Some(serde_json::json!({}))), None);
assert_eq!(goal_of("update_task", Some(serde_json::json!({"id": "t1"}))), None);
// ── 旧 helpers::tool_call_signature(样例 args 逐字比对输出)──
let sig = |name: &str, args: serde_json::Value| df_ai::ai_tools::tool_signature(name, &args);
assert_eq!(sig("grep", serde_json::json!({"pattern": "MAX", "path": "src", "case_sensitive": true})), "grep:pattern=MAX,path=src");
assert_eq!(sig("search_files", serde_json::json!({"query": "TODO", "glob": "**/*.rs"})), "search_files:pattern=TODO,path=**/*.rs");
assert_eq!(sig("read_file", serde_json::json!({"path": "big.log", "offset": 50, "limit": 100})), "read_file:path=big.log,offset=50,limit=100");
assert_eq!(sig("read_file", serde_json::json!({})), "read_file:path=,offset=,limit=");
assert_eq!(sig("run_command", serde_json::json!({"command": "ls -la", "timeout": 5000})), "run_command:command=ls -la");
assert_eq!(sig("list_directory", serde_json::json!({"path": "/tmp"})), "list_directory:path=/tmp");
assert_eq!(sig("http_request", serde_json::json!({"url": "https://x.io", "method": "GET", "headers": {}})), "http_request:url=https://x.io,method=GET");
assert_eq!(sig("fetch_url", serde_json::json!({"url": "https://y.io", "raw": false})), "fetch_url:url=https://y.io");
// 未注册/未声明 sig 走旧 default:工具名 + 全参 JSON
let unknown = sig("custom_tool", serde_json::json!({"x": 1, "y": "z"}));
assert!(unknown.starts_with("custom_tool:name=custom_tool,args="), "未知工具应走全参兜底: {}", unknown);
let nosig = sig("write_file", serde_json::json!({"path": "a"}));
assert!(nosig.starts_with("write_file:name=write_file,args="), "未声明 sig 的已注册工具应走全参兜底: {}", nosig);
// ── 注册表条目与全局表一致(条目存储元数据 + 查询函数双面)──
for name in registry.tool_names() {
let entry = registry.meta(&name).expect("条目应有元数据");
assert_eq!(Some(entry), df_ai::ai_tools::tool_meta(&name), "条目与全局表漂移: {}", name);
}
// 抽查条目维度:read_file 只读 + path 路径键;advance_task 有 entity 无轻量标签
let rf = registry.meta("read_file").unwrap();
assert!(rf.readonly && rf.path_args == ["path"]);
let at = registry.meta("advance_task").unwrap();
assert_eq!(at.entity_action, Some(("task", "update")));
assert_eq!(at.display_verb, None);
}
}
+5 -1
View File
@@ -10,7 +10,7 @@
use std::sync::Arc;
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
use df_ai::ai_tools::{AiToolRegistry, RiskLevel, SigMeta, SigValKind, ToolMeta};
use df_ai::declare_tool;
/// 注册 fetch_url 工具到 `$registry`(无 db 捕获,纯网络 GET + htmd 转换)。
@@ -42,6 +42,10 @@ pub fn register(registry: &mut AiToolRegistry) {
"fetch_url",
"获取网页 URL 内容并转为 markdown(HTML 清洗 + 截断),用于高效理解网页文档(API 文档/博客/技术资料)。只读 GET,自动剥离 script/style/nav 等噪声,提取 title,按 max_length 截断。返回 {url, title, markdown, length, truncated, render_mode, ?hint}。安全:仅 http/https,拒绝私网/保留 IP(SSRF 防护含 DNS resolve 后校验),重定向≤3 跳。render=true 用 obscura 渲染 JS(SPA/反爬,需装 obscura,未装自动回退静态)。如需 POST/鉴权/原始响应用 http_request",
RiskLevel::Low,
meta: ToolMeta {
sig: Some(SigMeta { groups: &[(&["url"], SigValKind::Str)] }),
..Default::default()
},
schema: schema,
args => {
// 转调 fetch_url.rs handler(SSRF 防护 + htmd 转换 + 去噪音 + 截断全在那)
+85 -2
View File
@@ -14,7 +14,7 @@
//! validate_path / generate_diff / apply_line_range / resolve_anchor_to_lines /
//! resolve_workspace_path_with_allowed / rename_or_cross_volume_copy / compute_file_hash /
//! FILE_LOCKS / probe_executable / list_dir_recursive / compile_glob_to_regex /
//! grep_recursive / grep_one_file / search_files_recursive / is_noise_dir / is_noise_file /
//! grep_recursive / grep_one_file / search_files_recursive / is_noise_entry / is_noise_file /
//! FileGrepHit / LineMatch。
//!
//! 单捕获宏适配(declare_tool! 宏仅支持单 capture 变量):
@@ -30,7 +30,7 @@
use std::sync::Arc;
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
use df_ai::ai_tools::{object_schema, AiToolRegistry, GoalMeta, GoalStyle, RiskLevel, SigMeta, SigValKind, ToolMeta};
use df_ai::declare_tool;
use tokio::sync::RwLock;
@@ -73,6 +73,14 @@ pub fn register(
"read_file",
"读取文件内容,返回文本内容。支持 offset/limit 分页;传入 search 则在文件内容中搜索匹配行(大小写敏感,字符串包含匹配),返回 matches 数组(限50条)。仅需函数/类型符号的签名或定义体时优先用 read_symbol(骨架/全文,避免 prompt 爆)",
RiskLevel::Low,
meta: ToolMeta {
path_args: &["path"],
readonly: true,
display_verb: Some("读取文件"),
goal: Some(GoalMeta { verb: "查看", keys: &["path"], style: GoalStyle::FileName, fallback: None }),
sig: Some(SigMeta { groups: &[(&["path"], SigValKind::Str), (&["offset"], SigValKind::Json), (&["limit"], SigValKind::Json)] }),
..Default::default()
},
schema: object_schema(vec![("path", "string", true), ("offset", "integer", false), ("limit", "integer", false), ("search", "string", false)]),
args => {
let snap = allowed_dirs.read().await.clone();
@@ -208,6 +216,10 @@ pub fn register(
"read_symbol",
"AST 符号解析(信息密度驱动,治 read_file 全文回灌 prompt 爆)。提取函数/结构体/类等符号:默认返回骨架(签名+行范围+内部调用,极小高密度);传 full=true 取完整定义体。不支持/未找到时回退提示用 grep+read_file。Phase1 支持 .rs/.ts/.tsx/.js/.jsx/.vue。",
RiskLevel::Low,
meta: ToolMeta {
readonly: true,
..Default::default()
},
schema: object_schema(vec![("path", "string", true), ("symbol", "string", true), ("full", "boolean", false), ("kind", "string", false), ("drill", "string", false)]),
args => {
let snap = allowed_dirs.read().await.clone();
@@ -308,6 +320,15 @@ pub fn register(
"list_directory",
"列出目录内容,返回文件和子目录列表(名称、类型、大小)。path 可选,不传时返回引导提示",
RiskLevel::Low,
meta: ToolMeta {
path_args: &["path"],
readonly: true,
cache_path_key: true,
display_verb: Some("列出目录"),
goal: Some(GoalMeta { verb: "浏览", keys: &["path"], style: GoalStyle::Raw, fallback: None }),
sig: Some(SigMeta { groups: &[(&["path"], SigValKind::Str)] }),
..Default::default()
},
schema: object_schema(vec![("path", "string", false), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false), ("max_depth", "integer", false)]),
args => {
let p = args["path"].as_str().unwrap_or("");
@@ -335,6 +356,13 @@ pub fn register(
"write_file",
"写入或创建文件,自动创建不存在的父目录。支持 encoding=\"base64\" 写入二进制/非 UTF-8 内容(图片/PDF/Excel 等),content 传 base64 编码字符串;默认 encoding=\"utf-8\" 传纯文本",
RiskLevel::Medium,
meta: ToolMeta {
path_args: &["path"],
entity_action: Some(("file", "update")),
display_verb: Some("写入文件"),
goal: Some(GoalMeta { verb: "修改", keys: &["path"], style: GoalStyle::FileName, fallback: None }),
..Default::default()
},
schema: object_schema(vec![
("path", "string", true),
("content", "string", true),
@@ -453,6 +481,14 @@ pub fn register(
"patch_file",
"局部更新文件内容(三模式互斥)。模式1 old_text:精确匹配原文替换(含空格/缩进,CAS 语义)。模式2 replace_lines:按行号区间 {start,end}(1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor:按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_text,expected_hash 可选通用。注意:若文件已被外部修改,请先重新 read_file 获取最新内容。所有 patch_file 操作自动放行不阻塞 AI 工作流,写入会落 audit 表可追溯。",
RiskLevel::Medium,
meta: ToolMeta {
path_args: &["path"],
entity_action: Some(("file", "update")),
approval_hint: Some(("修改文件:{}", &["path"])),
display_verb: Some("修改文件"),
goal: Some(GoalMeta { verb: "修改", keys: &["path"], style: GoalStyle::FileName, fallback: None }),
..Default::default()
},
schema: patch_file_schema,
args => {
let snap = allowed_dirs.read().await.clone();
@@ -637,6 +673,11 @@ pub fn register(
"file_info",
"获取文件或目录的元信息(是否存在、大小、行数、修改时间、是否二进制、是否目录),不读取文件内容",
RiskLevel::Low,
meta: ToolMeta {
path_args: &["path"],
goal: Some(GoalMeta { verb: "查看", keys: &["path"], style: GoalStyle::FileName, fallback: None }),
..Default::default()
},
schema: object_schema(vec![("path", "string", true)]),
args => {
let snap = allowed_dirs.read().await.clone();
@@ -691,6 +732,12 @@ pub fn register(
"append_file",
"向文件末尾追加内容,文件不存在则自动创建。返回写入字数和新文件大小",
RiskLevel::Medium,
meta: ToolMeta {
path_args: &["path"],
display_verb: Some("追加写入"),
goal: Some(GoalMeta { verb: "修改", keys: &["path"], style: GoalStyle::FileName, fallback: None }),
..Default::default()
},
schema: object_schema(vec![("path", "string", true), ("content", "string", true)]),
args => {
let snap = allowed_dirs.read().await.clone();
@@ -730,6 +777,13 @@ pub fn register(
"delete_file",
"删除文件。默认(permanent=false)软删除:移到工作区 .trash/ 目录保留可恢复;permanent=true 硬删除不可恢复。删除前备份,回收站机制对齐 list_trash。高风险,须人工批准",
RiskLevel::High,
meta: ToolMeta {
path_args: &["path"],
approval_hint: Some(("删除文件:{}({})", &["path", "permanent"])),
display_verb: Some("删除文件"),
goal: Some(GoalMeta { verb: "处理", keys: &["path"], style: GoalStyle::FileName, fallback: None }),
..Default::default()
},
schema: object_schema(vec![
("path", "string", true),
("permanent", "boolean", false),
@@ -810,6 +864,13 @@ pub fn register(
"rename_file",
"重命名或移动文件(同一工具覆盖 rename 和 move)。参数:from(源路径)+ to(目标路径)+ overwrite(默认 false,目标存在则拒绝)。同卷走 tokio::fs::rename(原子),跨卷自动降级 copy+remove(非原子,失败回滚删 to 保 from 完整)。须人工批准",
RiskLevel::Medium,
meta: ToolMeta {
path_args: &["from", "to"],
approval_hint: Some(("重命名/移动:{} → {}", &["from", "to"])),
display_verb: Some("重命名/移动"),
goal: Some(GoalMeta { verb: "处理", keys: &["path"], style: GoalStyle::FileName, fallback: None }),
..Default::default()
},
schema: object_schema(vec![
("from", "string", true),
("to", "string", true),
@@ -902,6 +963,14 @@ pub fn register(
"grep",
"跨文件内容搜索(grep -rn 模式)。参数:pattern(正则,大小写敏感,无特殊字符时等价字面包含)、path(搜索根,可选,不传时返回引导提示)、glob(可选文件名过滤如 *.rs)、output_mode(content/files_with_matches/count)、-n(行号默认 true)、-i(大小写不敏感默认 false)、-C(上下文行数默认 0)、context_chars(字符级窗口,大单行如 minified JS/CSS 命中行超阈值时截匹配位置 ±N 字符窗口替代整行,默认 0 不截;与 -C 互补:-C 行级前后 N 行,context_chars 同长行内字符级)、max_results(上限默认 50)。跳过噪音目录/噪音文件/symlink/二进制文件。返回 matches(files_with_matches 模式)或 matches(含 file/line/content/context,content 模式)+ total + truncated。授权目录内放行,未授权触发目录授权申请(AiDirAuthRequired)",
RiskLevel::Low,
meta: ToolMeta {
path_args: &["path"],
readonly: true,
display_verb: Some("搜索代码内容"),
goal: Some(GoalMeta { verb: "搜索", keys: &["pattern", "query"], style: GoalStyle::Truncate, fallback: Some("搜索文件") }),
sig: Some(SigMeta { groups: &[(&["pattern", "query"], SigValKind::Str), (&["path", "glob"], SigValKind::Str)] }),
..Default::default()
},
schema: grep_schema,
args => {
let p = args["path"].as_str().unwrap_or("");
@@ -1078,6 +1147,15 @@ pub fn register(
"search_files",
"在指定目录下搜索匹配模式(字符串包含匹配)的文件名,支持 offset/limit 分页。返回 results、total、has_more。默认 limit=50",
RiskLevel::Low,
meta: ToolMeta {
path_args: &["path"],
readonly: true,
deny_unauthorized: true,
display_verb: Some("搜索文件"),
goal: Some(GoalMeta { verb: "搜索", keys: &["pattern", "query"], style: GoalStyle::Truncate, fallback: Some("搜索文件") }),
sig: Some(SigMeta { groups: &[(&["pattern", "query"], SigValKind::Str), (&["path", "glob"], SigValKind::Str)] }),
..Default::default()
},
schema: object_schema(vec![("path", "string", false), ("pattern", "string", true), ("recursive", "boolean", false), ("offset", "integer", false), ("limit", "integer", false)]),
args => {
let p = args["path"].as_str().unwrap_or("");
@@ -1122,6 +1200,11 @@ pub fn register(
"run_command",
"在指定工作目录执行 shell 命令,返回 stdout/stderr/exit_code。仅用于命令执行场景:跑测试套件、构建项目、运行二进制/脚本验证行为。读取文件用 read_file,编辑文件用 patch_file/write_file,列目录用 list_directory,搜索文件名用 search_files——不要用本工具完成这些操作。高风险,须人工批准。命令需自包含(非交互式,避免需用户输入的程序)。默认超时 60 秒。返回含 succeeded 布尔(按 exit_code==0 判定)。注意:部分命令非零退出属正常语义(如 git diff 无差异返回 1),此时以 stdout/stderr 内容为准,勿仅凭 exit_code≠0 判失败。",
RiskLevel::High,
meta: ToolMeta {
goal: Some(GoalMeta { verb: "执行", keys: &["command"], style: GoalStyle::Truncate, fallback: Some("执行命令") }),
sig: Some(SigMeta { groups: &[(&["command"], SigValKind::Str)] }),
..Default::default()
},
schema: object_schema(vec![
("command", "string", true),
("working_dir", "string", false),
+7 -1
View File
@@ -23,7 +23,7 @@
use std::sync::Arc;
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
use df_ai::ai_tools::{AiToolRegistry, RiskLevel, SigMeta, SigValKind, ToolMeta};
use df_ai::declare_tool;
use tokio::sync::RwLock;
@@ -65,6 +65,12 @@ pub fn register(registry: &mut AiToolRegistry, allowed_dirs: &Arc<RwLock<Allowed
"http_request",
"发起结构化 HTTP 请求(GET/POST/PUT/PATCH/DELETE),用于查询外部 API。参数:method(默认 GET)、url(http/https)、headers(对象 map)、body(请求体字符串)、timeout_secs(默认 30,上限 60)、parse(json/text/auto,默认 auto)、output_file(响应落盘路径,适合大响应,全量写不截断)、max_response_chars(body 截断阈值字节,默认 50KB,clamp [1KB,10MB])。安全:仅 http/https 协议,拒绝私网/保留 IP(SSRF 防护含 DNS resolve 后校验防重绑定),重定向≤3 跳且每跳重校验。响应 body 默认截断 50KB(可 max_response_chars 覆盖或 output_file 落盘)。GET 为只读但触发外发网络,POST/PUT/PATCH/DELETE 有副作用,统一按高风险须人工批准",
RiskLevel::High,
meta: ToolMeta {
approval_hint: Some(("HTTP 请求:{} {}", &["method", "url"])),
display_verb: Some("发起 HTTP 请求"),
sig: Some(SigMeta { groups: &[(&["url"], SigValKind::Str), (&["method"], SigValKind::Str)] }),
..Default::default()
},
schema: schema,
args => {
// allowed_dirs read lock clone(output_file 路径校验用;无 output_file 时校验不触发)
+18 -1
View File
@@ -18,7 +18,7 @@
use std::sync::Arc;
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
use df_ai::ai_tools::{object_schema, AiToolRegistry, GoalMeta, GoalStyle, RiskLevel, ToolMeta};
use df_ai::declare_tool;
use df_storage::db::Database;
use df_storage::models::IdeaRecord;
@@ -61,6 +61,13 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"create_idea",
"捕获一个新灵感",
RiskLevel::Medium,
meta: ToolMeta {
entity_action: Some(("idea", "create")),
approval_hint: Some(("捕获灵感:{}", &["title"])),
display_verb: Some("捕获灵感"),
goal: Some(GoalMeta { verb: "捕获灵感", keys: &["title"], style: GoalStyle::Raw, fallback: None }),
..Default::default()
},
schema: object_schema(vec![
("title", "string", true), ("description", "string", false),
("tags", "string", false), ("source", "string", false),
@@ -99,6 +106,11 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"update_idea",
"更新灵感的指定字段(title/description/priority/tags/source/score 等),需要提供灵感 ID、字段名和新值。",
RiskLevel::Medium,
meta: ToolMeta {
entity_action: Some(("idea", "update")),
display_verb: Some("修改灵感"),
..Default::default()
},
schema: object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]),
args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
@@ -124,6 +136,11 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"delete_idea",
"删除灵感(软删除,移入回收站,可恢复)",
RiskLevel::Medium,
meta: ToolMeta {
entity_action: Some(("idea", "delete")),
display_verb: Some("删除灵感"),
..Default::default()
},
schema: object_schema(vec![("id", "string", true)]),
args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
+1
View File
@@ -26,3 +26,4 @@ pub mod trash;
pub mod file;
pub mod download_file;
pub mod entity_resolve;
pub mod read_skill;
+38 -1
View File
@@ -17,7 +17,7 @@
use std::sync::Arc;
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
use df_ai::ai_tools::{object_schema, AiToolRegistry, GoalMeta, GoalStyle, RiskLevel, ToolMeta};
use df_ai::declare_tool;
use df_storage::db::Database;
use df_storage::models::ProjectRecord;
@@ -42,6 +42,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"update_project",
"更新项目的指定字段(name/status/description/path/stack),需要提供项目 ID、字段名和新值。绑定代码目录推荐改用 bind_directory",
RiskLevel::Medium,
meta: ToolMeta {
entity_action: Some(("project", "update")),
approval_hint: Some(("修改项目{}字段「{}」", &["id", "field"])),
display_verb: Some("修改项目"),
..Default::default()
},
schema: object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]),
args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
@@ -69,6 +75,13 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"create_project",
"创建新项目,可选传 path/stack 一步完成创建+绑定目录(无需再调 bind_directory)",
RiskLevel::Medium,
meta: ToolMeta {
entity_action: Some(("project", "create")),
approval_hint: Some(("创建项目:「{}」", &["name"])),
display_verb: Some("创建项目"),
goal: Some(GoalMeta { verb: "创建项目", keys: &["name"], style: GoalStyle::Raw, fallback: None }),
..Default::default()
},
schema: object_schema(vec![
("name", "string", true), ("description", "string", false),
("path", "string", false), ("stack", "string", false),
@@ -104,6 +117,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"bind_directory",
"为项目绑定代码目录(自动探测技术栈,防重复绑定)。path 应为**项目根路径**(单仓库=仓库根;多工程 monorepo=工作区根,非单个子工程路径;子工程信息走 list_project_modules/update_project_module 维护)。多次调用会覆盖 path 字段,monorepo 场景勿为每个子工程重复绑定。可选 stack 手动指定技术栈(如 'Go / Gin / Vue3'),不传则自动探测。",
RiskLevel::Medium,
meta: ToolMeta {
entity_action: Some(("project", "update")),
approval_hint: Some(("绑定目录:{}(项目{})", &["path", "id"])),
display_verb: Some("绑定目录"),
..Default::default()
},
schema: object_schema(vec![("id", "string", true), ("path", "string", true), ("stack", "string", false)]),
args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id 参数"))?;
@@ -126,6 +145,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"delete_project",
"删除项目(移入回收站,可恢复。永久删除用 purge_project)",
RiskLevel::High,
meta: ToolMeta {
entity_action: Some(("project", "delete")),
approval_hint: Some(("删除项目{}", &["id"])),
display_verb: Some("删除项目"),
..Default::default()
},
schema: object_schema(vec![("id", "string", true)]),
args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
@@ -142,6 +167,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"restore_project",
"从回收站恢复已删除项目",
RiskLevel::High,
meta: ToolMeta {
entity_action: Some(("project", "update")),
approval_hint: Some(("从回收站恢复项目{}", &["id"])),
display_verb: Some("恢复项目"),
..Default::default()
},
schema: object_schema(vec![("id", "string", true)]),
args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
@@ -158,6 +189,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"purge_project",
"永久删除项目及关联数据(不可恢复)",
RiskLevel::High,
meta: ToolMeta {
entity_action: Some(("project", "delete")),
approval_hint: Some(("永久删除项目及关联数据,不可恢复{}", &["id"])),
display_verb: Some("永久删除项目"),
..Default::default()
},
schema: object_schema(vec![("id", "string", true)]),
args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
@@ -0,0 +1,48 @@
//! read_skill AI 工具声明式注册(按名读本机 Claude 技能规程正文,只读)。
//!
//! 工具职责:LLM 主动调 read_skill(name) → 查进程内技能缓存(含项目级,PROJECT_SKILLS_ROOT)
//! 命中后读 SKILL.md 剥 frontmatter 正文返回(含 source 来源标注);未命中返回明确错误文案
//! (LLM 可据此向用户澄清技能名,不静默空转)。
//!
//! 风险:Low(纯只读,读 ~/.claude 与项目 .claude 下 SKILL.md,无写副作用)。
//! handler 直调 skills.rs 的 `read_skill_content_stripped` / `skills_cached`(注入链同一
//! 真相源,与 / 技能 SkillResolver 读到相同内容)。
use std::sync::Arc;
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
use df_ai::declare_tool;
/// 注册 read_skill 工具到 `$registry`(无外部捕获,读 skills.rs 进程内缓存 + 单文件)。
pub fn register(registry: &mut AiToolRegistry) {
// 无捕获:占位 Arc<()>(handler 只调 skills.rs 缓存/读文件,不持外部状态)。
let dummy: Arc<()> = Arc::new(());
// schema:name 必填(技能名,如 "review" / "swarm")。
let schema = object_schema(vec![("name", "string", true)]);
declare_tool!(
registry,
dummy: Arc<()>,
"read_skill",
"按名称读取本机已安装的 Claude 技能规程(SKILL.md)完整正文,只读查阅,不执行技能。参数 name 为技能名。命中返回 {name, source, content}(source 为来源 skill/project/command/plugin,content 已剥离 frontmatter);未命中返回明确错误。当用户 / 技能调用失败、用户提到某技能名、或你判断某技能与本任务相关时调用本工具读取其完整规程内容",
RiskLevel::Low,
schema: schema,
args => {
let name = args["name"].as_str().unwrap_or("").trim().to_string();
if name.is_empty() {
return Err(anyhow::anyhow!("参数 name 不能为空:请传入技能名,如 {{\"name\": \"review\"}}"));
}
let content = crate::commands::ai::skills::read_skill_content_stripped(name.clone())
.await
.ok_or_else(|| anyhow::anyhow!(
"技能 \"{name}\" 未找到(未安装/名称不匹配/user_invocable:false)。请核对技能名后重试,或请用户确认该技能是否已安装",
))?;
let source = crate::commands::ai::skills::skills_cached()
.await
.into_iter()
.find(|s| s.name == name)
.map(|s| s.source)
.unwrap_or_else(|| "skill".to_string());
Ok(serde_json::json!({ "name": name, "source": source, "content": content }))
}
);
}
+23 -1
View File
@@ -16,7 +16,7 @@
use std::sync::Arc;
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel, ToolMeta};
use df_ai::declare_tool;
use df_storage::db::Database;
use df_storage::models::TaskRecord;
@@ -67,6 +67,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"create_task",
"在指定项目下创建新任务,可选传 idea_id 关联灵感(1对1 单向)。知识图谱 Phase 1 扩展三个可选参数:queue(管理维度池 backlog/todo/decision,默认 todo,新建不可直接落 active/done 须经 move_task_queue 流转)、parent_id(父任务 ID,限 1 级嵌套,父任务自身不能有 parent_id,IPC 层校验防孙任务)、content_json(结构化需求规格 JSON 字符串 {background,acceptance_criteria[],scope[],technical_design},须合法 JSON)",
RiskLevel::Medium,
meta: ToolMeta {
entity_action: Some(("task", "create")),
approval_hint: Some(("创建任务:{}(项目{})", &["title", "project_id"])),
display_verb: Some("创建任务"),
..Default::default()
},
schema: object_schema(vec![
("project_id", "string", true), ("title", "string", true),
("description", "string", false), ("priority", "integer", false),
@@ -129,6 +135,11 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"update_task",
"更新任务的指定字段(title/description/priority/assignee 等),需要提供任务 ID、字段名和新值。注意:status 改动须走 advance_task 工具(状态机推进,8 态 todo/in_progress/in_review/testing/blocked/done/cancelled/deferred,按 target_status 推进不可跳跃),本工具不接受 status 字段(防绕过状态机)",
RiskLevel::Medium,
meta: ToolMeta {
entity_action: Some(("task", "update")),
display_verb: Some("修改任务"),
..Default::default()
},
schema: object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]),
args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
@@ -168,6 +179,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"advance_task",
"推进任务状态,必须走状态机(8 态: todo/in_progress/in_review/testing/blocked/done/cancelled/deferred),按 target_status 推进,不可跳跃(非法转换会被状态机拦截)。退回转换(in_review→in_progress 等)自动累加 review_rounds。返回推进后的最新 TaskRecord",
RiskLevel::Medium,
// display_verb 刻意不设:轻量标签缺位让审批文案走模板(含 target_status 详情),与旧表一致
meta: ToolMeta {
entity_action: Some(("task", "update")),
approval_hint: Some(("推进任务状态:{} → {}", &["id", "target_status"])),
..Default::default()
},
schema: object_schema(vec![("id", "string", true), ("target_status", "string", true)]),
args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
@@ -211,6 +228,11 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"delete_task",
"删除任务(移入回收站,可恢复)",
RiskLevel::High,
meta: ToolMeta {
entity_action: Some(("task", "delete")),
display_verb: Some("删除任务"),
..Default::default()
},
schema: object_schema(vec![("id", "string", true)]),
args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
+7 -1
View File
@@ -15,7 +15,7 @@
use std::sync::Arc;
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
use df_ai::ai_tools::{object_schema, AiToolRegistry, GoalMeta, GoalStyle, RiskLevel, ToolMeta};
use df_ai::declare_tool;
/// 注册 run_workflow 工具到 `$registry`(无 db 捕获,防御兜底)。
@@ -35,6 +35,12 @@ pub fn register(registry: &mut AiToolRegistry) {
"run_workflow",
"按任务 target_status 推进对应工作流(含 AiNode 自审 / HumanNode 核对闸门)。参数 task_id + target_status 同时提供才联动任务推进(完成后按 target_status 推进任务,失败按退回态回滚)。属高风险操作(触发工作流引擎执行),须人工批准。审批通过后由后端直接执行工作流引擎并联动推进任务,返回 execution_id",
RiskLevel::High,
meta: ToolMeta {
approval_hint: Some(("触发工作流:任务{} 推进到 {}", &["task_id", "target_status"])),
display_verb: Some("触发工作流"),
goal: Some(GoalMeta { verb: "执行工作流", keys: &[], style: GoalStyle::Raw, fallback: Some("执行工作流") }),
..Default::default()
},
schema: object_schema(vec![("task_id", "string", true), ("target_status", "string", true)]),
args => {
let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?;