Files
DevFlow/src-tauri/src/commands/ai/audit/record.rs
T
lxy e68d283651 优化: UI/UX遗留批(审计/AI命令层/前端组件,会话前基线收尾)
- AuditLog +298(筛选/详情/i18n)+ audit 后端 record/mod

- AI 命令层:generate_image +81 / fetch_url / fetch_search / skills / tool_registry / tools/file / provider / conversation

- 前端组件:AiChat/TopBar/ConversationSidebar/GitChanges/ApprovalPopup/Dashboard/ProjectDetail 等 30+ + composables + i18n

- 诊断文档: aichat历史会话实证诊断-2026-08-04 + project_soft_delete 测试
2026-08-05 22:15:34 +08:00

226 lines
8.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 审计记录写/查操作 + 审批历史 DTO + 审计面板查询 IPC。
//!
//! 第六批从 audit/mod.rs 抽离,行为零变更。包含:
//! - `audit_tool_call`:写一条工具执行审计记录(insert)
//! - `record_audit``audit_tool_call` 的别名,语义更清晰("记一条审计")
//! - `query_audit_history`:按条件查询审计历史记录
//! - `ToolExecutionDto`:传给前端的精简审计视图
//! - `list_tool_executions`:审批历史面板查询 IPC
//!
//! 依赖 audit/utils.rs 的 `truncate_chars` 做参数/结果截断,通过 `super::truncate_chars` 引用。
use serde::{Deserialize, Serialize};
use tauri::State;
use df_ai::ai_tools::RiskLevel;
use df_storage::crud::{AiToolExecutionRepo, AuditQuery};
use df_storage::models::AiToolExecutionRecord;
use df_types::types::new_id;
use crate::commands::err_str;
use crate::commands::now_millis;
use crate::state::AppState;
use super::risk_str;
/// 写一条工具执行审计记录(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>,
message_id: Option<&str>,
) {
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None };
if let Err(e) = repo
.insert(AiToolExecutionRecord {
id: new_id(),
conversation_id: Some(conv_id.to_string()),
// P1 消息级溯源:message_id 由调用方(process_tool_calls)从
// ContextManager 取当前 assistant 消息 id 传入(LLM 返回带 tool_calls 的
// assistant 消息已 push 到 per_conv.messages,入口取末条 assistant id)。
// None 表示无 assistant 消息(异常路径/老数据无 id),展示侧兼容。
message_id: message_id.map(|s| s.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
{
tracing::error!(
"audit_tool_call: 写审计记录失败(conv={}, tool_call_id={}, tool={}): {}",
conv_id,
tool_call_id,
tool_name,
e
);
}
}
/// `audit_tool_call` 的语义别名,功能完全相同。
/// 命名更符合"记录一条审计"的调用意图,供新代码使用。
#[allow(dead_code)]
pub(crate) async fn record_audit(
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>,
message_id: Option<&str>,
) {
audit_tool_call(repo, conv_id, tool_call_id, tool_name, arguments, status, risk_level, result, decided_by, message_id).await;
}
/// 查询审计历史记录(分页,按 requested_at 倒序)。
///
/// 封装 `list_recent` 添加一层可读语义,方便未来扩展筛选条件。
#[allow(dead_code)]
pub(crate) async fn query_audit_history(
repo: &AiToolExecutionRepo,
limit: u32,
offset: u32,
) -> Result<Vec<AiToolExecutionRecord>, String> {
let limit = limit.min(200);
repo.list_recent(limit, offset)
.await
.map_err(|e| format!("query_audit_history 查询失败: {}", e))
}
/// 审批历史 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/autoNone 表示尚未决策
pub decided_by: Option<String>,
}
/// 审批历史查询入参(前端透传,空值=不过滤)。
///
/// 复用 [`AuditQuery`](`df_storage::crud::AuditQuery`) 的字段语义:status/risk_level 精确匹配,
/// tool_keyword 走 tool_name LIKE。limit/offset 默认 50/0,storage 层钳制 limit ≤ 200。
///
/// `Deserialize`:Tauri IPC 从前端 JSON 反序列化。
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ToolExecQuery {
/// 状态精确匹配(pending/approved/rejected/executing/completed/failed/interrupted)
pub status: Option<String>,
/// 风险等级精确匹配(low/medium/high)
pub risk_level: Option<String>,
/// 工具名关键词(tool_name LIKE %kw%)
pub tool_keyword: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
impl From<ToolExecQuery> for AuditQuery {
fn from(q: ToolExecQuery) -> Self {
AuditQuery {
status: q.status,
risk_level: q.risk_level,
tool_keyword: q.tool_keyword,
limit: q.limit,
offset: q.offset,
}
}
}
/// 审批历史分页结果(对标项目通用 `{items,total,has_more}` 结构)。
///
/// - `items`:当前页审计 DTO 列表
/// - `total`:满足筛选条件的总行数(忽略分页裁剪,前端用于"第 N 页 / 共 M 条"展示)
/// - `has_more`:基于 `loaded < total` 推断,而非"本页是否满 limit"启发式
#[derive(Debug, Clone, Serialize)]
pub struct ToolExecutionPage {
pub items: Vec<ToolExecutionDto>,
pub total: i64,
pub has_more: bool,
}
/// 审批历史面板查询:按 requested_at 倒序(最新在前)分页返回工具调用审计记录。
///
/// 支持 status / risk_level / 工具名关键词筛选(WHERE 在后端收口,非前端 filter 当前页)。
/// 默认 limit=50 / offset=0(第一页)。limit 在 storage 层钳制 ≤200 防滥用。
/// 敏感字段(arguments/result)截断成摘要返回,完整原值仍留库。
///
/// 返回 `{items,total,has_more}`:total 为满足筛选条件的真实总数(独立 COUNT 查询),
/// has_more 基于 `offset + items.len() < total` 推断。
#[tauri::command]
pub async fn list_tool_executions(
state: State<'_, AppState>,
query: Option<ToolExecQuery>,
) -> Result<ToolExecutionPage, String> {
let q = query.unwrap_or_default();
let _limit = q.limit.unwrap_or(50);
let offset = q.offset.unwrap_or(0);
let audit_q = AuditQuery::from(q);
let records = state
.ai_tool_executions
.list_by_query(&audit_q)
.await
.map_err(err_str)?;
let total = state
.ai_tool_executions
.count_by_query(&audit_q)
.await
.map_err(err_str)?;
let items: Vec<ToolExecutionDto> = 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: super::truncate_chars(&r.arguments, 120),
result_brief: r.result.map(|s| super::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();
let has_more = (offset as i64 + items.len() as i64) < total;
Ok(ToolExecutionPage {
items,
total,
has_more,
})
}