diff --git a/src-tauri/src/commands/ai/audit.rs b/src-tauri/src/commands/ai/audit.rs index d05406e..f3a9315 100644 --- a/src-tauri/src/commands/ai/audit.rs +++ b/src-tauri/src/commands/ai/audit.rs @@ -38,6 +38,71 @@ pub(crate) fn risk_from_str(s: &str) -> Option { } } +/// 拼审批 reason:按工具名 + args 取可读字段,让用户知道"审批要做什么"。 +/// +/// 主要审批工具特化(带对象名/标识): +/// - delete_project/restore_project/purge_project → "删除/恢复/清除项目"+id +/// - update_project → "修改项目"+field +/// - bind_directory → "绑定目录"+path +/// - create_task → "创建任务"+title;create_project → "创建项目"+name;create_idea → "捕获想法"+title +/// - run_workflow → "运行工作流"+name +/// args 无可读字段或工具未特化时,fallback 原 risk 模板(含风险等级提示)。 +fn build_approval_reason(tool_name: &str, args: &serde_json::Value, risk_level: RiskLevel) -> String { + let s = |key: &str| args.get(key).and_then(|v| v.as_str()).unwrap_or(""); + let detail = match tool_name { + "delete_project" => { + let id = s("id"); + if !id.is_empty() { format!("删除项目(ID: {})", id) } else { String::new() } + } + "restore_project" => { + let id = s("id"); + if !id.is_empty() { format!("从回收站恢复项目(ID: {})", id) } else { String::new() } + } + "purge_project" => { + let id = s("id"); + if !id.is_empty() { format!("永久删除项目及关联数据,不可恢复(ID: {})", id) } else { String::new() } + } + "update_project" => { + let field = s("field"); + if !field.is_empty() { format!("修改项目字段「{}」", field) } else { String::new() } + } + "bind_directory" => { + let path = s("path"); + if !path.is_empty() { format!("绑定目录:{}", path) } else { String::new() } + } + "create_task" => { + let title = s("title"); + if !title.is_empty() { format!("创建任务:{}", title) } else { String::new() } + } + "create_project" => { + let name = s("name"); + if !name.is_empty() { format!("创建项目:{}", name) } else { String::new() } + } + "create_idea" => { + let title = s("title"); + if !title.is_empty() { format!("捕获想法:{}", title) } else { String::new() } + } + "run_workflow" => { + let name = s("name"); + if !name.is_empty() { format!("运行工作流:{}", name) } else { String::new() } + } + _ => String::new(), + }; + if detail.is_empty() { + // fallback:无可读字段,保留风险等级提示模板 + match risk_level { + RiskLevel::High => "高风险操作,必须人工批准".to_string(), + _ => "创建操作,请确认是否执行".to_string(), + } + } else { + // 拼上风险等级后缀(高风险标注,便于用户权衡) + match risk_level { + RiskLevel::High => format!("{}(高风险,需人工批准)", detail), + _ => format!("{}(请确认是否执行)", detail), + } + } +} + /// 启动恢复:从审计表重建 pending_approvals(重启前未审批的工具调用,内存态已丢) /// /// pending_approvals 是 AiSession 内存 HashMap,重启必丢。ai_tool_executions 表已存 @@ -176,10 +241,7 @@ pub(crate) async fn process_tool_calls( recovered: false, }); session.messages.push(ChatMessage::tool_result(&draft.id, "需要用户审批,等待确认")); - let reason = match risk_level { - RiskLevel::High => "高风险操作,必须人工批准".to_string(), - _ => "创建操作,请确认是否执行".to_string(), - }; + let reason = build_approval_reason(&draft.name, &args, risk_level); let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiApprovalRequired { id: draft.id.clone(), name: draft.name.clone(), diff --git a/src-tauri/src/commands/ai/commands.rs b/src-tauri/src/commands/ai/commands.rs index 46c08b4..d6baec9 100644 --- a/src-tauri/src/commands/ai/commands.rs +++ b/src-tauri/src/commands/ai/commands.rs @@ -366,6 +366,11 @@ pub async fn ai_conversation_create(state: State<'_, AppState>) -> Result) -> AiToolRegistry { })}, ); registry.register( - "create_project", "创建新项目", - df_ai::ai_tools::object_schema(vec![("name", "string", true), ("description", "string", false)]), + "create_project", "创建新项目,可选传 path/stack 一步完成创建+绑定目录(无需再调 bind_directory)", + df_ai::ai_tools::object_schema(vec![ + ("name", "string", true), ("description", "string", false), + ("path", "string", false), ("stack", "string", false), + ]), RiskLevel::Medium, { let db = db.clone(); Box::new(move |args: serde_json::Value| { let db = db.clone(); @@ -161,7 +164,42 @@ pub fn build_ai_tool_registry(db: &Arc) -> AiToolRegistry { }; let id = record.id.clone(); repo.insert(record).await?; - Ok(serde_json::json!({ "id": id, "name": name, "status": "planning" })) + // path 缺省:保持原行为(仅创建,不绑定) + let path_opt = args.get("path").and_then(|v| v.as_str()).filter(|s| !s.is_empty()); + if let Some(path) = path_opt { + // 绑定逻辑复用自 bind_directory handler(下文):目录校验 + 防重复绑定 + spawn_blocking 探测技术栈 + // TODO: 抽公共绑定闭包/函数供 create_project 与 bind_directory 共用(当前内联以收敛改动范围) + let dir = std::path::Path::new(path); + if !dir.is_dir() { + anyhow::bail!("目录不存在: {path}"); + } + let target = df_project::scan::normalize_path(path); + let projects = repo.list_active().await?; + for proj in &projects { + if proj.id != id { + if let Some(pp) = &proj.path { + if df_project::scan::normalize_path(pp) == target { + anyhow::bail!("目录已被项目「{}」绑定", proj.name); + } + } + } + } + // stack:AI 提供则用,否则探测(detect_stack 内含多次同步 fs IO,必须 spawn_blocking) + let stack = if let Some(s) = args.get("stack").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) { + s.split(',').map(|t| t.trim().to_string()).filter(|t| !t.is_empty()).collect::>() + } else { + let dir_buf = std::path::PathBuf::from(path); + tokio::task::spawn_blocking(move || df_project::scan::detect_stack(&dir_buf)) + .await + .map_err(|e| anyhow::anyhow!("技术栈探测任务失败: {e}"))?? + }; + let stack_json = serde_json::to_string(&stack)?; + repo.update_field(&id, "path", path).await?; + repo.update_field(&id, "stack", &stack_json).await?; + Ok(serde_json::json!({ "id": id, "name": name, "status": "planning", "path": path, "stack": stack, "bound": true })) + } else { + Ok(serde_json::json!({ "id": id, "name": name, "status": "planning" })) + } }) })}, ); diff --git a/src/components/ToolCard.vue b/src/components/ToolCard.vue index ebfa440..e2e7796 100644 --- a/src/components/ToolCard.vue +++ b/src/components/ToolCard.vue @@ -31,7 +31,7 @@
- {{ arg.key }} + {{ arg.label }} {{ formatArgValue(arg.val) }}
@@ -119,6 +119,8 @@ interface ToolResult { title?: string field?: string deleted?: boolean + restored?: boolean + purged?: boolean } /** 内联 SVG 图标字符串(经 v-html 注入,常量安全;图标库改造见决策记录) */ @@ -145,10 +147,29 @@ function formatJson(data: unknown): string { } } +/** 审批参数 key → 友好标签映射(静态,不查 store);未命中回退原 key */ +const ARG_LABEL_MAP: Record = { + id: 'ID', + project_id: '项目 ID', + name: '名称', + title: '标题', + description: '描述', + field: '字段', + value: '新值', + path: '路径', + priority: '优先级', + tags: '标签', + source: '来源', +} + /** 审批参数转键值对数组(非对象/空时返回空数组,防 args 非 object 时 v-for 报错) */ -function toolArgsEntries(args: unknown): Array<{ key: string; val: unknown }> { +function toolArgsEntries(args: unknown): Array<{ key: string; label: string; val: unknown }> { if (args && typeof args === 'object' && !Array.isArray(args)) { - return Object.entries(args as Record).map(([key, val]) => ({ key, val })) + return Object.entries(args as Record).map(([key, val]) => ({ + key, + label: ARG_LABEL_MAP[key] || key, + val, + })) } return [] } @@ -282,6 +303,8 @@ function toolResultSummary(tc: AiToolCallInfo): string { case 'create_task': return r.title ? t('aiTool.createdWithName', { name: r.title }) : t('aiTool.created') case 'update_project': return r.field ? t('aiTool.updatedField', { field: r.field }) : t('aiTool.updated') case 'delete_project': return r.deleted ? t('aiTool.deleted') : t('aiTool.deleteIneffective') + case 'restore_project': return r.restored ? t('aiTool.restored') : t('aiTool.restoreIneffective') + case 'purge_project': return r.purged ? t('aiTool.purged') : t('aiTool.purgeIneffective') case 'run_workflow': return t('aiTool.workflowHint') default: return '' } diff --git a/src/i18n/en/aiTool.ts b/src/i18n/en/aiTool.ts index b323473..ec0fa6f 100644 --- a/src/i18n/en/aiTool.ts +++ b/src/i18n/en/aiTool.ts @@ -26,6 +26,10 @@ export default { updated: 'Updated', deleted: 'Deleted', deleteIneffective: 'Delete ineffective', + restored: 'Restored', + restoreIneffective: 'Restore ineffective (not in trash)', + purged: 'Purged', + purgeIneffective: 'Purge ineffective', workflowHint: 'Run it on the workflow page', }, } diff --git a/src/i18n/zh-CN/aiTool.ts b/src/i18n/zh-CN/aiTool.ts index 41a5c73..fe51fb0 100644 --- a/src/i18n/zh-CN/aiTool.ts +++ b/src/i18n/zh-CN/aiTool.ts @@ -28,6 +28,10 @@ export default { updated: '已更新', deleted: '已删除', deleteIneffective: '删除未生效', + restored: '已恢复', + restoreIneffective: '恢复未生效(项目不在回收站)', + purged: '已彻底删除', + purgeIneffective: '清除未生效', workflowHint: '请到工作流页面运行', }, }