新增: AI Chat多项增强(审批去重/编辑重发/导出/实体引用/会话置顶搜索)+任务推进链df-nodes落地
This commit is contained in:
@@ -3,7 +3,8 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
||||
use df_ai::provider::ChatMessage;
|
||||
@@ -15,7 +16,7 @@ use df_core::types::new_id;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use crate::commands::now_millis;
|
||||
use crate::commands::{err_str, now_millis};
|
||||
|
||||
use super::{AiChatEvent, AiSession, PendingApproval, ToolCallDraft};
|
||||
|
||||
@@ -38,10 +39,87 @@ pub(crate) fn risk_from_str(s: &str) -> Option<RiskLevel> {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 审批历史查询 IPC(AE-2025-08)
|
||||
// ============================================================
|
||||
|
||||
/// 审批历史 DTO(传给前端的精简视图,敏感字段截断防泄露)
|
||||
///
|
||||
/// arguments/result 在落库时是完整 JSON(可能含项目名/路径/长结果),审计面板只展示摘要,
|
||||
/// 故截断到固定长度(参数 120 / 结果 160),既保留可读性又不泄露全量数据到前端 DOM。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ToolExecutionDto {
|
||||
pub id: String,
|
||||
pub conversation_id: Option<String>,
|
||||
pub tool_call_id: String,
|
||||
pub tool_name: String,
|
||||
/// 参数摘要(截断 120 字符,完整原值仍留库)
|
||||
pub arguments_brief: String,
|
||||
/// 结果摘要(截断 160 字符,None → 空串便于前端展示)
|
||||
pub result_brief: Option<String>,
|
||||
/// pending/approved/rejected/executing/completed/failed
|
||||
pub status: String,
|
||||
/// low/medium/high
|
||||
pub risk_level: String,
|
||||
pub requested_at: String,
|
||||
pub executed_at: Option<String>,
|
||||
/// human/auto,None 表示尚未决策
|
||||
pub decided_by: Option<String>,
|
||||
}
|
||||
|
||||
/// 截断字符串到 max 字符(按 char_indices 边界切,避免切坏中文/emoji)
|
||||
fn truncate_chars(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out: String = s.chars().take(max).collect();
|
||||
out.push('…');
|
||||
out
|
||||
}
|
||||
|
||||
/// 审批历史面板查询:按 requested_at 倒序(最新在前)分页返回工具调用审计记录。
|
||||
///
|
||||
/// 默认 limit=50 / offset=0(第一页)。limit 在 storage 层钳制 ≤200 防滥用。
|
||||
/// 敏感字段(arguments/result)截断成摘要返回,完整原值仍留库。
|
||||
#[tauri::command]
|
||||
pub async fn list_tool_executions(
|
||||
state: State<'_, AppState>,
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
) -> Result<Vec<ToolExecutionDto>, String> {
|
||||
let limit = limit.unwrap_or(50);
|
||||
let offset = offset.unwrap_or(0);
|
||||
let records = state
|
||||
.ai_tool_executions
|
||||
.list_recent(limit, offset)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
Ok(records
|
||||
.into_iter()
|
||||
.map(|r| ToolExecutionDto {
|
||||
id: r.id,
|
||||
conversation_id: r.conversation_id,
|
||||
tool_call_id: r.tool_call_id,
|
||||
tool_name: r.tool_name,
|
||||
arguments_brief: truncate_chars(&r.arguments, 120),
|
||||
result_brief: r.result.map(|s| truncate_chars(&s, 160)),
|
||||
status: r.status,
|
||||
risk_level: r.risk_level,
|
||||
requested_at: r.requested_at,
|
||||
executed_at: r.executed_at,
|
||||
decided_by: r.decided_by,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
|
||||
/// 查项目可读标签:id → "「项目名」(id=x)",查不到回退友好提示,空 id 返回空串。
|
||||
///
|
||||
/// AR-3:审批卡片需显示对象名而非裸 id(用户反馈"只返回 ID 不知道是什么数据")。
|
||||
/// 查不到(项目已被彻底清除/外部 id)时给"项目已不存在"提示而非裸 id,避免用户困惑。
|
||||
/// 三臂区分:
|
||||
/// - Ok(Some) → 项目名标签
|
||||
/// - Ok(None) → "项目已不存在"(真不存在,项目已彻底清除/外部 id)
|
||||
/// - Err → 裸 id 回退 + warn 日志(DB 故障/锁/连接断,不误报"已不存在"误导用户)
|
||||
async fn resolve_project_label(db: &Arc<Database>, id: &str) -> String {
|
||||
if id.is_empty() {
|
||||
return String::new();
|
||||
@@ -49,7 +127,11 @@ async fn resolve_project_label(db: &Arc<Database>, id: &str) -> String {
|
||||
let repo = ProjectRepo::new(db);
|
||||
match repo.get_by_id(id).await {
|
||||
Ok(Some(p)) => format!("「{}」(id={})", p.name, id),
|
||||
_ => format!("(项目已不存在, id={})", id),
|
||||
Ok(None) => format!("(项目已不存在, id={})", id),
|
||||
Err(e) => {
|
||||
tracing::warn!("resolve_project_label: 查询项目 id={} 失败,回退裸 id: {}", id, e);
|
||||
format!("(id={})", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,6 +378,119 @@ pub(crate) fn emit_data_changed(app_handle: &AppHandle, tool_name: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// F-260616-05:高危工具去重(根治 run_command 超时→重试→重新审批循环)。
|
||||
///
|
||||
/// **根因链**(F-04 batch42 已做超时标注):LLM 调 run_command 超时 → F-04 把超时标注成
|
||||
/// tool_result 回传 LLM → LLM(不可靠)仍重试同命令 → 新 tool_call_id(provider 每轮新 id)
|
||||
/// → `process_tool_calls` 重新 insert pending(audit.rs:418) → 用户被迫重新审批,循环。
|
||||
///
|
||||
/// **治本(F-05)**:即使 LLM 仍重试,同 (tool_name, args) 不重复审批。本函数扫描会话历史,
|
||||
/// 若发现**已落定**(executed/failed/rejected,非 pending)的同类同参高危调用,返回其
|
||||
/// tool_result 内容,调用方把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM,跳过审批。
|
||||
///
|
||||
/// **安全边界**:
|
||||
/// - 仅 High risk(循环源头);Low/Med 不去重(Low 直行无审批,Med 重试场景少且去重易误伤)。
|
||||
/// - 仅匹配**已落定**结果(pending 的不命中——pending 已有独立流程,不会触发循环;
|
||||
/// 且避免两个 pending 互相吞掉审批)。LLM 只有在收到 tool_result 后才会重试,
|
||||
/// 故循环必然是「上一条已落定 → 重试」形态,pending 不命中不影响治本。
|
||||
/// - args 走 JSON 规范化比较(键序无关),避免 LLM 两次生成键序不同误判为不同命令。
|
||||
/// - 返回 None 表示无缓存命中(走原审批流程)。
|
||||
///
|
||||
/// `session` 只读扫描 messages(不写),调用方据返回值决定是否跳过 insert pending。
|
||||
fn find_cached_high_risk_result(
|
||||
session: &AiSession,
|
||||
tool_name: &str,
|
||||
args: &serde_json::Value,
|
||||
) -> Option<String> {
|
||||
use df_ai::provider::MessageRole;
|
||||
|
||||
// 规范化新调用的 args 为可比字符串(排序键,键序无关)
|
||||
let new_args_key = canonical_args_key(args);
|
||||
|
||||
// ContextManager::iter 返回 impl Iterator(非 DoubleEnded),collect 成 Vec 再反向遍历。
|
||||
// 单对话消息量小(百级),collect 开销可忽略。
|
||||
let msgs: Vec<&ChatMessage> = session.messages.iter().collect();
|
||||
|
||||
// 1) 反向扫描 assistant tool_calls,找最近一条同名同参的 High 工具调用 → 拿到旧 tool_call_id
|
||||
// 反向:循环是「最近一次超时→重试」,命中通常是末尾附近,反向先停省全扫。
|
||||
let mut prev_tool_call_id: Option<String> = None;
|
||||
for msg in msgs.iter().rev() {
|
||||
if !matches!(msg.role, MessageRole::Assistant) {
|
||||
continue;
|
||||
}
|
||||
let Some(tcs) = msg.tool_calls.as_ref() else { continue };
|
||||
for tc in tcs {
|
||||
if tc.function.name != tool_name {
|
||||
continue;
|
||||
}
|
||||
// 旧调用的 args 是流式拼接的 JSON 字符串,解析失败跳过(不误判为命中)
|
||||
let Ok(old_args) = serde_json::from_str::<serde_json::Value>(&tc.function.arguments) else {
|
||||
continue;
|
||||
};
|
||||
if canonical_args_key(&old_args) == new_args_key {
|
||||
prev_tool_call_id = Some(tc.id.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
if prev_tool_call_id.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 用旧 tool_call_id 找对应 tool_result。注意:审批拒绝/超时失败也属「已落定」,
|
||||
// 其 tool_result 内容同样回传(LLM 看到原反馈自行决定,不再逼用户二次审批)。
|
||||
// pending 占位(「需要用户审批,等待确认」)不命中——仍在审批中,走原流程。
|
||||
let old_id = prev_tool_call_id?;
|
||||
for msg in msgs.iter().rev() {
|
||||
if !matches!(msg.role, MessageRole::Tool) {
|
||||
continue;
|
||||
}
|
||||
if msg.tool_call_id.as_deref() != Some(old_id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
// 命中旧 tool_result:排除 pending 占位(内容固定为「需要用户审批,等待确认」)
|
||||
if msg.content == "需要用户审批,等待确认" {
|
||||
return None;
|
||||
}
|
||||
return Some(msg.content.clone());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 把 JSON args 规范化为可比字符串:对象键按字典序排序后序列化,
|
||||
/// 键序不同的等价参数生成同一 key(防 LLM 两次生成键序不同误判为不同命令)。
|
||||
fn canonical_args_key(args: &serde_json::Value) -> String {
|
||||
let mut v = args.clone();
|
||||
sort_object_keys(&mut v);
|
||||
// 紧凑序列化(无空白),保证稳定可比
|
||||
serde_json::to_string(&v).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 递归对 JSON 对象的键做字典序排序(就地),数组成员也递归排序。
|
||||
fn sort_object_keys(v: &mut serde_json::Value) {
|
||||
match v {
|
||||
serde_json::Value::Object(map) => {
|
||||
// BTreeMap 按键排序,重建 Object
|
||||
let mut entries: Vec<(String, serde_json::Value)> = map
|
||||
.iter()
|
||||
.map(|(k, val)| (k.clone(), val.clone()))
|
||||
.collect();
|
||||
entries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
map.clear();
|
||||
for (k, mut val) in entries {
|
||||
sort_object_keys(&mut val);
|
||||
map.insert(k, val);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(arr) => {
|
||||
for item in arr {
|
||||
sort_object_keys(item);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控
|
||||
/// 返回待审批的工具数量(0 = 全部自动执行完成)
|
||||
pub(crate) async fn process_tool_calls(
|
||||
@@ -326,12 +521,37 @@ pub(crate) async fn process_tool_calls(
|
||||
.collect();
|
||||
|
||||
// 分类:Low 收集并行执行,Med/High 立即进审批门控(push 占位 tool_result)
|
||||
//
|
||||
// F-260616-05:High risk 在进审批门前先查去重缓存(find_cached_high_risk_result)。
|
||||
// 若 LLM 重试同命令(同 tool_name + 同 args,键序无关),命中已落定的旧 tool_result,
|
||||
// 把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM,跳过 insert pending + 跳过审批,
|
||||
// 断「超时→重试→重新审批」循环。Med 不去重(去重易误伤),Low 无审批本就不进此分支。
|
||||
let mut low_risk: Vec<(ToolCallDraft, serde_json::Value)> = Vec::new();
|
||||
for (_, draft, args) in drafts {
|
||||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||||
match risk_level {
|
||||
RiskLevel::Low => low_risk.push((draft, args)),
|
||||
RiskLevel::Medium | RiskLevel::High => {
|
||||
// F-05:仅 High 查去重缓存;Med 保持原审批流程
|
||||
if matches!(risk_level, RiskLevel::High) {
|
||||
if let Some(cached) = find_cached_high_risk_result(session, &draft.name, &args) {
|
||||
// 命中:把缓存结果作为新 tool_call_id 的 tool_result 回传,跳过审批
|
||||
tracing::info!(
|
||||
tool = %draft.name,
|
||||
new_tool_call_id = %draft.id,
|
||||
"[F-05] 高危工具去重命中:LLM 重试同命令,复用缓存结果跳过审批(断循环)"
|
||||
);
|
||||
session.messages.push(ChatMessage::tool_result(&draft.id, &cached));
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: serde_json::Value::String(cached.clone()),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
// 审计:去重命中记一条 completed(decided_by=auto_dedup),不进 pending
|
||||
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "completed", risk_level, Some(cached), Some("auto_dedup")).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
pending_count += 1;
|
||||
session.pending_approvals.insert(draft.id.clone(), PendingApproval {
|
||||
tool_call_id: draft.id.clone(),
|
||||
|
||||
Reference in New Issue
Block a user