新增: Phase2 阶段收尾(Sprint 1-20)
重构:删 5 零引用 crate(df-evolve/plugin/stages/task/traceability)+ 清死模块、ai.rs 拆 11 子 module、ai.ts 拆 6 composable、i18n 拆目录 功能:知识库全栈(df-project/scan + CRUD + 时间线 + 前端)、Settings 拆分、appSettings KV 迁移、模型池、LLM 并发 Semaphore 修复:审批持久化根治、ConditionEngine 默认拒绝、NodeRegistry unimplemented 清除、promote 补偿删除、工具结果截断 50KB、路径校验防 symlink 逃逸 文档:B-03 人工审批设计、决策记录三分档、规格契约自检、经验记录、todo 看板、PROGRESS 更新 详见 PROGRESS.md。src-tauri/儿童每日打卡应用/ 与本项目无关,已排除。
This commit is contained in:
238
src-tauri/src/commands/ai/audit.rs
Normal file
238
src-tauri/src/commands/ai/audit.rs
Normal file
@@ -0,0 +1,238 @@
|
||||
//! 工具调用审计 + pending 审批恢复 + 工具调用处理
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
||||
use df_ai::provider::ChatMessage;
|
||||
use df_storage::crud::AiToolExecutionRepo;
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::AiToolExecutionRecord;
|
||||
|
||||
use df_core::types::new_id;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use crate::commands::now_millis;
|
||||
|
||||
use super::{AiChatEvent, AiSession, PendingApproval, ToolCallDraft};
|
||||
|
||||
/// RiskLevel → 审计记录字符串(low/medium/high)
|
||||
pub(crate) fn risk_str(r: RiskLevel) -> &'static str {
|
||||
match r {
|
||||
RiskLevel::Low => "low",
|
||||
RiskLevel::Medium => "medium",
|
||||
RiskLevel::High => "high",
|
||||
}
|
||||
}
|
||||
|
||||
/// 审计记录字符串 → RiskLevel(启动重建 pending_approvals 用,未知串返回 None 跳过)
|
||||
pub(crate) fn risk_from_str(s: &str) -> Option<RiskLevel> {
|
||||
match s {
|
||||
"low" => Some(RiskLevel::Low),
|
||||
"medium" => Some(RiskLevel::Medium),
|
||||
"high" => Some(RiskLevel::High),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动恢复:从审计表重建 pending_approvals(重启前未审批的工具调用,内存态已丢)
|
||||
///
|
||||
/// pending_approvals 是 AiSession 内存 HashMap,重启必丢。ai_tool_executions 表已存
|
||||
/// status=pending 的行(持久化真相源),此处读回重建内存态,使重启后待审批不丢。
|
||||
/// 前端经 ai_pending_tool_calls 查询 + switchConversation 恢复 toolCard 的 pending_approval 态。
|
||||
pub async fn restore_pending_approvals(state: &AppState) {
|
||||
let pending = match state.ai_tool_executions.list_pending().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("启动恢复 pending 审批失败(非阻断): {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut session = state.ai_session.lock().await;
|
||||
for rec in pending {
|
||||
let args: serde_json::Value = serde_json::from_str(&rec.arguments).unwrap_or_default();
|
||||
let Some(risk) = risk_from_str(&rec.risk_level) else { continue };
|
||||
session.pending_approvals.insert(
|
||||
rec.tool_call_id.clone(),
|
||||
PendingApproval {
|
||||
tool_call_id: rec.tool_call_id,
|
||||
tool_name: rec.tool_name,
|
||||
arguments: args,
|
||||
risk_level: risk,
|
||||
conversation_id: rec.conversation_id,
|
||||
recovered: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
tracing::info!("启动恢复: {} 条 pending 工具审批重建到内存", session.pending_approvals.len());
|
||||
}
|
||||
|
||||
/// 写一条工具执行审计记录(insert 失败不阻断主流程,故 `let _ =`)
|
||||
///
|
||||
/// `decided_by` 有值(auto/human)= 已决策执行 → 记 executed_at;
|
||||
/// `None`(pending 待审批)→ executed_at 留空,待 audit_finalize 回填。
|
||||
pub(crate) async fn audit_tool_call(
|
||||
repo: &AiToolExecutionRepo,
|
||||
conv_id: &str,
|
||||
tool_call_id: &str,
|
||||
tool_name: &str,
|
||||
arguments: &str,
|
||||
status: &str,
|
||||
risk_level: RiskLevel,
|
||||
result: Option<String>,
|
||||
decided_by: Option<&str>,
|
||||
) {
|
||||
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None };
|
||||
let _ = repo
|
||||
.insert(AiToolExecutionRecord {
|
||||
id: new_id(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
tool_call_id: tool_call_id.to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
arguments: arguments.to_string(),
|
||||
result,
|
||||
status: status.to_string(),
|
||||
risk_level: risk_str(risk_level).to_string(),
|
||||
requested_at: now_millis(),
|
||||
executed_at,
|
||||
decided_by: decided_by.map(|s| s.to_string()),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// 审批后更新审计记录状态(按 tool_call_id 定位 pending 记录,回填 status/decided_by=human/executed_at/result)
|
||||
///
|
||||
/// 走专用 find_by_tool_call_id —— 通用 query 宏硬编码 ORDER BY created_at DESC,
|
||||
/// 而 ai_tool_executions 无该列,调用会报 "no such column: created_at" 被 unwrap_or_default 吞掉,
|
||||
/// 导致审批后审计记录永久卡 pending。
|
||||
pub(crate) async fn audit_finalize(state: &AppState, tool_call_id: &str, status: &str, result: Option<String>) {
|
||||
let Some(mut rec) = state
|
||||
.ai_tool_executions
|
||||
.find_by_tool_call_id(tool_call_id)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
else {
|
||||
tracing::warn!("audit_finalize: 未找到 tool_call_id={} 的审计记录", tool_call_id);
|
||||
return;
|
||||
};
|
||||
rec.status = status.to_string();
|
||||
rec.decided_by = Some("human".to_string());
|
||||
rec.executed_at = Some(now_millis());
|
||||
if let Some(r) = result {
|
||||
rec.result = Some(r);
|
||||
}
|
||||
let _ = state.ai_tool_executions.update_full(&rec).await;
|
||||
}
|
||||
|
||||
/// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控
|
||||
/// 返回待审批的工具数量(0 = 全部自动执行完成)
|
||||
pub(crate) async fn process_tool_calls(
|
||||
session: &mut AiSession,
|
||||
tool_calls_acc: HashMap<u32, ToolCallDraft>,
|
||||
tools_arc: &Arc<AiToolRegistry>,
|
||||
db: &Arc<Database>,
|
||||
app_handle: &AppHandle,
|
||||
conv_id: &str,
|
||||
) -> usize {
|
||||
let mut tc_list: Vec<_> = tool_calls_acc.into_iter().collect();
|
||||
tc_list.sort_unstable_by_key(|(i, _)| *i);
|
||||
let mut pending_count = 0usize;
|
||||
let audit_repo = AiToolExecutionRepo::new(db);
|
||||
|
||||
// 解析 args + 批量发 Started(前端骨架按原始 index 顺序展示)
|
||||
let drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = tc_list.into_iter()
|
||||
.map(|(idx, draft)| {
|
||||
let args = serde_json::from_str(&draft.args).unwrap_or(serde_json::Value::Object(Default::default()));
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallStarted {
|
||||
id: draft.id.clone(),
|
||||
name: draft.name.clone(),
|
||||
args: args.clone(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
(idx, draft, args)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 分类:Low 收集并行执行,Med/High 立即进审批门控(push 占位 tool_result)
|
||||
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 => {
|
||||
pending_count += 1;
|
||||
session.pending_approvals.insert(draft.id.clone(), PendingApproval {
|
||||
tool_call_id: draft.id.clone(),
|
||||
tool_name: draft.name.clone(),
|
||||
arguments: args.clone(),
|
||||
risk_level,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
recovered: false,
|
||||
});
|
||||
session.messages.push(ChatMessage::tool_result(&draft.id, "需要用户审批,等待确认"));
|
||||
let reason = match risk_level {
|
||||
RiskLevel::High => "高风险操作,必须人工批准".to_string(),
|
||||
_ => "创建操作,请确认是否执行".to_string(),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiApprovalRequired {
|
||||
id: draft.id.clone(),
|
||||
name: draft.name.clone(),
|
||||
args: args.clone(),
|
||||
reason,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Low 风险并行执行:execute + 即时 emit 在闭包内(不持 session 锁),
|
||||
// push tool_result / audit 在 join_all 后串行回填(持锁,与 Med/High 占位拼接)。
|
||||
// join_all 保序——结果顺序 = low_risk 输入顺序 = tc_list 原始 index 顺序,不额外 sort
|
||||
if !low_risk.is_empty() {
|
||||
let results: Vec<(ToolCallDraft, Result<String, String>)> =
|
||||
futures::future::join_all(low_risk.into_iter().map(|(draft, args)| {
|
||||
let tools = tools_arc.clone();
|
||||
let app_clone = app_handle.clone();
|
||||
let conv_clone = conv_id.to_string();
|
||||
async move {
|
||||
let result = tools.execute(&draft.name, args).await;
|
||||
match result {
|
||||
Ok(val) => {
|
||||
let _ = app_clone.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: val.clone(),
|
||||
conversation_id: Some(conv_clone),
|
||||
});
|
||||
(draft, Ok(val.to_string()))
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = app_clone.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
error: format!("工具 {} 执行失败: {}", draft.name, e),
|
||||
conversation_id: Some(conv_clone),
|
||||
});
|
||||
(draft, Err(format!("错误: {}", e)))
|
||||
}
|
||||
}
|
||||
}
|
||||
})).await;
|
||||
|
||||
// 串行回填 tool_result + 审计(持 session 锁)
|
||||
for (draft, outcome) in results {
|
||||
let (status, content) = match outcome {
|
||||
Ok(c) => ("completed", c),
|
||||
Err(c) => ("failed", c),
|
||||
};
|
||||
session.messages.push(ChatMessage::tool_result(&draft.id, content.clone()));
|
||||
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, RiskLevel::Low, Some(content), Some("auto")).await;
|
||||
}
|
||||
}
|
||||
|
||||
pending_count
|
||||
}
|
||||
Reference in New Issue
Block a user