From 6b68aa63e80786f3f45e30957df832f8a3788851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sat, 1 Aug 2026 13:30:05 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20tool=5Fregistry=20?= =?UTF-8?q?=E5=89=A9=E4=BD=99=2021=20=E5=B7=A5=E5=85=B7=E8=BF=81=E5=A3=B0?= =?UTF-8?q?=E6=98=8E=E5=BC=8F(task=5Fgraph/git/http/workflow/idea/trash)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/ai/tool_registry.rs | 915 +----------------- src-tauri/src/commands/ai/tools/git.rs | 340 +++++++ src-tauri/src/commands/ai/tools/http.rs | 63 ++ src-tauri/src/commands/ai/tools/idea.rs | 87 ++ src-tauri/src/commands/ai/tools/mod.rs | 6 + src-tauri/src/commands/ai/tools/task_graph.rs | 447 +++++++++ src-tauri/src/commands/ai/tools/trash.rs | 50 + src-tauri/src/commands/ai/tools/workflow.rs | 55 ++ 8 files changed, 1101 insertions(+), 862 deletions(-) create mode 100644 src-tauri/src/commands/ai/tools/git.rs create mode 100644 src-tauri/src/commands/ai/tools/http.rs create mode 100644 src-tauri/src/commands/ai/tools/idea.rs create mode 100644 src-tauri/src/commands/ai/tools/task_graph.rs create mode 100644 src-tauri/src/commands/ai/tools/trash.rs create mode 100644 src-tauri/src/commands/ai/tools/workflow.rs diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index 569f962..745b249 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -6,22 +6,23 @@ use std::sync::{Arc, LazyLock}; use tokio::sync::{Mutex as TokioMutex, RwLock}; -use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; +use df_ai::ai_tools::AiToolRegistry; use df_execute::shell::{execute, ShellRequest}; use df_storage::db::Database; -use df_storage::models::{ProjectServiceRecord, IdeaRecord}; -use df_types::types::{new_id, IdeaStatus}; -// TaskRecord/TaskStatus 原 register_task_tools 用(任务工具已迁 tools/task.rs,本文件 lib 不再引用); -// 与 ProjectRecord/ProjectStatus 不同,测试模块也无引用,故直接从 import 移除(非 #[cfg(test)] 保留)。 -// ProjectRecord/ProjectStatus 仅 #[cfg(test)] 用(项目工具已迁 tools/project.rs,lib 不再引用, -// 迁移前同模块 import 会触发 unused warning,故移到 test-only 条件 import)。 +// ProjectServiceRecord/IdeaRecord/new_id/IdeaStatus 原 register_task_graph_tools/register_idea_tools 用 +// (知识图谱/灵感工具已迁 tools/task_graph.rs、tools/idea.rs,本文件 lib 不再引用), +// 测试模块也无引用,故直接从 import 移除(非 #[cfg(test)] 保留)。 +// RiskLevel 原 register_* 内联手写 register 用(已全迁声明式 tools/*,lib 不再引用)。 +// ProjectRecord/ProjectStatus/now_millis 仅 #[cfg(test)] 用(项目工具已迁 tools/project.rs, +// lib 不再引用,迁移前同模块 import 会触发 unused warning,故移到 test-only 条件 import)。 #[cfg(test)] use df_storage::models::ProjectRecord; #[cfg(test)] use df_types::types::ProjectStatus; - +#[cfg(test)] use crate::commands::now_millis; + use crate::state::AllowedDirs; /// CRUD list 工具的默认返回上限(防 LLM context 膨胀) @@ -466,30 +467,13 @@ pub fn build_ai_tool_registry( /// 注意:一个工具名两种 risk 不支持(register 单一 risk_level),故按最高风险 High 注册。 // (写方法 High 兜底;GET 也走 High 审批更保守 — 宁可多审批不漏副作用。) // 设计权衡见 http.rs 模块注释。若未来需 GET 走 Medium 自动,需拆 get_request/post_request 两工具。 +/// +/// tool_registry 拆分:http_request 迁声明式(tools/http.rs),handler body 逐字照搬原 +/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托 +/// super::tools::http::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count +/// 仍断言 48 总量 + 工具名集合稳定。 fn register_http_tools(registry: &mut AiToolRegistry) { - registry.register( - "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)。安全:仅 http/https 协议,拒绝私网/保留 IP(SSRF 防护含 DNS resolve 后校验防重绑定),重定向≤3 跳且每跳重校验。响应 body 截断 50KB。GET 为只读但触发外发网络,POST/PUT/PATCH/DELETE 有副作用,统一按高风险须人工批准", - // schema:headers 是对象 map,object_schema 仅支持扁平标量三元组,故手工拼 - { - let mut props = serde_json::Map::new(); - props.insert("method".into(), serde_json::json!({ "type": "string", "description": "HTTP 方法:GET/POST/PUT/PATCH/DELETE(默认 GET)", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] })); - props.insert("url".into(), serde_json::json!({ "type": "string", "description": "请求 URL,仅 http/https,拒私网/localhost(SSRF 防护)" })); - props.insert("headers".into(), serde_json::json!({ "type": "object", "description": "请求头 map,如 {\"Authorization\":\"Bearer xxx\",\"Content-Type\":\"application/json\"}", "additionalProperties": { "type": "string" } })); - props.insert("body".into(), serde_json::json!({ "type": "string", "description": "请求体(POST/PUT/PATCH 用),原样发送,Content-Type 须在 headers 显式指定" })); - props.insert("timeout_secs".into(), serde_json::json!({ "type": "integer", "description": "超时秒数(默认 30,上限 60)", "minimum": 1, "maximum": 60 })); - props.insert("parse".into(), serde_json::json!({ "type": "string", "description": "响应 body 解析:json(pretty 格式化)/text(原样)/auto(按 Content-Type 自动,默认)", "enum": ["json", "text", "auto"] })); - serde_json::json!({ - "type": "object", - "properties": props, - "required": ["url"], - }) - }, - RiskLevel::High, - Box::new(|args: serde_json::Value| Box::pin(async move { - // 转调 http.rs handler(SSRF 防护 + 重定向 + 截断全在那) - super::http::execute_http_request(args).await - })), - ); + super::tools::http::register(registry); } /// 数据层 AI 工具注册(25 个持 db 的 CRUD/状态机/工作流/知识图谱工具)——从 build_ai_tool_registry 抽出。 @@ -556,9 +540,9 @@ fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc) { super::tools::task::register(registry, db); } -/// 工作流类 AI 工具注册(1 个:run_workflow)——不持 db(handler 防御兜底,详见注释)。 -/// 知识图谱 Phase 1+2 AI 工具注册(7 个:task_link CRUD + 父子树 + 跨池移动 + content 更新 -/// + 项目事件流查询) —— 持 db:Arc,对标设计 §五 Phase1/Phase2 AI 工具表 + §2.1/§2.2/§2.4。 +/// 知识图谱 / 工程系统类 AI 工具注册(10 个:task_link CRUD + 父子树 + 跨池移动 + content 更新 +/// + 项目事件流查询 + 项目基础设施配置 + 工程列表) —— 持 db:Arc,对标设计 §五 +/// Phase1/Phase2/Phase3 AI 工具表 + §2.1/§2.2/§2.3/§2.4 + 工程系统。 /// /// 工具直接调 df_storage::crud Repo(与 register_task_tools 同源模式,经 db Arc 重建 Repo), /// 复用 IPC 层(commands::task)的底层 Repo 方法,不重复实现业务校验: @@ -568,853 +552,60 @@ fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc) { /// - content 更新:走通用 update_field(content_json 已在 tasks 白名单) + JSON 合法性校验。 /// /// 风险等级对标设计 §五:link 写=Medium / 只读=Low / queue 移动=Medium / content 更新=Medium。 +/// +/// tool_registry 拆分:10 个知识图谱/工程系统工具全部迁声明式(tools/task_graph.rs), +/// handler body 逐字照搬原 async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。 +/// 本函数改 thin 委托 super::tools::task_graph::register(...)。基线测试 +/// test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。 fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc) { - // ── create_task_link (Medium,设计 §2.2 + §五) ── - // 建立任务横向关联。TaskLinkRepo::create_link 内含: - // link_type 白名单(depends_on/blocks/relates_to)+ 自环拒绝 + depends_on BFS 循环依赖检测。 - // handler 仅转发参数 + new_id,业务校验全部下沉 Repo 层(单一真相源,与 IPC create_task_link 同源)。 - registry.register( - "create_task_link", "建立任务横向关联(AI 拓扑排序编排调度基础)。参数:task_id(source 任务,关联发起方)、target_id(target 任务,关联指向方)、link_type(depends_on 依赖/blocks 阻塞/relates_to 弱关联)、remark(可选备注)。约束:link_type 白名单校验;source==target 自环拒绝;depends_on 链 BFS 循环依赖检测(A→B→A 拒绝);跨项目允许。返回 link id", - df_ai::ai_tools::object_schema(vec![ - ("task_id", "string", true), ("target_id", "string", true), - ("link_type", "string", true), ("remark", "string", false), - ]), - RiskLevel::Medium, - { let db = db.clone(); Box::new(move |args: serde_json::Value| { - let db = db.clone(); - Box::pin(async move { - let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?; - let target_id = args["target_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 target_id"))?; - let link_type = args["link_type"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 link_type"))?; - let remark = args.get("remark").and_then(|v| v.as_str()).filter(|s| !s.is_empty()); - let repo = df_storage::crud::TaskLinkRepo::new(&db); - let id = new_id(); - let link_id = repo.create_link(&id, task_id, target_id, link_type, remark).await?; - Ok(serde_json::json!({ "id": link_id, "task_id": task_id, "target_id": target_id, "link_type": link_type })) - }) - })}, - ); - - // ── remove_task_link (Medium,设计 §2.2 + §五) ── - // 解除任务关联(按 link id)。返回是否命中(deleted=false=link 不存在)。 - registry.register( - "remove_task_link", "解除任务关联(按 link id)。返回 deleted 布尔(是否命中,link 不存在则 false)", - df_ai::ai_tools::object_schema(vec![("id", "string", true)]), - RiskLevel::Medium, - { let db = db.clone(); Box::new(move |args: serde_json::Value| { - let db = db.clone(); - Box::pin(async move { - let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; - let repo = df_storage::crud::TaskLinkRepo::new(&db); - let deleted = repo.delete(id).await?; - Ok(serde_json::json!({ "deleted": deleted, "id": id })) - }) - })}, - ); - - // ── list_task_links (Low 只读,设计 §2.2 + §五) ── - // 查询任务关联(方向:outgoing 作为 source / incoming 作为 target / both 双向合并去重)。 - // AI 编排调度用:取某任务的全部依赖/阻塞/关联,做拓扑排序。 - registry.register( - "list_task_links", "查询任务关联(AI 编排调度用)。参数:task_id(目标任务)、direction(查询方向 outgoing=作为 source 查我依赖谁 / incoming=作为 target 查谁依赖我 / both=双向合并去重,默认 both)。返回关联记录列表(按 created_at 升序)", - df_ai::ai_tools::object_schema(vec![("task_id", "string", true), ("direction", "string", false)]), - RiskLevel::Low, - { let db = db.clone(); Box::new(move |args: serde_json::Value| { - let db = db.clone(); - Box::pin(async move { - let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?; - // 方向默认 both(对齐 commands::task::LinkDirection::default)。空串/未传走 both。 - let direction = args.get("direction").and_then(|v| v.as_str()).unwrap_or("both").to_lowercase(); - let repo = df_storage::crud::TaskLinkRepo::new(&db); - let links = match direction.as_str() { - "outgoing" => repo.get_by_source(task_id).await?, - "incoming" => repo.get_by_target(task_id).await?, - // both:Outgoing + Incoming 合并后按 id 去重(同一 link 双向查询可能重复) - _ => { - let mut outgoing = repo.get_by_source(task_id).await?; - let incoming = repo.get_by_target(task_id).await?; - let mut seen: std::collections::HashSet = - outgoing.iter().map(|l| l.id.clone()).collect(); - for l in incoming { - if seen.insert(l.id.clone()) { - outgoing.push(l); - } - } - outgoing.sort_by(|a, b| a.created_at.cmp(&b.created_at)); - outgoing - } - }; - let total = links.len(); - Ok(serde_json::json!({ "items": links, "total": total, "task_id": task_id, "direction": direction })) - }) - })}, - ); - - // ── get_task_tree (Low 只读,设计 §2.1 + §五) ── - // 获取父子任务树(限 1 级嵌套,设计 D2:无孙任务)。返回 parent + 直接 children 列表。 - // AI 用途:查看需求分解结构、子任务进度聚合。 - registry.register( - "get_task_tree", "获取任务父子树(限 1 级嵌套,设计 D2 无孙任务)。参数 task_id(任务 ID,无论叶子还是父任务)。返回 { parent: 父任务记录, children: 直接子任务列表(parent_id 指向 task_id,按 created_at 升序) }。叶子任务 children 为空。AI 用途:查看需求分解结构、子任务进度聚合", - df_ai::ai_tools::object_schema(vec![("task_id", "string", true)]), - RiskLevel::Low, - { let db = db.clone(); Box::new(move |args: serde_json::Value| { - let db = db.clone(); - Box::pin(async move { - let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?; - let repo = df_storage::crud::TaskRepo::new(&db); - let parent = repo - .get_by_id(task_id) - .await? - .ok_or_else(|| anyhow::anyhow!("任务 {task_id} 不存在"))?; - let children = repo.get_children(task_id).await?; - let child_count = children.len(); - Ok(serde_json::json!({ "parent": parent, "children": children, "child_count": child_count })) - }) - })}, - ); - - // ── move_task_queue (Medium,设计 §2.1 + §五) ── - // 跨池移动任务(backlog/todo/decision/active/done),按一致性约束联动 status。 - // 对齐 commands::task::move_task_queue 语义: - // - done → status 强制 done / backlog → status 强制 todo - // - active → status 若不在执行中三态则强制 in_progress / todo → status 强制 todo - // - decision → status 不变(待决策池保留执行态) - // status 写入走专用 set_status_for_aggregation(绕过 status 收口,move_task_queue 是合法非状态机路径)。 - registry.register( - "move_task_queue", "跨池移动任务(管理维度池 backlog/todo/decision/active/done,与 status 执行维度正交)。参数 id(任务 ID)、new_queue(目标池)。一致性约束联动 status:done→status=done / backlog→status=todo / active→status 若非执行中三态强制 in_progress / todo→status=todo / decision→status 不变。返回移动后的最新 TaskRecord", - df_ai::ai_tools::object_schema(vec![("id", "string", true), ("new_queue", "string", true)]), - RiskLevel::Medium, - { let db = db.clone(); Box::new(move |args: serde_json::Value| { - let db = db.clone(); - Box::pin(async move { - let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; - let new_queue = args["new_queue"].as_str() - .ok_or_else(|| anyhow::anyhow!("缺少 new_queue"))? - .trim() - .to_string(); - // queue 白名单校验(对标 commands::task::validate_queue) - const TASK_QUEUE_VALUES: &[&str] = &["backlog", "todo", "decision", "active", "done"]; - if !TASK_QUEUE_VALUES.contains(&new_queue.as_str()) { - anyhow::bail!("非法 new_queue 值 {:?},合法值: {:?}", new_queue, TASK_QUEUE_VALUES); - } - - let repo = df_storage::crud::TaskRepo::new(&db); - let current = repo - .get_by_id(id) - .await? - .ok_or_else(|| anyhow::anyhow!("任务 {id} 不存在"))?; - - // 一致性约束联动:根据 new_queue 决定 status 是否需调整 - // (对标 commands::task::move_task_queue 同源逻辑,单一真相源) - const ACTIVE_OK_STATUSES: &[&str] = &["in_progress", "in_review", "testing"]; - let new_status = match new_queue.as_str() { - "done" => "done".to_string(), - "backlog" => "todo".to_string(), - "active" => { - if ACTIVE_OK_STATUSES.contains(¤t.status.as_str()) { - current.status.as_str().to_string() - } else { - "in_progress".to_string() - } - } - "todo" => "todo".to_string(), - "decision" => current.status.as_str().to_string(), - _ => unreachable!("queue 白名单已收口"), - }; - - // 写 queue(queue 已在 tasks 白名单);与 current 不同才写(避免无谓 updated_at 抖动) - if current.queue != new_queue { - repo.update_field(id, "queue", &new_queue).await?; - } - // 写 status(专用 set_status_for_aggregation 绕过 status 收口,合法非状态机路径) - if current.status.as_str() != new_status { - repo.set_status_for_aggregation(id, &new_status).await?; - } - - // 回读最新记录返回 - let updated = repo - .get_by_id(id) - .await? - .ok_or_else(|| anyhow::anyhow!("任务 {id} 不存在(移动后回读失败)"))?; - Ok(serde_json::to_value(&updated)?) - }) - })}, - ); - - // ── update_content (Medium,设计 §五) ── - // 更新 content_json 结构化需求规格({background,acceptance_criteria[],scope[],technical_design})。 - // 走通用 update_field(content_json 已在 tasks 白名单) + JSON 合法性校验(对齐 create_task 校验)。 - // AI 从对话中提取信息填充 content_json,人确认后 AI 执行中用 acceptance_criteria 自检。 - registry.register( - "update_content", "更新任务的结构化需求规格 content_json(JSON 字符串 {background,acceptance_criteria[],scope[],technical_design,custom_fields})。参数 id(任务 ID)、content_json(合法 JSON 字符串,空串清空 content_json)。校验:须是合法 JSON,否则拒。AI 用途:从对话提取信息填充需求规格,供后续 acceptance_criteria 自检", - df_ai::ai_tools::object_schema(vec![("id", "string", true), ("content_json", "string", true)]), - RiskLevel::Medium, - { let db = db.clone(); Box::new(move |args: serde_json::Value| { - let db = db.clone(); - Box::pin(async move { - let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; - let content_json = args["content_json"].as_str() - .ok_or_else(|| anyhow::anyhow!("缺少 content_json"))?; - // JSON 合法性校验(对齐 commands::task::create_task 的 content_json 校验) - // 非空串须是合法 JSON;空串视为清空(写 NULL)——但 update_field 收 string, - // 用空串写库后会留空字符串而非 NULL(可接受,content_json 为 Option 但 - // 通用 update_field 走 TEXT 列接受空串)。这里对非空串做校验,空串直接放行清空。 - if !content_json.trim().is_empty() { - serde_json::from_str::(content_json) - .map_err(|e| anyhow::anyhow!("content_json 不是合法 JSON: {e}"))?; - } - let repo = df_storage::crud::TaskRepo::new(&db); - repo.update_field(id, "content_json", content_json).await?; - let title = repo.get_by_id(id).await?.map(|t| t.title).unwrap_or_default(); - Ok(serde_json::json!({ "id": id, "title": title, "field": "content_json", "updated": true })) - }) - })}, - ); - - // ── get_project_timeline (Low 只读,设计 §五 + §2.4) ── - // 查询项目事件流(project_events 统一事件流)。AI 精准检索基础: - // 回答"上周做了什么 / 这个任务为何 blocked / 决策何时做出"。 - // 参数 project_id(必填) + event_type(可选过滤) + limit(默认 50,上限 200)。 - // source 标 "ai"(AI 工具路径,区别于 IPC 层的 "human")。 - registry.register( - "get_project_timeline", "查询项目事件流(AI 精准检索基础,回答\"上周做了什么/这个任务为何 blocked/决策何时做出\")。参数 project_id(必填,项目 ID)、event_type(可选,按事件类型精确过滤,如 task_created/task_advanced/idea_promoted/idea_evaluated/decision_made 等,空=不过滤)、limit(可选,返回条数上限,默认 50,内部钳制 ≤ 200)。返回 { items: 事件列表(时间倒序), total, project_id }。事件含 event_type/entity_type/entity_id/from_state/to_state/source/created_at", - df_ai::ai_tools::object_schema(vec![ - ("project_id", "string", true), - ("event_type", "string", false), - ("limit", "integer", false), - ]), - RiskLevel::Low, - { let db = db.clone(); Box::new(move |args: serde_json::Value| { - let db = db.clone(); - Box::pin(async move { - let project_id = args["project_id"].as_str() - .ok_or_else(|| anyhow::anyhow!("缺少 project_id"))? - .trim() - .to_string(); - if project_id.is_empty() { - anyhow::bail!("project_id 不能为空"); - } - let event_filter = args.get("event_type") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - // limit 默认 50(对齐 commands::events::default_limit);负数/异常值兜底 50。 - let limit = args.get("limit") - .and_then(|v| v.as_u64()) - .map(|n| n as u32) - .unwrap_or(50); - - let repo = df_storage::crud::ProjectEventRepo::new(&db); - let mut events = repo.get_by_project(&project_id, limit).await?; - // 应用层按 event_type 过滤(单用户事件量小,保持 Repo 方法精简)。 - if let Some(et) = &event_filter { - events.retain(|e| e.event_type == *et); - } - let total = events.len(); - Ok(serde_json::json!({ - "items": events, - "total": total, - "project_id": project_id, - })) - }) - })}, - ); - - // ── add_project_service (Medium,设计 §2.3 + §五 + D10) ── - // 添加项目依赖的基础设施配置(数据库/缓存/MQ/API 等)。为 AI 执行任务时提供基础设施上下文: - // "这项目用了什么数据库、Redis 在哪、有没有 MQ"。 - // service_type 白名单 + D10 凭证审查下沉 Repo::insert_validated(单一真相源,与 IPC - // add_project_service 同源)。 - // - // ⚠️ D10 安全边界:config_json / endpoint / remark 不含敏感凭证(密码/密钥/Token)。 - // Repo 层审查 password/secret/token/api_key 子串,命中即拒。凭证走环境变量。 - registry.register( - "add_project_service", "为项目添加基础设施配置(为 AI 执行任务时提供\"用了什么数据库/缓存/MQ/API\"上下文)。参数:project_id(项目 ID)、name(服务名,如 主库/Redis 缓存)、service_type(类型,白名单 mysql/postgresql/sqlite/redis/mongodb/mq/api/other)、environment(环境 development/staging/production,默认 development)、endpoint(可选,连接地址 localhost:3306 或 URL,不含凭证)、config_json(可选,类型相关配置 JSON 字符串,如 {\"pool_size\":10},⚠️不含密码字段,凭证走环境变量)、remark(可选,备注)。返回新增完整记录。约束:service_type 白名单;D10 安全边界 config_json/endpoint/remark 含 password/secret/token/api_key 子串拒绝", - df_ai::ai_tools::object_schema(vec![ - ("project_id", "string", true), - ("name", "string", true), - ("service_type", "string", true), - ("environment", "string", false), - ("endpoint", "string", false), - ("config_json", "string", false), - ("remark", "string", false), - ]), - RiskLevel::Medium, - { let db = db.clone(); Box::new(move |args: serde_json::Value| { - let db = db.clone(); - Box::pin(async move { - let project_id = args["project_id"].as_str() - .ok_or_else(|| anyhow::anyhow!("缺少 project_id"))? - .trim() - .to_string(); - let name = args["name"].as_str() - .ok_or_else(|| anyhow::anyhow!("缺少 name"))? - .trim() - .to_string(); - let service_type = args["service_type"].as_str() - .ok_or_else(|| anyhow::anyhow!("缺少 service_type"))? - .trim() - .to_string(); - if project_id.is_empty() { - anyhow::bail!("project_id 不能为空"); - } - if name.is_empty() { - anyhow::bail!("name 不能为空"); - } - if service_type.is_empty() { - anyhow::bail!("service_type 不能为空"); - } - let endpoint = args.get("endpoint") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - let config_json = args.get("config_json") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - let environment = args.get("environment") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty()) - .unwrap_or("development") - .to_string(); - let remark = args.get("remark") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - - let record = ProjectServiceRecord { - id: new_id(), - project_id: project_id.clone(), - name, - service_type, - endpoint, - config_json, - environment, - remark, - created_at: now_millis(), - updated_at: now_millis(), - }; - let repo = df_storage::crud::ProjectServiceRepo::new(&db); - // insert_validated 内含 service_type 白名单 + D10 凭证审查(密码/token/api_key 子串拒绝)。 - let id = repo.insert_validated(record).await?; - let inserted = repo.get_by_id(&id).await? - .ok_or_else(|| anyhow::anyhow!("插入后回读失败"))?; - Ok(serde_json::to_value(&inserted)?) - }) - })}, - ); - - // ── list_project_services (Low 只读,设计 §2.3 + §五) ── - // 查询项目基础设施配置。AI 执行任务时按需取基础设施上下文: - // 部署 production 任务只取 production 服务,开发调试取 development(环境隔离避免误连生产库)。 - registry.register( - "list_project_services", "查询项目基础设施配置(AI 执行任务时取\"用了什么数据库/缓存/MQ/API\"上下文)。参数:project_id(项目 ID)、environment(可选,按环境过滤 development/staging/production,空=返回所有环境)。返回 { items: 配置列表(时间倒序), total, project_id }。环境隔离:production 部署任务取 production 服务,开发调试取 development,避免误连生产库", - df_ai::ai_tools::object_schema(vec![ - ("project_id", "string", true), - ("environment", "string", false), - ]), - RiskLevel::Low, - { let db = db.clone(); Box::new(move |args: serde_json::Value| { - let db = db.clone(); - Box::pin(async move { - let project_id = args["project_id"].as_str() - .ok_or_else(|| anyhow::anyhow!("缺少 project_id"))? - .trim() - .to_string(); - if project_id.is_empty() { - anyhow::bail!("project_id 不能为空"); - } - let env_filter = args.get("environment") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - - let repo = df_storage::crud::ProjectServiceRepo::new(&db); - let items = if let Some(env) = &env_filter { - repo.list_by_project_env(&project_id, env).await? - } else { - repo.list_by_project(&project_id).await? - }; - let total = items.len(); - Ok(serde_json::json!({ - "items": items, - "total": total, - "project_id": project_id, - })) - }) - })}, - ); - - // ── list_project_modules (Low 只读,工程系统) ── - // 查询项目工程列表(AI 取项目结构上下文:有哪些代码仓库、各自目录/技术栈/Git 地址)。 - // 多工程场景(Monorepo/微服务/前后端分离)AI 据此决定在哪个工程执行 git/构建操作。 - registry.register( - "list_project_modules", "查询项目工程列表(每个工程是独立代码仓库)。参数:project_id(项目 ID)。返回 { modules: 工程列表(含 name/path/git_url/stack), total }。AI 据此了解项目有哪些代码仓库及各自技术栈", - df_ai::ai_tools::object_schema(vec![ - ("project_id", "string", true), - ]), - RiskLevel::Low, - { let db = db.clone(); Box::new(move |args: serde_json::Value| { - let db = db.clone(); - Box::pin(async move { - let project_id = args["project_id"].as_str() - .ok_or_else(|| anyhow::anyhow!("缺少 project_id"))? - .trim() - .to_string(); - if project_id.is_empty() { - anyhow::bail!("project_id 不能为空"); - } - let repo = df_storage::crud::ProjectModuleRepo::new(&db); - let modules = repo.list_by_project(&project_id).await?; - let total = modules.len(); - Ok(serde_json::json!({ - "modules": modules, - "total": total, - "project_id": project_id, - })) - }) - })}, - ); + super::tools::task_graph::register(registry, db); } -// ============================================================ -// Git 只读 AI 工具(工程系统,2026-06-29) -// ============================================================ - -/// 在指定目录执行 git 命令(10s 超时,返回 stdout)。失败/超时返回空字符串(非崩溃)。 +/// Git AI 工具注册(6 个:status/diff/log 只读 Low + commit/branch 写 Medium + merge High)。 /// -/// BUG-2026-07-18: 原实现 spawn_blocking 内裸 std::process::Command::output() 无 timeout -/// (注释谎称"10s 超时")。git 在 OneDrive/网盘/挂载盘/lfs/大仓库场景会卡数十秒到无限, -/// spawn_blocking 线程永不返回 → 累计耗尽 tokio blocking 池 → 间接卡死单线程 runtime -/// (与 env_snapshot::probe_version 同型病根)。AI 的 git_status/log/diff 工具在会话内高频触发。 +/// AI 据此自主查看工程代码仓库状态(status/diff/log 无需审批),commit/branch/merge 需审批。 +/// 均在工程目录(module.path)执行,无 .git 返回空状态。 /// -/// 改用 tokio::process + tokio::time::timeout(10s) + kill_on_drop:超时 drop 时 child 进程 -/// 被 kill,不泄漏线程/进程,对齐 shell.rs execute 同源封装。 -async fn exec_git(working_dir: &str, args: &[&str]) -> String { - let mut cmd = tokio::process::Command::new("git"); - cmd.args(args) - .current_dir(working_dir) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()) - .kill_on_drop(true); - match tokio::time::timeout(std::time::Duration::from_secs(10), cmd.output()).await { - Ok(Ok(out)) => String::from_utf8_lossy(&out.stdout).to_string(), - Ok(Err(_)) => String::new(), - Err(_elapsed) => String::new(), // 超时:child 被 kill_on_drop 终止 - } -} - -/// git status --porcelain 解析为结构化文件列表。 -/// 返回 (当前分支, 改动文件列表 [{path, status}]) -async fn run_git_status(working_dir: &str) -> (String, Vec) { - let branch = exec_git(working_dir, &["branch", "--show-current"]).await; - let branch = branch.trim().to_string(); - let raw = exec_git(working_dir, &["status", "--porcelain"]).await; - let files: Vec = raw - .lines() - .filter(|l| !l.is_empty()) - .map(|line| { - // porcelain 格式:"XY filename",XY 为两位状态码 - let status = line.chars().take(2).collect::(); - let path = line.get(3..).unwrap_or("").trim().to_string(); - // 简化状态码:M/A/D/??/R - let simple = if status.starts_with("??") { "??" } - else if status.contains('A') { "A" } - else if status.contains('D') { "D" } - else if status.contains('R') { "R" } - else { "M" }; - serde_json::json!({ "path": path, "status": simple }) - }) - .collect(); - (branch, files) -} - -/// git diff 解析为结构化文件列表(每文件统计 + patch 截断)。 -async fn run_git_diff(working_dir: &str, staged: bool) -> serde_json::Value { - let mut args = vec!["diff", "--stat"]; - if staged { args.push("--cached"); } - let stat_raw = exec_git(working_dir, &args).await; - - // 每文件 patch(截断防 token 爆) - let mut patch_args = vec!["diff" ]; - if staged { patch_args.push("--cached"); } - let patch_raw = exec_git(working_dir, &patch_args).await; - // 截断到 8000 字符(防大体量 diff) - let patch_truncated = if patch_raw.len() > 8000 { - format!("{}\n... (diff 截断,共 {} 字符)", &patch_raw[..8000], patch_raw.len()) - } else { - patch_raw - }; - - serde_json::json!({ - "stat": stat_raw, - "patch": patch_truncated, - }) -} - -/// git log 解析为结构化提交列表。 -async fn run_git_log(working_dir: &str, limit: usize) -> Vec { - let format = "%H|%an|%ad|%s"; - let limit_str = format!("-{}", limit); - let raw = exec_git(working_dir, &["log", "--oneline", &format!("--format={}", format), &limit_str, "--date=short"]).await; - raw.lines() - .filter(|l| !l.is_empty()) - .filter_map(|line| { - let parts: Vec<&str> = line.splitn(4, '|').collect(); - if parts.len() == 4 { - Some(serde_json::json!({ - "hash": parts[0], - "author": parts[1], - "date": parts[2], - "message": parts[3], - })) - } else { None } - }) - .collect() -} - -/// Git 只读 AI 工具注册(3 个:status/diff/log,均 Low 风险自动执行)。 -/// -/// AI 据此自主查看工程代码仓库状态,无需用户审批。均在工程目录(module.path)执行, -/// 无 .git 返回空状态。 +/// tool_registry 拆分:6 个 git 工具全部迁声明式(tools/git.rs),配套私有 helper +/// (exec_git/run_git_status/run_git_diff/run_git_log)原样搬入该文件(仅 git 工具用,不暴露)。 +/// handler body 逐字照搬原 async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。 +/// 本函数改 thin 委托 super::tools::git::register(...)。基线测试 +/// test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。 fn register_git_tools(registry: &mut AiToolRegistry, db: &Arc) { - // ── git_status (Low 只读) ── - registry.register( - "git_status", "查看工程 Git 工作区状态。参数:module_id(工程 ID)。返回当前分支、改动文件列表(每个文件含路径+状态 M/A/D/??)、改动总数。只读无副作用", - df_ai::ai_tools::object_schema(vec![ - ("module_id", "string", true), - ]), - RiskLevel::Low, - { let db = db.clone(); Box::new(move |args: serde_json::Value| { - let db = db.clone(); - Box::pin(async move { - let module_id = args["module_id"].as_str() - .ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?; - let repo = df_storage::crud::ProjectModuleRepo::new(&db); - let module = repo.get_by_id(module_id).await? - .ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?; - let (branch, files) = run_git_status(&module.path).await; - Ok(serde_json::json!({ - "branch": branch, - "files": files, - "total_changes": files.len(), - })) - }) - })}, - ); - - // ── git_diff (Low 只读) ── - registry.register( - "git_diff", "查看工程未提交的代码改动。参数:module_id(工程 ID)、staged(可选 bool,仅看已暂存改动,默认 false=全部含未暂存)。返回改动统计 + patch 内容(截断防 token 爆)。只读无副作用", - df_ai::ai_tools::object_schema(vec![ - ("module_id", "string", true), - ("staged", "boolean", false), - ]), - RiskLevel::Low, - { let db = db.clone(); Box::new(move |args: serde_json::Value| { - let db = db.clone(); - Box::pin(async move { - let module_id = args["module_id"].as_str() - .ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?; - let staged = args.get("staged").and_then(|v| v.as_bool()).unwrap_or(false); - let repo = df_storage::crud::ProjectModuleRepo::new(&db); - let module = repo.get_by_id(module_id).await? - .ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?; - let diff = run_git_diff(&module.path, staged).await; - Ok(diff) - }) - })}, - ); - - // ── git_log (Low 只读) ── - registry.register( - "git_log", "查看工程 Git 提交历史。参数:module_id(工程 ID)、limit(可选 int,最近 N 条提交,默认 20,最大 100)。返回提交列表(每个含哈希/作者/消息/日期)。只读无副作用", - df_ai::ai_tools::object_schema(vec![ - ("module_id", "string", true), - ("limit", "integer", false), - ]), - RiskLevel::Low, - { let db = db.clone(); Box::new(move |args: serde_json::Value| { - let db = db.clone(); - Box::pin(async move { - let module_id = args["module_id"].as_str() - .ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?; - let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20).min(100) as usize; - let repo = df_storage::crud::ProjectModuleRepo::new(&db); - let module = repo.get_by_id(module_id).await? - .ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?; - let commits = run_git_log(&module.path, limit).await; - Ok(serde_json::json!({ "commits": commits })) - }) - })}, - ); - - // ── git_commit (Medium 审批) ── - // 提交工作区改动。需审批(写操作影响代码仓库)。 - // 安全边界:不提交敏感文件(.env/.key/.pem,由 .gitignore 兜底 + 此处检查)。 - registry.register( - "git_commit", "提交工程工作区改动到本地仓库。参数:module_id(工程 ID)、message(提交信息,必填)、add_all(可选 bool,是否添加全部改动到暂存区,默认 true)。中等风险,需审批。提交前检查不提交敏感文件(.env/.key/.pem)", - df_ai::ai_tools::object_schema(vec![ - ("module_id", "string", true), - ("message", "string", true), - ("add_all", "boolean", false), - ]), - RiskLevel::Medium, - { let db = db.clone(); Box::new(move |args: serde_json::Value| { - let db = db.clone(); - Box::pin(async move { - let module_id = args["module_id"].as_str() - .ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?; - let message = args["message"].as_str() - .ok_or_else(|| anyhow::anyhow!("缺少 message"))?; - let add_all = args.get("add_all").and_then(|v| v.as_bool()).unwrap_or(true); - if message.trim().is_empty() { - anyhow::bail!("提交信息不能为空"); - } - let repo = df_storage::crud::ProjectModuleRepo::new(&db); - let module = repo.get_by_id(module_id).await? - .ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?; - // 敏感文件检查:提交前查看待提交文件列表,如有 .env/.key/.pem 则拒绝 - let status_raw = exec_git(&module.path, &["status", "--porcelain"]).await; - let sensitive_patterns = [".env", ".key", ".pem", "id_rsa", ".htpasswd"]; - for line in status_raw.lines() { - let path = line.get(3..).unwrap_or("").trim().to_lowercase(); - if sensitive_patterns.iter().any(|p| path.contains(p)) { - anyhow::bail!("检测到敏感文件在待提交列表中: {}。请手动检查 .gitignore 或移除该文件后重试", line.get(3..).unwrap_or("")); - } - } - // git add - if add_all { - let _ = exec_git(&module.path, &["add", "-A"]).await; - } - // git commit - let commit_out = exec_git(&module.path, &["commit", "-m", message]).await; - // 提交后取最新 commit hash - let hash = exec_git(&module.path, &["log", "-1", "--format=%H"]).await.trim().to_string(); - Ok(serde_json::json!({ - "committed": !hash.is_empty(), - "hash": hash, - "message": message, - "output": commit_out, - })) - }) - })}, - ); - - // ── git_branch (Medium 审批) ── - // 分支管理:列表/创建/切换。需审批(影响工作区分支状态)。 - registry.register( - "git_branch", "工程分支管理。参数:module_id(工程 ID)、action(list/create/switch,默认 list)、name(分支名,create/switch 时必填)。中等风险,需审批。list=列出所有分支+当前分支,create=创建新分支,switch=切换分支", - df_ai::ai_tools::object_schema(vec![ - ("module_id", "string", true), - ("action", "string", false), - ("name", "string", false), - ]), - RiskLevel::Medium, - { let db = db.clone(); Box::new(move |args: serde_json::Value| { - let db = db.clone(); - Box::pin(async move { - let module_id = args["module_id"].as_str() - .ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?; - let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("list"); - let name = args.get("name").and_then(|v| v.as_str()).unwrap_or(""); - let repo = df_storage::crud::ProjectModuleRepo::new(&db); - let module = repo.get_by_id(module_id).await? - .ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?; - match action { - "list" => { - let raw = exec_git(&module.path, &["branch", "--list"]).await; - let current = exec_git(&module.path, &["branch", "--show-current"]).await.trim().to_string(); - let branches: Vec = raw.lines() - .map(|l| l.trim_start_matches("* ").trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - Ok(serde_json::json!({ "current": current, "branches": branches })) - } - "create" => { - if name.is_empty() { anyhow::bail!("create 需要 name 参数"); } - let _ = exec_git(&module.path, &["branch", name]).await; - Ok(serde_json::json!({ "created": name })) - } - "switch" => { - if name.is_empty() { anyhow::bail!("switch 需要 name 参数"); } - let _ = exec_git(&module.path, &["checkout", name]).await; - let current = exec_git(&module.path, &["branch", "--show-current"]).await.trim().to_string(); - Ok(serde_json::json!({ "switched_to": current })) - } - _ => anyhow::bail!("未知 action: {},合法值: list/create/switch", action), - } - }) - })}, - ); - - // ── git_merge (High 审批) ── - // 合并分支。高风险(可能产生冲突,影响代码完整性)。 - registry.register( - "git_merge", "合并指定分支到当前分支。参数:module_id(工程 ID)、branch(要合并的分支名,必填)。高风险,需审批。合并冲突时返回冲突文件列表,需用户手动解决", - df_ai::ai_tools::object_schema(vec![ - ("module_id", "string", true), - ("branch", "string", true), - ]), - RiskLevel::High, - { let db = db.clone(); Box::new(move |args: serde_json::Value| { - let db = db.clone(); - Box::pin(async move { - let module_id = args["module_id"].as_str() - .ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?; - let branch = args["branch"].as_str() - .ok_or_else(|| anyhow::anyhow!("缺少 branch"))?; - let repo = df_storage::crud::ProjectModuleRepo::new(&db); - let module = repo.get_by_id(module_id).await? - .ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?; - let merge_out = exec_git(&module.path, &["merge", branch]).await; - let has_conflict = merge_out.contains("CONFLICT") || merge_out.contains("Merge conflict"); - if has_conflict { - let status_raw = exec_git(&module.path, &["status", "--porcelain"]).await; - let conflicts: Vec = status_raw.lines() - .filter(|l| l.starts_with("UU") || l.starts_with("AA") || l.starts_with("DD")) - .map(|l| l.get(3..).unwrap_or("").trim().to_string()) - .collect(); - Ok(serde_json::json!({ - "merged": false, - "conflicts": conflicts, - "message": "合并冲突,需手动解决", - })) - } else { - Ok(serde_json::json!({ - "merged": true, - "branch": branch, - "output": merge_out, - })) - } - }) - })}, - ); + super::tools::git::register(registry, db); } -/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。 +/// 工作流类 AI 工具注册(1 个:run_workflow)——不持 db(handler 防御兜底,详见注释)。 +/// 真正执行经 ai_approve → execute_run_workflow_for_tool → workflow.rs::run_workflow_inner。 +/// +/// tool_registry 拆分:run_workflow 迁声明式(tools/workflow.rs),handler body 逐字照搬原 +/// async move 块(逻辑零变更,仅返 Err 防御兜底),仅闭包包装改由 `declare_tool!` 宏生成。 +/// 本函数改 thin 委托 super::tools::workflow::register(...)。基线测试 +/// test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。 fn register_workflow_tools(registry: &mut AiToolRegistry) { - // F-260616-07 阶段3(子项2):run_workflow AI 工具注册实装。 - // 实施路径文档 §三 列为阶段3 必做项(tool_registry.rs 此前无此工具连空壳都没有)。 - // 描述明确按任务 target_status 推进对应工作流(含 AiNode 自审 / HumanNode 核对闸门)。 - // - // handler 约束说明(B-260617-01 更新):run_workflow 真正执行需要 AppHandle(转发 workflow-event - // 到前端) + AppState(registry 构建 DAG / event_bus 订阅 / workflows Repo 落库 / - // workflow_state_registry 注销),这些在 tool handler(仅持 db: Arc)中无法构造。 - // - // **执行路径(单一)**:run_workflow 是 High risk → 始终经 audit.rs:process_tool_calls 进 pending - // → ai_approve 审批 → commands.rs ai_approve 内识别 run_workflow 分支调 - // execute_run_workflow_for_tool(mod.rs)→ workflow.rs::run_workflow_inner(持完整 State)真正执行。 - // 故本 handler 经 ai_tools.execute 调用的路径在正常流程下不可达(dead code 防御): - // 仅当未来出现"不经 ai_approve 直接 execute run_workflow"的异常调用方时,本 Err 作为防御兜底 - // 返回明确错误,而非静默 panic。LLM 误判重试同 tool_call 时,High risk 去重缓存 - // (audit.rs:find_cached_high_risk_result)会复用旧 tool_result 跳过审批,断重试循环。 - registry.register( - "run_workflow", "按任务 target_status 推进对应工作流(含 AiNode 自审 / HumanNode 核对闸门)。参数 task_id + target_status 同时提供才联动任务推进(完成后按 target_status 推进任务,失败按退回态回滚)。属高风险操作(触发工作流引擎执行),须人工批准。审批通过后由后端直接执行工作流引擎并联动推进任务,返回 execution_id", - df_ai::ai_tools::object_schema(vec![("task_id", "string", true), ("target_status", "string", true)]), - RiskLevel::High, - // CR-52: 删死代码 `let _db = db.clone();`(完全未用 — 防御 handler 仅返回 Err, - // 不持 db;run_workflow 真正执行经 ai_approve → execute_run_workflow_for_tool - // → workflow.rs::run_workflow_inner,此处 handler 在正常流程不可达)。 - { Box::new(move |args: serde_json::Value| { - Box::pin(async move { - let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?; - let target_status = args["target_status"].as_str() - .ok_or_else(|| anyhow::anyhow!("缺少 target_status"))?; - // 防御兜底(正常流程不可达):run_workflow 经 ai_approve 分支执行,不经此 handler。 - // 误入此路径说明调用方异常(非 ai_approve 直接 execute),返回明确错误勿盲目重试 - // (对齐 run_command 超时标注模式 L700-712),High risk 去重缓存会断 LLM 重试循环。 - Err(anyhow::anyhow!( - "run_workflow 须经人工审批后由后端 ai_approve 分支执行(转调 run_workflow_inner,\ - 持完整 State)。本 handler 经 ai_tools.execute 调用属异常路径(无 AppHandle/State),\ - 勿盲目重试同调用(task_id={}, target_status={});\ - 若需推进任务,重新发起 run_workflow tool_call 走审批流程。", - task_id, target_status - )) - }) - })}, - ); + super::tools::workflow::register(registry); } /// 灵感类 AI 工具注册(2 个:list + create)——持 db:Arc。 /// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。 +/// +/// tool_registry 拆分:2 个灵感工具全部迁声明式(tools/idea.rs),handler body 逐字照搬原 +/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托 +/// super::tools::idea::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count +/// 仍断言 48 总量 + 工具名集合稳定。 fn register_idea_tools(registry: &mut AiToolRegistry, db: &Arc) { - registry.register( - "list_ideas", "列出所有灵感,支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50", - df_ai::ai_tools::object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]), RiskLevel::Low, - { let db = db.clone(); Box::new(move |args: serde_json::Value| { - let db = db.clone(); - Box::pin(async move { - let repo = df_storage::crud::IdeaRepo::new(&db); - let items = repo.list_all().await?; - let total = items.len(); - let offset = args["offset"].as_u64().unwrap_or(0) as usize; - let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize; - let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect(); - let has_more = (offset + page_items.len()) < total; - Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more })) - }) - })}, - ); - registry.register( - "create_idea", "捕获一个新灵感", - df_ai::ai_tools::object_schema(vec![ - ("title", "string", true), ("description", "string", false), - ("tags", "string", false), ("source", "string", false), - // priority:与 commands::idea::CreateIdeaInput 默认值一致(=1,灵感默认普通优先级) - ("priority", "integer", false), - ]), - RiskLevel::Medium, - { let db = db.clone(); Box::new(move |args: serde_json::Value| { - let db = db.clone(); - Box::pin(async move { - let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?; - let repo = df_storage::crud::IdeaRepo::new(&db); - let record = IdeaRecord { - id: new_id(), title: title.to_string(), - description: args["description"].as_str().unwrap_or("").to_string(), - // priority 默认 1:灵感默认普通优先级(与 commands::idea::default_priority 及 tasks 表 SQL DEFAULT 1 对齐) - status: IdeaStatus::Draft, priority: args["priority"].as_i64().unwrap_or(1) as i32, - score: None, tags: args["tags"].as_str().map(|s| s.to_string()), - source: args["source"].as_str().map(|s| s.to_string()), - promoted_to: None, ai_analysis: None, scores: None, - related_ids: None, - created_at: now_millis(), updated_at: now_millis(), - }; - let id = record.id.clone(); - repo.insert(record).await?; - Ok(serde_json::json!({ "id": id, "title": title, "status": "draft" })) - }) - })}, - ); + super::tools::idea::register(registry, db); } /// 回收站类 AI 工具注册(1 个:list_trash)——持 db:Arc。 /// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。 +/// +/// tool_registry 拆分:list_trash 迁声明式(tools/trash.rs),handler body 逐字照搬原 +/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托 +/// super::tools::trash::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count +/// 仍断言 48 总量 + 工具名集合稳定。 fn register_trash_tools(registry: &mut AiToolRegistry, db: &Arc) { - registry.register( - "list_trash", "列出回收站已删除项目,支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50", - df_ai::ai_tools::object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]), RiskLevel::Low, - { let db = db.clone(); Box::new(move |args: serde_json::Value| { - let db = db.clone(); - Box::pin(async move { - let repo = df_storage::crud::ProjectRepo::new(&db); - let items = repo.list_deleted().await?; - let total = items.len(); - let offset = args["offset"].as_u64().unwrap_or(0) as usize; - let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize; - let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect(); - let has_more = (offset + page_items.len()) < total; - Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more })) - }) - })}, - ); + super::tools::trash::register(registry, db); } /// 文件系统类 AI 工具注册(命令执行/读/列/写/改/元/追加/删/移/搜)——不持 db,纯文件系统操作。 diff --git a/src-tauri/src/commands/ai/tools/git.rs b/src-tauri/src/commands/ai/tools/git.rs new file mode 100644 index 0000000..cd4f7fb --- /dev/null +++ b/src-tauri/src/commands/ai/tools/git.rs @@ -0,0 +1,340 @@ +//! Git 类 AI 工具声明式注册(register_git_tools 6 个迁入)。 +//! +//! 迁自 `tool_registry.rs::register_git_tools`(原 6 个:git_status/git_diff/git_log 只读 + +//! git_commit/git_branch/git_merge 写),改用 `declare_tool!` 宏。 +//! +//! 迁移策略(handler 逻辑零变更): +//! - handler body 逐字照搬原 `register_git_tools` 内 async move 块(逻辑等价), +//! 仅闭包包装(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone(); +//! Box::pin(async move { ... }) }) }`)改由 `declare_tool!` 宏生成。 +//! - name/desc/schema/risk 与原手写定义逐字一致。 +//! - 配套私有 helper(exec_git/run_git_status/run_git_diff/run_git_log)原样搬入本文件, +//! 仅 register_git_tools 使用(external module.rs::run_git_status 是同名不同签名的另一函数)。 +//! +//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 + +//! 工具名集合稳定(防 rename / 漏注册)。 + +use std::sync::Arc; + +use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel}; +use df_ai::declare_tool; +use df_storage::db::Database; + +// ============================================================ +// Git 私有 helper(原 tool_registry.rs 986-1080 行原样搬入,仅本模块使用) +// ============================================================ + +/// 在指定目录执行 git 命令(10s 超时,返回 stdout)。失败/超时返回空字符串(非崩溃)。 +/// +/// BUG-2026-07-18: 原实现 spawn_blocking 内裸 std::process::Command::output() 无 timeout +/// (注释谎称"10s 超时")。git 在 OneDrive/网盘/挂载盘/lfs/大仓库场景会卡数十秒到无限, +/// spawn_blocking 线程永不返回 → 累计耗尽 tokio blocking 池 → 间接卡死单线程 runtime +/// (与 env_snapshot::probe_version 同型病根)。AI 的 git_status/log/diff 工具在会话内高频触发。 +/// +/// 改用 tokio::process + tokio::time::timeout(10s) + kill_on_drop:超时 drop 时 child 进程 +/// 被 kill,不泄漏线程/进程,对齐 shell.rs execute 同源封装。 +async fn exec_git(working_dir: &str, args: &[&str]) -> String { + let mut cmd = tokio::process::Command::new("git"); + cmd.args(args) + .current_dir(working_dir) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true); + match tokio::time::timeout(std::time::Duration::from_secs(10), cmd.output()).await { + Ok(Ok(out)) => String::from_utf8_lossy(&out.stdout).to_string(), + Ok(Err(_)) => String::new(), + Err(_elapsed) => String::new(), // 超时:child 被 kill_on_drop 终止 + } +} + +/// git status --porcelain 解析为结构化文件列表。 +/// 返回 (当前分支, 改动文件列表 [{path, status}]) +async fn run_git_status(working_dir: &str) -> (String, Vec) { + let branch = exec_git(working_dir, &["branch", "--show-current"]).await; + let branch = branch.trim().to_string(); + let raw = exec_git(working_dir, &["status", "--porcelain"]).await; + let files: Vec = raw + .lines() + .filter(|l| !l.is_empty()) + .map(|line| { + // porcelain 格式:"XY filename",XY 为两位状态码 + let status = line.chars().take(2).collect::(); + let path = line.get(3..).unwrap_or("").trim().to_string(); + // 简化状态码:M/A/D/??/R + let simple = if status.starts_with("??") { "??" } + else if status.contains('A') { "A" } + else if status.contains('D') { "D" } + else if status.contains('R') { "R" } + else { "M" }; + serde_json::json!({ "path": path, "status": simple }) + }) + .collect(); + (branch, files) +} + +/// git diff 解析为结构化文件列表(每文件统计 + patch 截断)。 +async fn run_git_diff(working_dir: &str, staged: bool) -> serde_json::Value { + let mut args = vec!["diff", "--stat"]; + if staged { args.push("--cached"); } + let stat_raw = exec_git(working_dir, &args).await; + + // 每文件 patch(截断防 token 爆) + let mut patch_args = vec!["diff" ]; + if staged { patch_args.push("--cached"); } + let patch_raw = exec_git(working_dir, &patch_args).await; + // 截断到 8000 字符(防大体量 diff) + let patch_truncated = if patch_raw.len() > 8000 { + format!("{}\n... (diff 截断,共 {} 字符)", &patch_raw[..8000], patch_raw.len()) + } else { + patch_raw + }; + + serde_json::json!({ + "stat": stat_raw, + "patch": patch_truncated, + }) +} + +/// git log 解析为结构化提交列表。 +async fn run_git_log(working_dir: &str, limit: usize) -> Vec { + let format = "%H|%an|%ad|%s"; + let limit_str = format!("-{}", limit); + let raw = exec_git(working_dir, &["log", "--oneline", &format!("--format={}", format), &limit_str, "--date=short"]).await; + raw.lines() + .filter(|l| !l.is_empty()) + .filter_map(|line| { + let parts: Vec<&str> = line.splitn(4, '|').collect(); + if parts.len() == 4 { + Some(serde_json::json!({ + "hash": parts[0], + "author": parts[1], + "date": parts[2], + "message": parts[3], + })) + } else { None } + }) + .collect() +} + +/// Git AI 工具注册(6 个:status/diff/log 只读 Low + commit/branch 写 Medium + merge High)。 +/// +/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1: +/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义 +/// - risk 与原一致(status/diff/log=Low,commit/branch=Medium,merge=High) +/// - handler body 与原 async move 块逐字一致(逻辑零变更) +/// +/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。 +pub fn register(registry: &mut AiToolRegistry, db: &Arc) { + // ── git_status (Low 只读) ── + declare_tool!( + registry, + db: Arc, + "git_status", + "查看工程 Git 工作区状态。参数:module_id(工程 ID)。返回当前分支、改动文件列表(每个文件含路径+状态 M/A/D/??)、改动总数。只读无副作用", + RiskLevel::Low, + schema: object_schema(vec![ + ("module_id", "string", true), + ]), + args => { + let module_id = args["module_id"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?; + let repo = df_storage::crud::ProjectModuleRepo::new(&db); + let module = repo.get_by_id(module_id).await? + .ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?; + let (branch, files) = run_git_status(&module.path).await; + Ok(serde_json::json!({ + "branch": branch, + "files": files, + "total_changes": files.len(), + })) + } + ); + + // ── git_diff (Low 只读) ── + declare_tool!( + registry, + db: Arc, + "git_diff", + "查看工程未提交的代码改动。参数:module_id(工程 ID)、staged(可选 bool,仅看已暂存改动,默认 false=全部含未暂存)。返回改动统计 + patch 内容(截断防 token 爆)。只读无副作用", + RiskLevel::Low, + schema: object_schema(vec![ + ("module_id", "string", true), + ("staged", "boolean", false), + ]), + args => { + let module_id = args["module_id"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?; + let staged = args.get("staged").and_then(|v| v.as_bool()).unwrap_or(false); + let repo = df_storage::crud::ProjectModuleRepo::new(&db); + let module = repo.get_by_id(module_id).await? + .ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?; + let diff = run_git_diff(&module.path, staged).await; + Ok(diff) + } + ); + + // ── git_log (Low 只读) ── + declare_tool!( + registry, + db: Arc, + "git_log", + "查看工程 Git 提交历史。参数:module_id(工程 ID)、limit(可选 int,最近 N 条提交,默认 20,最大 100)。返回提交列表(每个含哈希/作者/消息/日期)。只读无副作用", + RiskLevel::Low, + schema: object_schema(vec![ + ("module_id", "string", true), + ("limit", "integer", false), + ]), + args => { + let module_id = args["module_id"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?; + let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20).min(100) as usize; + let repo = df_storage::crud::ProjectModuleRepo::new(&db); + let module = repo.get_by_id(module_id).await? + .ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?; + let commits = run_git_log(&module.path, limit).await; + Ok(serde_json::json!({ "commits": commits })) + } + ); + + // ── git_commit (Medium 审批) ── + // 提交工作区改动。需审批(写操作影响代码仓库)。 + // 安全边界:不提交敏感文件(.env/.key/.pem,由 .gitignore 兜底 + 此处检查)。 + declare_tool!( + registry, + db: Arc, + "git_commit", + "提交工程工作区改动到本地仓库。参数:module_id(工程 ID)、message(提交信息,必填)、add_all(可选 bool,是否添加全部改动到暂存区,默认 true)。中等风险,需审批。提交前检查不提交敏感文件(.env/.key/.pem)", + RiskLevel::Medium, + schema: object_schema(vec![ + ("module_id", "string", true), + ("message", "string", true), + ("add_all", "boolean", false), + ]), + args => { + let module_id = args["module_id"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?; + let message = args["message"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 message"))?; + let add_all = args.get("add_all").and_then(|v| v.as_bool()).unwrap_or(true); + if message.trim().is_empty() { + anyhow::bail!("提交信息不能为空"); + } + let repo = df_storage::crud::ProjectModuleRepo::new(&db); + let module = repo.get_by_id(module_id).await? + .ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?; + // 敏感文件检查:提交前查看待提交文件列表,如有 .env/.key/.pem 则拒绝 + let status_raw = exec_git(&module.path, &["status", "--porcelain"]).await; + let sensitive_patterns = [".env", ".key", ".pem", "id_rsa", ".htpasswd"]; + for line in status_raw.lines() { + let path = line.get(3..).unwrap_or("").trim().to_lowercase(); + if sensitive_patterns.iter().any(|p| path.contains(p)) { + anyhow::bail!("检测到敏感文件在待提交列表中: {}。请手动检查 .gitignore 或移除该文件后重试", line.get(3..).unwrap_or("")); + } + } + // git add + if add_all { + let _ = exec_git(&module.path, &["add", "-A"]).await; + } + // git commit + let commit_out = exec_git(&module.path, &["commit", "-m", message]).await; + // 提交后取最新 commit hash + let hash = exec_git(&module.path, &["log", "-1", "--format=%H"]).await.trim().to_string(); + Ok(serde_json::json!({ + "committed": !hash.is_empty(), + "hash": hash, + "message": message, + "output": commit_out, + })) + } + ); + + // ── git_branch (Medium 审批) ── + // 分支管理:列表/创建/切换。需审批(影响工作区分支状态)。 + declare_tool!( + registry, + db: Arc, + "git_branch", + "工程分支管理。参数:module_id(工程 ID)、action(list/create/switch,默认 list)、name(分支名,create/switch 时必填)。中等风险,需审批。list=列出所有分支+当前分支,create=创建新分支,switch=切换分支", + RiskLevel::Medium, + schema: object_schema(vec![ + ("module_id", "string", true), + ("action", "string", false), + ("name", "string", false), + ]), + args => { + let module_id = args["module_id"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?; + let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("list"); + let name = args.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let repo = df_storage::crud::ProjectModuleRepo::new(&db); + let module = repo.get_by_id(module_id).await? + .ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?; + match action { + "list" => { + let raw = exec_git(&module.path, &["branch", "--list"]).await; + let current = exec_git(&module.path, &["branch", "--show-current"]).await.trim().to_string(); + let branches: Vec = raw.lines() + .map(|l| l.trim_start_matches("* ").trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + Ok(serde_json::json!({ "current": current, "branches": branches })) + } + "create" => { + if name.is_empty() { anyhow::bail!("create 需要 name 参数"); } + let _ = exec_git(&module.path, &["branch", name]).await; + Ok(serde_json::json!({ "created": name })) + } + "switch" => { + if name.is_empty() { anyhow::bail!("switch 需要 name 参数"); } + let _ = exec_git(&module.path, &["checkout", name]).await; + let current = exec_git(&module.path, &["branch", "--show-current"]).await.trim().to_string(); + Ok(serde_json::json!({ "switched_to": current })) + } + _ => anyhow::bail!("未知 action: {},合法值: list/create/switch", action), + } + } + ); + + // ── git_merge (High 审批) ── + // 合并分支。高风险(可能产生冲突,影响代码完整性)。 + declare_tool!( + registry, + db: Arc, + "git_merge", + "合并指定分支到当前分支。参数:module_id(工程 ID)、branch(要合并的分支名,必填)。高风险,需审批。合并冲突时返回冲突文件列表,需用户手动解决", + RiskLevel::High, + schema: object_schema(vec![ + ("module_id", "string", true), + ("branch", "string", true), + ]), + args => { + let module_id = args["module_id"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?; + let branch = args["branch"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 branch"))?; + let repo = df_storage::crud::ProjectModuleRepo::new(&db); + let module = repo.get_by_id(module_id).await? + .ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?; + let merge_out = exec_git(&module.path, &["merge", branch]).await; + let has_conflict = merge_out.contains("CONFLICT") || merge_out.contains("Merge conflict"); + if has_conflict { + let status_raw = exec_git(&module.path, &["status", "--porcelain"]).await; + let conflicts: Vec = status_raw.lines() + .filter(|l| l.starts_with("UU") || l.starts_with("AA") || l.starts_with("DD")) + .map(|l| l.get(3..).unwrap_or("").trim().to_string()) + .collect(); + Ok(serde_json::json!({ + "merged": false, + "conflicts": conflicts, + "message": "合并冲突,需手动解决", + })) + } else { + Ok(serde_json::json!({ + "merged": true, + "branch": branch, + "output": merge_out, + })) + } + } + ); +} diff --git a/src-tauri/src/commands/ai/tools/http.rs b/src-tauri/src/commands/ai/tools/http.rs new file mode 100644 index 0000000..c22cac9 --- /dev/null +++ b/src-tauri/src/commands/ai/tools/http.rs @@ -0,0 +1,63 @@ +//! 网络层 AI 工具声明式注册(register_http_tools 1 个:http_request 迁入)。 +//! +//! 迁自 `tool_registry.rs::register_http_tools`(原 1 个 http_request),改用 `declare_tool!` 宏。 +//! +//! 迁移策略(handler 逻辑零变更): +//! - handler body 逐字照搬原 async move 块(转调 `crate::commands::ai::http::execute_http_request`), +//! 仅闭包包装改由 `declare_tool!` 宏生成。 +//! - name/desc/schema/risk 与原手写定义逐字一致(headers 是对象 map,object_schema 仅支持扁平 +//! 标量三元组,故保留原手工拼 serde_json::Map schema 表达式)。 +//! - 无捕获:原 handler 不 clone db(handler 仅转调 http.rs),用占位 `Arc<()>` 捕获(宏要求 capture 形参)。 +//! +//! 风险:GET=Medium(只读但触发外发)/ 写方法=High(须人工批准),一个工具名两种 risk 不支持(register +//! 单一 risk_level),故按最高风险 High 注册(写方法 High 兜底;GET 也走 High 审批更保守)。 +//! +//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 + +//! 工具名集合稳定(防 rename / 漏注册)。 + +use std::sync::Arc; + +use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; +use df_ai::declare_tool; + +/// 注册 http_request 工具到 `$registry`(无 db 捕获,纯 reqwest 调用)。 +/// +/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1: +/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义 +/// - risk=High(写方法兜底,GET 同走审批更保守) +/// - handler body 与原 async move 块逐字一致(逻辑零变更) +/// +/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。 +pub fn register(registry: &mut AiToolRegistry) { + // 无捕获:占位 Arc<()>(原 handler 不持 db,仅转调 http.rs)。 + let dummy: Arc<()> = Arc::new(()); + // schema:headers 是对象 map,object_schema 仅支持扁平标量三元组,故手工拼 serde_json::Map。 + // 提取为 let 绑定:declare_tool! 的 schema: $schema:expr 形参对花括号块表达式解析有歧义, + // 先求值到局部变量再传入,语义等价(原手写 register 亦以此 Map 作为 schema 实参)。 + let schema = { + let mut props = serde_json::Map::new(); + props.insert("method".into(), serde_json::json!({ "type": "string", "description": "HTTP 方法:GET/POST/PUT/PATCH/DELETE(默认 GET)", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] })); + props.insert("url".into(), serde_json::json!({ "type": "string", "description": "请求 URL,仅 http/https,拒私网/localhost(SSRF 防护)" })); + props.insert("headers".into(), serde_json::json!({ "type": "object", "description": "请求头 map,如 {\"Authorization\":\"Bearer xxx\",\"Content-Type\":\"application/json\"}", "additionalProperties": { "type": "string" } })); + props.insert("body".into(), serde_json::json!({ "type": "string", "description": "请求体(POST/PUT/PATCH 用),原样发送,Content-Type 须在 headers 显式指定" })); + props.insert("timeout_secs".into(), serde_json::json!({ "type": "integer", "description": "超时秒数(默认 30,上限 60)", "minimum": 1, "maximum": 60 })); + props.insert("parse".into(), serde_json::json!({ "type": "string", "description": "响应 body 解析:json(pretty 格式化)/text(原样)/auto(按 Content-Type 自动,默认)", "enum": ["json", "text", "auto"] })); + serde_json::json!({ + "type": "object", + "properties": props, + "required": ["url"], + }) + }; + declare_tool!( + registry, + dummy: Arc<()>, + "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)。安全:仅 http/https 协议,拒绝私网/保留 IP(SSRF 防护含 DNS resolve 后校验防重绑定),重定向≤3 跳且每跳重校验。响应 body 截断 50KB。GET 为只读但触发外发网络,POST/PUT/PATCH/DELETE 有副作用,统一按高风险须人工批准", + RiskLevel::High, + schema: schema, + args => { + // 转调 http.rs handler(SSRF 防护 + 重定向 + 截断全在那) + crate::commands::ai::http::execute_http_request(args).await + } + ); +} diff --git a/src-tauri/src/commands/ai/tools/idea.rs b/src-tauri/src/commands/ai/tools/idea.rs new file mode 100644 index 0000000..66b8f4a --- /dev/null +++ b/src-tauri/src/commands/ai/tools/idea.rs @@ -0,0 +1,87 @@ +//! 灵感类 AI 工具声明式注册(register_idea_tools 2 个:list + create 迁入)。 +//! +//! 迁自 `tool_registry.rs::register_idea_tools`(原 2 个 list_ideas/create_idea), +//! 改用 `declare_tool!` 宏。 +//! +//! 迁移策略(handler 逻辑零变更): +//! - handler body 逐字照搬原 `register_idea_tools` 内 async move 块(逻辑等价), +//! 仅闭包包装(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone(); +//! Box::pin(async move { ... }) }) }`)改由 `declare_tool!` 宏生成。 +//! - name/desc/schema/risk 与原手写定义逐字一致。 +//! - 复用类型/常量:new_id/IdeaStatus/IdeaRecord/now_millis(各 pub(crate) 来源,与原同源) + +//! MAX_LIST_RESULTS(super::tool_registry 单真相源)。 +//! +//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 + +//! 工具名集合稳定(防 rename / 漏注册)。 + +use std::sync::Arc; + +use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel}; +use df_ai::declare_tool; +use df_storage::db::Database; +use df_storage::models::IdeaRecord; +use df_types::types::{new_id, IdeaStatus}; + +use crate::commands::now_millis; +use crate::commands::ai::tool_registry::MAX_LIST_RESULTS; + +/// 注册 2 个灵感类工具(list/create)到 `$registry`。 +/// +/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1: +/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义 +/// - risk 与原一致(list=Low,create=Medium) +/// - handler body 与原 async move 块逐字一致(逻辑零变更) +/// +/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。 +pub fn register(registry: &mut AiToolRegistry, db: &Arc) { + declare_tool!( + registry, + db: Arc, + "list_ideas", + "列出所有灵感,支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50", + RiskLevel::Low, + schema: object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]), + args => { + let repo = df_storage::crud::IdeaRepo::new(&db); + let items = repo.list_all().await?; + let total = items.len(); + let offset = args["offset"].as_u64().unwrap_or(0) as usize; + let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize; + let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect(); + let has_more = (offset + page_items.len()) < total; + Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more })) + } + ); + + declare_tool!( + registry, + db: Arc, + "create_idea", + "捕获一个新灵感", + RiskLevel::Medium, + schema: object_schema(vec![ + ("title", "string", true), ("description", "string", false), + ("tags", "string", false), ("source", "string", false), + // priority:与 commands::idea::CreateIdeaInput 默认值一致(=1,灵感默认普通优先级) + ("priority", "integer", false), + ]), + args => { + let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?; + let repo = df_storage::crud::IdeaRepo::new(&db); + let record = IdeaRecord { + id: new_id(), title: title.to_string(), + description: args["description"].as_str().unwrap_or("").to_string(), + // priority 默认 1:灵感默认普通优先级(与 commands::idea::default_priority 及 tasks 表 SQL DEFAULT 1 对齐) + status: IdeaStatus::Draft, priority: args["priority"].as_i64().unwrap_or(1) as i32, + score: None, tags: args["tags"].as_str().map(|s| s.to_string()), + source: args["source"].as_str().map(|s| s.to_string()), + promoted_to: None, ai_analysis: None, scores: None, + related_ids: None, + created_at: now_millis(), updated_at: now_millis(), + }; + let id = record.id.clone(); + repo.insert(record).await?; + Ok(serde_json::json!({ "id": id, "title": title, "status": "draft" })) + } + ); +} diff --git a/src-tauri/src/commands/ai/tools/mod.rs b/src-tauri/src/commands/ai/tools/mod.rs index 900fa0a..e861030 100644 --- a/src-tauri/src/commands/ai/tools/mod.rs +++ b/src-tauri/src/commands/ai/tools/mod.rs @@ -11,4 +11,10 @@ pub mod list_projects; pub mod project; pub mod task; +pub mod task_graph; +pub mod git; +pub mod http; +pub mod workflow; +pub mod idea; +pub mod trash; pub mod file; diff --git a/src-tauri/src/commands/ai/tools/task_graph.rs b/src-tauri/src/commands/ai/tools/task_graph.rs new file mode 100644 index 0000000..59ae3b3 --- /dev/null +++ b/src-tauri/src/commands/ai/tools/task_graph.rs @@ -0,0 +1,447 @@ +//! 知识图谱 / 工程系统类 AI 工具声明式注册(register_task_graph_tools 10 个迁入)。 +//! +//! 迁自 `tool_registry.rs::register_task_graph_tools`(原 10 个:task_link CRUD + 父子树 +//! + 跨池移动 + content 更新 + 项目事件流 + 项目基础设施配置 + 工程列表),改用 `declare_tool!` 宏。 +//! +//! 迁移策略(handler 逻辑零变更): +//! - handler body 逐字照搬原 `register_task_graph_tools` 内 async move 块(逻辑等价), +//! 仅闭包包装(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone(); +//! Box::pin(async move { ... }) }) }`)改由 `declare_tool!` 宏生成。 +//! - name/desc/schema/risk 与原手写定义逐字一致。 +//! - 复用类型:new_id/ProjectServiceRecord/now_millis(各 pub(crate) 来源,与原同源)。 +//! +//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 + +//! 工具名集合稳定(防 rename / 漏注册)。 + +use std::sync::Arc; + +use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel}; +use df_ai::declare_tool; +use df_storage::db::Database; +use df_storage::models::ProjectServiceRecord; +use df_types::types::new_id; + +use crate::commands::now_millis; + +/// 注册 10 个知识图谱 / 工程系统类工具到 `$registry`。 +/// +/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1: +/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义 +/// - risk 与原一致(link 写/queue 移动/content 更新/add_service=Medium,只读=Low) +/// - handler body 与原 async move 块逐字一致(逻辑零变更) +/// +/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。 +pub fn register(registry: &mut AiToolRegistry, db: &Arc) { + // ── create_task_link (Medium,设计 §2.2 + §五) ── + // 建立任务横向关联。TaskLinkRepo::create_link 内含: + // link_type 白名单(depends_on/blocks/relates_to)+ 自环拒绝 + depends_on BFS 循环依赖检测。 + // handler 仅转发参数 + new_id,业务校验全部下沉 Repo 层(单一真相源,与 IPC create_task_link 同源)。 + declare_tool!( + registry, + db: Arc, + "create_task_link", + "建立任务横向关联(AI 拓扑排序编排调度基础)。参数:task_id(source 任务,关联发起方)、target_id(target 任务,关联指向方)、link_type(depends_on 依赖/blocks 阻塞/relates_to 弱关联)、remark(可选备注)。约束:link_type 白名单校验;source==target 自环拒绝;depends_on 链 BFS 循环依赖检测(A→B→A 拒绝);跨项目允许。返回 link id", + RiskLevel::Medium, + schema: object_schema(vec![ + ("task_id", "string", true), ("target_id", "string", true), + ("link_type", "string", true), ("remark", "string", false), + ]), + args => { + let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?; + let target_id = args["target_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 target_id"))?; + let link_type = args["link_type"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 link_type"))?; + let remark = args.get("remark").and_then(|v| v.as_str()).filter(|s| !s.is_empty()); + let repo = df_storage::crud::TaskLinkRepo::new(&db); + let id = new_id(); + let link_id = repo.create_link(&id, task_id, target_id, link_type, remark).await?; + Ok(serde_json::json!({ "id": link_id, "task_id": task_id, "target_id": target_id, "link_type": link_type })) + } + ); + + // ── remove_task_link (Medium,设计 §2.2 + §五) ── + // 解除任务关联(按 link id)。返回是否命中(deleted=false=link 不存在)。 + declare_tool!( + registry, + db: Arc, + "remove_task_link", + "解除任务关联(按 link id)。返回 deleted 布尔(是否命中,link 不存在则 false)", + RiskLevel::Medium, + schema: object_schema(vec![("id", "string", true)]), + args => { + let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; + let repo = df_storage::crud::TaskLinkRepo::new(&db); + let deleted = repo.delete(id).await?; + Ok(serde_json::json!({ "deleted": deleted, "id": id })) + } + ); + + // ── list_task_links (Low 只读,设计 §2.2 + §五) ── + // 查询任务关联(方向:outgoing 作为 source / incoming 作为 target / both 双向合并去重)。 + // AI 编排调度用:取某任务的全部依赖/阻塞/关联,做拓扑排序。 + declare_tool!( + registry, + db: Arc, + "list_task_links", + "查询任务关联(AI 编排调度用)。参数:task_id(目标任务)、direction(查询方向 outgoing=作为 source 查我依赖谁 / incoming=作为 target 查谁依赖我 / both=双向合并去重,默认 both)。返回关联记录列表(按 created_at 升序)", + RiskLevel::Low, + schema: object_schema(vec![("task_id", "string", true), ("direction", "string", false)]), + args => { + let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?; + // 方向默认 both(对齐 commands::task::LinkDirection::default)。空串/未传走 both。 + let direction = args.get("direction").and_then(|v| v.as_str()).unwrap_or("both").to_lowercase(); + let repo = df_storage::crud::TaskLinkRepo::new(&db); + let links = match direction.as_str() { + "outgoing" => repo.get_by_source(task_id).await?, + "incoming" => repo.get_by_target(task_id).await?, + // both:Outgoing + Incoming 合并后按 id 去重(同一 link 双向查询可能重复) + _ => { + let mut outgoing = repo.get_by_source(task_id).await?; + let incoming = repo.get_by_target(task_id).await?; + let mut seen: std::collections::HashSet = + outgoing.iter().map(|l| l.id.clone()).collect(); + for l in incoming { + if seen.insert(l.id.clone()) { + outgoing.push(l); + } + } + outgoing.sort_by(|a, b| a.created_at.cmp(&b.created_at)); + outgoing + } + }; + let total = links.len(); + Ok(serde_json::json!({ "items": links, "total": total, "task_id": task_id, "direction": direction })) + } + ); + + // ── get_task_tree (Low 只读,设计 §2.1 + §五) ── + // 获取父子任务树(限 1 级嵌套,设计 D2:无孙任务)。返回 parent + 直接 children 列表。 + // AI 用途:查看需求分解结构、子任务进度聚合。 + declare_tool!( + registry, + db: Arc, + "get_task_tree", + "获取任务父子树(限 1 级嵌套,设计 D2 无孙任务)。参数 task_id(任务 ID,无论叶子还是父任务)。返回 { parent: 父任务记录, children: 直接子任务列表(parent_id 指向 task_id,按 created_at 升序) }。叶子任务 children 为空。AI 用途:查看需求分解结构、子任务进度聚合", + RiskLevel::Low, + schema: object_schema(vec![("task_id", "string", true)]), + args => { + let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?; + let repo = df_storage::crud::TaskRepo::new(&db); + let parent = repo + .get_by_id(task_id) + .await? + .ok_or_else(|| anyhow::anyhow!("任务 {task_id} 不存在"))?; + let children = repo.get_children(task_id).await?; + let child_count = children.len(); + Ok(serde_json::json!({ "parent": parent, "children": children, "child_count": child_count })) + } + ); + + // ── move_task_queue (Medium,设计 §2.1 + §五) ── + // 跨池移动任务(backlog/todo/decision/active/done),按一致性约束联动 status。 + // 对齐 commands::task::move_task_queue 语义: + // - done → status 强制 done / backlog → status 强制 todo + // - active → status 若不在执行中三态则强制 in_progress / todo → status 强制 todo + // - decision → status 不变(待决策池保留执行态) + // status 写入走专用 set_status_for_aggregation(绕过 status 收口,move_task_queue 是合法非状态机路径)。 + declare_tool!( + registry, + db: Arc, + "move_task_queue", + "跨池移动任务(管理维度池 backlog/todo/decision/active/done,与 status 执行维度正交)。参数 id(任务 ID)、new_queue(目标池)。一致性约束联动 status:done→status=done / backlog→status=todo / active→status 若非执行中三态强制 in_progress / todo→status=todo / decision→status 不变。返回移动后的最新 TaskRecord", + RiskLevel::Medium, + schema: object_schema(vec![("id", "string", true), ("new_queue", "string", true)]), + args => { + let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; + let new_queue = args["new_queue"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 new_queue"))? + .trim() + .to_string(); + // queue 白名单校验(对标 commands::task::validate_queue) + const TASK_QUEUE_VALUES: &[&str] = &["backlog", "todo", "decision", "active", "done"]; + if !TASK_QUEUE_VALUES.contains(&new_queue.as_str()) { + anyhow::bail!("非法 new_queue 值 {:?},合法值: {:?}", new_queue, TASK_QUEUE_VALUES); + } + + let repo = df_storage::crud::TaskRepo::new(&db); + let current = repo + .get_by_id(id) + .await? + .ok_or_else(|| anyhow::anyhow!("任务 {id} 不存在"))?; + + // 一致性约束联动:根据 new_queue 决定 status 是否需调整 + // (对标 commands::task::move_task_queue 同源逻辑,单一真相源) + const ACTIVE_OK_STATUSES: &[&str] = &["in_progress", "in_review", "testing"]; + let new_status = match new_queue.as_str() { + "done" => "done".to_string(), + "backlog" => "todo".to_string(), + "active" => { + if ACTIVE_OK_STATUSES.contains(¤t.status.as_str()) { + current.status.as_str().to_string() + } else { + "in_progress".to_string() + } + } + "todo" => "todo".to_string(), + "decision" => current.status.as_str().to_string(), + _ => unreachable!("queue 白名单已收口"), + }; + + // 写 queue(queue 已在 tasks 白名单);与 current 不同才写(避免无谓 updated_at 抖动) + if current.queue != new_queue { + repo.update_field(id, "queue", &new_queue).await?; + } + // 写 status(专用 set_status_for_aggregation 绕过 status 收口,合法非状态机路径) + if current.status.as_str() != new_status { + repo.set_status_for_aggregation(id, &new_status).await?; + } + + // 回读最新记录返回 + let updated = repo + .get_by_id(id) + .await? + .ok_or_else(|| anyhow::anyhow!("任务 {id} 不存在(移动后回读失败)"))?; + Ok(serde_json::to_value(&updated)?) + } + ); + + // ── update_content (Medium,设计 §五) ── + // 更新 content_json 结构化需求规格({background,acceptance_criteria[],scope[],technical_design})。 + // 走通用 update_field(content_json 已在 tasks 白名单) + JSON 合法性校验(对齐 create_task 校验)。 + // AI 从对话中提取信息填充 content_json,人确认后 AI 执行中用 acceptance_criteria 自检。 + declare_tool!( + registry, + db: Arc, + "update_content", + "更新任务的结构化需求规格 content_json(JSON 字符串 {background,acceptance_criteria[],scope[],technical_design,custom_fields})。参数 id(任务 ID)、content_json(合法 JSON 字符串,空串清空 content_json)。校验:须是合法 JSON,否则拒。AI 用途:从对话提取信息填充需求规格,供后续 acceptance_criteria 自检", + RiskLevel::Medium, + schema: object_schema(vec![("id", "string", true), ("content_json", "string", true)]), + args => { + let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; + let content_json = args["content_json"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 content_json"))?; + // JSON 合法性校验(对齐 commands::task::create_task 的 content_json 校验) + // 非空串须是合法 JSON;空串视为清空(写 NULL)——但 update_field 收 string, + // 用空串写库后会留空字符串而非 NULL(可接受,content_json 为 Option 但 + // 通用 update_field 走 TEXT 列接受空串)。这里对非空串做校验,空串直接放行清空。 + if !content_json.trim().is_empty() { + serde_json::from_str::(content_json) + .map_err(|e| anyhow::anyhow!("content_json 不是合法 JSON: {e}"))?; + } + let repo = df_storage::crud::TaskRepo::new(&db); + repo.update_field(id, "content_json", content_json).await?; + let title = repo.get_by_id(id).await?.map(|t| t.title).unwrap_or_default(); + Ok(serde_json::json!({ "id": id, "title": title, "field": "content_json", "updated": true })) + } + ); + + // ── get_project_timeline (Low 只读,设计 §五 + §2.4) ── + // 查询项目事件流(project_events 统一事件流)。AI 精准检索基础: + // 回答"上周做了什么 / 这个任务为何 blocked / 决策何时做出"。 + // 参数 project_id(必填) + event_type(可选过滤) + limit(默认 50,上限 200)。 + // source 标 "ai"(AI 工具路径,区别于 IPC 层的 "human")。 + declare_tool!( + registry, + db: Arc, + "get_project_timeline", + "查询项目事件流(AI 精准检索基础,回答\"上周做了什么/这个任务为何 blocked/决策何时做出\")。参数 project_id(必填,项目 ID)、event_type(可选,按事件类型精确过滤,如 task_created/task_advanced/idea_promoted/idea_evaluated/decision_made 等,空=不过滤)、limit(可选,返回条数上限,默认 50,内部钳制 ≤ 200)。返回 { items: 事件列表(时间倒序), total, project_id }。事件含 event_type/entity_type/entity_id/from_state/to_state/source/created_at", + RiskLevel::Low, + schema: object_schema(vec![ + ("project_id", "string", true), + ("event_type", "string", false), + ("limit", "integer", false), + ]), + args => { + let project_id = args["project_id"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 project_id"))? + .trim() + .to_string(); + if project_id.is_empty() { + anyhow::bail!("project_id 不能为空"); + } + let event_filter = args.get("event_type") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + // limit 默认 50(对齐 commands::events::default_limit);负数/异常值兜底 50。 + let limit = args.get("limit") + .and_then(|v| v.as_u64()) + .map(|n| n as u32) + .unwrap_or(50); + + let repo = df_storage::crud::ProjectEventRepo::new(&db); + let mut events = repo.get_by_project(&project_id, limit).await?; + // 应用层按 event_type 过滤(单用户事件量小,保持 Repo 方法精简)。 + if let Some(et) = &event_filter { + events.retain(|e| e.event_type == *et); + } + let total = events.len(); + Ok(serde_json::json!({ + "items": events, + "total": total, + "project_id": project_id, + })) + } + ); + + // ── add_project_service (Medium,设计 §2.3 + §五 + D10) ── + // 添加项目依赖的基础设施配置(数据库/缓存/MQ/API 等)。为 AI 执行任务时提供基础设施上下文: + // "这项目用了什么数据库、Redis 在哪、有没有 MQ"。 + // service_type 白名单 + D10 凭证审查下沉 Repo::insert_validated(单一真相源,与 IPC + // add_project_service 同源)。 + // + // ⚠️ D10 安全边界:config_json / endpoint / remark 不含敏感凭证(密码/密钥/Token)。 + // Repo 层审查 password/secret/token/api_key 子串,命中即拒。凭证走环境变量。 + declare_tool!( + registry, + db: Arc, + "add_project_service", + "为项目添加基础设施配置(为 AI 执行任务时提供\"用了什么数据库/缓存/MQ/API\"上下文)。参数:project_id(项目 ID)、name(服务名,如 主库/Redis 缓存)、service_type(类型,白名单 mysql/postgresql/sqlite/redis/mongodb/mq/api/other)、environment(环境 development/staging/production,默认 development)、endpoint(可选,连接地址 localhost:3306 或 URL,不含凭证)、config_json(可选,类型相关配置 JSON 字符串,如 {\"pool_size\":10},⚠️不含密码字段,凭证走环境变量)、remark(可选,备注)。返回新增完整记录。约束:service_type 白名单;D10 安全边界 config_json/endpoint/remark 含 password/secret/token/api_key 子串拒绝", + RiskLevel::Medium, + schema: object_schema(vec![ + ("project_id", "string", true), + ("name", "string", true), + ("service_type", "string", true), + ("environment", "string", false), + ("endpoint", "string", false), + ("config_json", "string", false), + ("remark", "string", false), + ]), + args => { + let project_id = args["project_id"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 project_id"))? + .trim() + .to_string(); + let name = args["name"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 name"))? + .trim() + .to_string(); + let service_type = args["service_type"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 service_type"))? + .trim() + .to_string(); + if project_id.is_empty() { + anyhow::bail!("project_id 不能为空"); + } + if name.is_empty() { + anyhow::bail!("name 不能为空"); + } + if service_type.is_empty() { + anyhow::bail!("service_type 不能为空"); + } + let endpoint = args.get("endpoint") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + let config_json = args.get("config_json") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + let environment = args.get("environment") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("development") + .to_string(); + let remark = args.get("remark") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + let record = ProjectServiceRecord { + id: new_id(), + project_id: project_id.clone(), + name, + service_type, + endpoint, + config_json, + environment, + remark, + created_at: now_millis(), + updated_at: now_millis(), + }; + let repo = df_storage::crud::ProjectServiceRepo::new(&db); + // insert_validated 内含 service_type 白名单 + D10 凭证审查(密码/token/api_key 子串拒绝)。 + let id = repo.insert_validated(record).await?; + let inserted = repo.get_by_id(&id).await? + .ok_or_else(|| anyhow::anyhow!("插入后回读失败"))?; + Ok(serde_json::to_value(&inserted)?) + } + ); + + // ── list_project_services (Low 只读,设计 §2.3 + §五) ── + // 查询项目基础设施配置。AI 执行任务时按需取基础设施上下文: + // 部署 production 任务只取 production 服务,开发调试取 development(环境隔离避免误连生产库)。 + declare_tool!( + registry, + db: Arc, + "list_project_services", + "查询项目基础设施配置(AI 执行任务时取\"用了什么数据库/缓存/MQ/API\"上下文)。参数:project_id(项目 ID)、environment(可选,按环境过滤 development/staging/production,空=返回所有环境)。返回 { items: 配置列表(时间倒序), total, project_id }。环境隔离:production 部署任务取 production 服务,开发调试取 development,避免误连生产库", + RiskLevel::Low, + schema: object_schema(vec![ + ("project_id", "string", true), + ("environment", "string", false), + ]), + args => { + let project_id = args["project_id"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 project_id"))? + .trim() + .to_string(); + if project_id.is_empty() { + anyhow::bail!("project_id 不能为空"); + } + let env_filter = args.get("environment") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + let repo = df_storage::crud::ProjectServiceRepo::new(&db); + let items = if let Some(env) = &env_filter { + repo.list_by_project_env(&project_id, env).await? + } else { + repo.list_by_project(&project_id).await? + }; + let total = items.len(); + Ok(serde_json::json!({ + "items": items, + "total": total, + "project_id": project_id, + })) + } + ); + + // ── list_project_modules (Low 只读,工程系统) ── + // 查询项目工程列表(AI 取项目结构上下文:有哪些代码仓库、各自目录/技术栈/Git 地址)。 + // 多工程场景(Monorepo/微服务/前后端分离)AI 据此决定在哪个工程执行 git/构建操作。 + declare_tool!( + registry, + db: Arc, + "list_project_modules", + "查询项目工程列表(每个工程是独立代码仓库)。参数:project_id(项目 ID)。返回 { modules: 工程列表(含 name/path/git_url/stack), total }。AI 据此了解项目有哪些代码仓库及各自技术栈", + RiskLevel::Low, + schema: object_schema(vec![ + ("project_id", "string", true), + ]), + args => { + let project_id = args["project_id"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 project_id"))? + .trim() + .to_string(); + if project_id.is_empty() { + anyhow::bail!("project_id 不能为空"); + } + let repo = df_storage::crud::ProjectModuleRepo::new(&db); + let modules = repo.list_by_project(&project_id).await?; + let total = modules.len(); + Ok(serde_json::json!({ + "modules": modules, + "total": total, + "project_id": project_id, + })) + } + ); +} diff --git a/src-tauri/src/commands/ai/tools/trash.rs b/src-tauri/src/commands/ai/tools/trash.rs new file mode 100644 index 0000000..0f623b1 --- /dev/null +++ b/src-tauri/src/commands/ai/tools/trash.rs @@ -0,0 +1,50 @@ +//! 回收站类 AI 工具声明式注册(register_trash_tools 1 个:list_trash 迁入)。 +//! +//! 迁自 `tool_registry.rs::register_trash_tools`(原 1 个 list_trash),改用 `declare_tool!` 宏。 +//! +//! 迁移策略(handler 逻辑零变更): +//! - handler body 逐字照搬原 `register_trash_tools` 内 async move 块(逻辑等价), +//! 仅闭包包装(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone(); +//! Box::pin(async move { ... }) }) }`)改由 `declare_tool!` 宏生成。 +//! - name/desc/schema/risk 与原手写定义逐字一致。 +//! - 复用常量:MAX_LIST_RESULTS(super::tool_registry 单真相源)。 +//! +//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 + +//! 工具名集合稳定(防 rename / 漏注册)。 + +use std::sync::Arc; + +use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel}; +use df_ai::declare_tool; +use df_storage::db::Database; + +use crate::commands::ai::tool_registry::MAX_LIST_RESULTS; + +/// 注册 list_trash 工具到 `$registry`。 +/// +/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1: +/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义 +/// - risk=Low(只读) +/// - handler body 与原 async move 块逐字一致(逻辑零变更) +/// +/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。 +pub fn register(registry: &mut AiToolRegistry, db: &Arc) { + declare_tool!( + registry, + db: Arc, + "list_trash", + "列出回收站已删除项目,支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50", + RiskLevel::Low, + schema: object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]), + args => { + let repo = df_storage::crud::ProjectRepo::new(&db); + let items = repo.list_deleted().await?; + let total = items.len(); + let offset = args["offset"].as_u64().unwrap_or(0) as usize; + let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize; + let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect(); + let has_more = (offset + page_items.len()) < total; + Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more })) + } + ); +} diff --git a/src-tauri/src/commands/ai/tools/workflow.rs b/src-tauri/src/commands/ai/tools/workflow.rs new file mode 100644 index 0000000..466396f --- /dev/null +++ b/src-tauri/src/commands/ai/tools/workflow.rs @@ -0,0 +1,55 @@ +//! 工作流类 AI 工具声明式注册(register_workflow_tools 1 个:run_workflow 迁入)。 +//! +//! 迁自 `tool_registry.rs::register_workflow_tools`(原 1 个 run_workflow),改用 `declare_tool!` 宏。 +//! +//! 迁移策略(handler 逻辑零变更): +//! - handler body 逐字照搬原 async move 块(防御兜底返 Err,正常流程不可达),仅闭包包装改由 +//! `declare_tool!` 宏生成。 +//! - name/desc/schema/risk 与原手写定义逐字一致。 +//! - 无捕获:CR-52 删死代码后 handler 不 clone db(防御兜底,run_workflow 真正执行经 +//! ai_approve → execute_run_workflow_for_tool → workflow.rs::run_workflow_inner),用占位 +//! `Arc<()>` 捕获(宏要求 capture 形参)。 +//! +//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 + +//! 工具名集合稳定(防 rename / 漏注册)。 + +use std::sync::Arc; + +use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel}; +use df_ai::declare_tool; + +/// 注册 run_workflow 工具到 `$registry`(无 db 捕获,防御兜底)。 +/// +/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1: +/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义 +/// - risk=High(触发工作流引擎执行,须人工批准) +/// - handler body 与原 async move 块逐字一致(逻辑零变更,仅返 Err 兜底) +/// +/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。 +pub fn register(registry: &mut AiToolRegistry) { + // 无捕获:占位 Arc<()>(CR-52 后 handler 不持 db,仅返 Err 防御)。 + let dummy: Arc<()> = Arc::new(()); + declare_tool!( + registry, + dummy: Arc<()>, + "run_workflow", + "按任务 target_status 推进对应工作流(含 AiNode 自审 / HumanNode 核对闸门)。参数 task_id + target_status 同时提供才联动任务推进(完成后按 target_status 推进任务,失败按退回态回滚)。属高风险操作(触发工作流引擎执行),须人工批准。审批通过后由后端直接执行工作流引擎并联动推进任务,返回 execution_id", + RiskLevel::High, + 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"))?; + let target_status = args["target_status"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 target_status"))?; + // 防御兜底(正常流程不可达):run_workflow 经 ai_approve 分支执行,不经此 handler。 + // 误入此路径说明调用方异常(非 ai_approve 直接 execute),返回明确错误勿盲目重试 + // (对齐 run_command 超时标注模式 L700-712),High risk 去重缓存会断 LLM 重试循环。 + Err(anyhow::anyhow!( + "run_workflow 须经人工审批后由后端 ai_approve 分支执行(转调 run_workflow_inner,\ + 持完整 State)。本 handler 经 ai_tools.execute 调用属异常路径(无 AppHandle/State),\ + 勿盲目重试同调用(task_id={}, target_status={});\ + 若需推进任务,重新发起 run_workflow tool_call 走审批流程。", + task_id, target_status + )) + } + ); +}