diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index 974b890..203c35b 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -9,9 +9,15 @@ use tokio::sync::{Mutex as TokioMutex, RwLock}; use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; use df_execute::shell::{execute, ShellRequest}; use df_storage::db::Database; -use df_storage::models::{ProjectRecord, ProjectServiceRecord, TaskRecord, IdeaRecord}; +use df_storage::models::{ProjectServiceRecord, TaskRecord, IdeaRecord}; -use df_types::types::{new_id, IdeaStatus, ProjectStatus, TaskStatus}; +use df_types::types::{new_id, IdeaStatus, TaskStatus}; +// ProjectRecord/ProjectStatus 仅 #[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; use crate::commands::now_millis; use crate::state::AllowedDirs; @@ -395,7 +401,7 @@ fn resolve_workspace_path_impl(path: &str, allowed: &AllowedDirs) -> anyhow::Res /// /// - `stack_opt`: 调用方解析好的逗号分隔技术栈原始串(None=让本函数自动探测) /// - 返回最终采用的 stack(供调用方回填响应,保持原 bind_directory 返回结构) -async fn bind_dir_to_project( +pub(crate) async fn bind_dir_to_project( repo: &df_storage::crud::ProjectRepo, id: &str, path: &str, @@ -524,137 +530,14 @@ fn register_data_tools(registry: &mut AiToolRegistry, db: &Arc) { /// 项目类 AI 工具注册(8 个:CRUD + 目录绑定 + 探总量)——持 db:Arc。 /// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。 /// 组内顺序保留原相对顺序(list/update/create/bind/delete/restore/purge/get_count)。 +/// +/// tool_registry 拆分:8 个项目工具全部迁声明式(tools/list_projects.rs 试点 + +/// tools/project.rs 剩余 7 个),handler body 逐字照搬原 async move 块(逻辑零变更), +/// 仅闭包包装改由 `declare_tool!` 宏生成。基线测试 test_build_ai_tool_registry_baseline_tool_count +/// 仍断言 48 总量 + 工具名集合稳定。 fn register_project_tools(registry: &mut AiToolRegistry, db: &Arc) { - // tool_registry 拆分第一步:list_projects 迁声明式试点(tools/list_projects.rs), - // 改调 declare_tool! 宏注册。其余 7 个项目工具仍手写在下方,零改动(共存)。 - // 行为逐字等价(schema/risk/handler body 同源),基线测试 test_build_ai_tool_registry_baseline_tool_count - // 仍断言 48 总量。 super::tools::list_projects::register(registry, db); - registry.register( - "update_project", "更新项目的指定字段(name/status/description/path/stack),需要提供项目 ID、字段名和新值。绑定代码目录推荐改用 bind_directory", - df_ai::ai_tools::object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "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 field = args["field"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 field"))?; - let value = args["value"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 value"))?; - // 复用 df-storage CRUD 白名单(按表隔离),与 update_field 校验同源 - if !df_storage::crud::is_allowed_column("projects", field) { - anyhow::bail!("不允许更新字段 '{}'", field); - } - let repo = df_storage::crud::ProjectRepo::new(&db); - repo.update_field(id, field, value).await?; - Ok(serde_json::json!({ "id": id, "field": field, "updated": true })) - }) - })}, - ); - registry.register( - "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(); - Box::pin(async move { - let name = args["name"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 name 参数"))?; - let description = args["description"].as_str().unwrap_or(""); - let repo = df_storage::crud::ProjectRepo::new(&db); - let record = ProjectRecord { - id: new_id(), name: name.to_string(), description: description.to_string(), - status: ProjectStatus::Planning, idea_id: None, - path: None, stack: None, - created_at: now_millis(), updated_at: now_millis(), - }; - let id = record.id.clone(); - repo.insert(record).await?; - // path 缺省:保持原行为(仅创建,不绑定);有 path 则复用公共绑定闭包(create_project 与 bind_directory 同源) - let path_opt = args.get("path").and_then(|v| v.as_str()).filter(|s| !s.is_empty()); - if let Some(path) = path_opt { - let stack_opt = args.get("stack").and_then(|v| v.as_str()).filter(|s| !s.is_empty()); - let stack = bind_dir_to_project(&repo, &id, path, stack_opt).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" })) - } - }) - })}, - ); - registry.register( - "bind_directory", "为项目绑定代码目录(自动探测技术栈,防重复绑定)。path 应为**项目根路径**(单仓库=仓库根;多工程 monorepo=工作区根,非单个子工程路径;子工程信息走 list_project_modules/update_project_module 维护)。多次调用会覆盖 path 字段,monorepo 场景勿为每个子工程重复绑定。可选 stack 手动指定技术栈(如 'Go / Gin / Vue3'),不传则自动探测。", - df_ai::ai_tools::object_schema(vec![("id", "string", true), ("path", "string", true), ("stack", "string", false)]), - 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 path = args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?; - let stack_opt = args.get("stack").and_then(|v| v.as_str()).filter(|s| !s.is_empty()); - let repo = df_storage::crud::ProjectRepo::new(&db); - let stack = bind_dir_to_project(&repo, id, path, stack_opt).await?; - // F-260619-03: 项目绑定目录加 AI 白名单由 reload_allowed_dirs 自动读 projects.bind_directory - // 统一处理(优雅:绑定 = 自动授权,reload 时合并)。bind handler 不直接写 allowed_dirs - // (register_project_tools 无 allowed_dirs 参数,且 reload 集中更易维护)。 - // 绑定后触发 reload:通过 app_handle emit 事件让 state reload,或下次工具调用前 reload 兜底。 - Ok(serde_json::json!({ "id": id, "path": path, "stack": stack, "bound": true })) - }) - })}, - ); - registry.register( - "delete_project", "删除项目(移入回收站,可恢复。永久删除用 purge_project)", - df_ai::ai_tools::object_schema(vec![("id", "string", true)]), RiskLevel::High, - { 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::ProjectRepo::new(&db); - let deleted = repo.soft_delete(id).await?; - Ok(serde_json::json!({ "deleted": deleted, "id": id })) - }) - })}, - ); - registry.register( - "restore_project", "从回收站恢复已删除项目", - df_ai::ai_tools::object_schema(vec![("id", "string", true)]), RiskLevel::High, - { 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::ProjectRepo::new(&db); - let restored = repo.restore(id).await?; - Ok(serde_json::json!({ "restored": restored, "id": id })) - }) - })}, - ); - registry.register( - "purge_project", "永久删除项目及关联数据(不可恢复)", - df_ai::ai_tools::object_schema(vec![("id", "string", true)]), RiskLevel::High, - { 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::ProjectRepo::new(&db); - let purged = repo.purge_with_descendants(id).await?; - Ok(serde_json::json!({ "purged": purged, "id": id })) - }) - })}, - ); - // ── 探总量工具 (Low risk, F-260616-08) ── - registry.register( - "get_project_count", "获取项目总数(未删除项目),用于分页策略判断。返回 { total: usize }", - df_ai::ai_tools::object_schema(vec![]), 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_active().await?; - Ok(serde_json::json!({ "total": items.len() })) - }) - })}, - ); + super::tools::project::register(registry, db); } /// 任务类 AI 工具注册(6 个:CRUD + 状态机推进 + 探总量)——持 db:Arc。 diff --git a/src-tauri/src/commands/ai/tools/mod.rs b/src-tauri/src/commands/ai/tools/mod.rs index 68eb6a4..5d339df 100644 --- a/src-tauri/src/commands/ai/tools/mod.rs +++ b/src-tauri/src/commands/ai/tools/mod.rs @@ -9,3 +9,4 @@ //! 文件边界清晰,新增工具只加文件 + 在对应 register_* 接一行,不动 4224 行巨函数主体。 pub mod list_projects; +pub mod project; diff --git a/src-tauri/src/commands/ai/tools/project.rs b/src-tauri/src/commands/ai/tools/project.rs new file mode 100644 index 0000000..cbe7936 --- /dev/null +++ b/src-tauri/src/commands/ai/tools/project.rs @@ -0,0 +1,179 @@ +//! 项目类 AI 工具声明式注册(register_project_tools 剩余 7 个迁入)。 +//! +//! 第一步试点已迁 `list_projects`(见 tools/list_projects.rs),本文件迁完 +//! `register_project_tools` 的剩余 7 个:update_project/create_project/bind_directory/ +//! delete_project/restore_project/purge_project/get_project_count。 +//! +//! 迁移策略(handler 逻辑零变更): +//! - handler body 逐字照搬原 `register_project_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 与原手写定义逐字一致。 +//! - `bind_dir_to_project`(create_project/bind_directory 共用)原为 tool_registry.rs +//! 私有 helper,本模块跨 super::tool_registry 调用,故该 helper 已改 `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::ProjectRecord; +use df_types::types::{new_id, ProjectStatus}; + +use crate::commands::now_millis; +use crate::commands::ai::tool_registry::bind_dir_to_project; + +/// 注册剩余 7 个项目类工具(update/create/bind/delete/restore/purge/get_count)到 `$registry`。 +/// +/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1: +/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义 +/// - risk 与原一致(update/create/bind=Medium,delete/restore/purge=High,get_count=Low) +/// - handler body 与原 async move 块逐字一致(逻辑零变更) +/// +/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。 +pub fn register(registry: &mut AiToolRegistry, db: &Arc) { + // ── update_project (Medium) ── + declare_tool!( + registry, + db: Arc, + "update_project", + "更新项目的指定字段(name/status/description/path/stack),需要提供项目 ID、字段名和新值。绑定代码目录推荐改用 bind_directory", + RiskLevel::Medium, + schema: object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]), + args => { + let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; + let field = args["field"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 field"))?; + let value = args["value"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 value"))?; + // 复用 df-storage CRUD 白名单(按表隔离),与 update_field 校验同源 + if !df_storage::crud::is_allowed_column("projects", field) { + anyhow::bail!("不允许更新字段 '{}'", field); + } + let repo = df_storage::crud::ProjectRepo::new(&db); + repo.update_field(id, field, value).await?; + Ok(serde_json::json!({ "id": id, "field": field, "updated": true })) + } + ); + + // ── create_project (Medium) ── + declare_tool!( + registry, + db: Arc, + "create_project", + "创建新项目,可选传 path/stack 一步完成创建+绑定目录(无需再调 bind_directory)", + RiskLevel::Medium, + schema: object_schema(vec![ + ("name", "string", true), ("description", "string", false), + ("path", "string", false), ("stack", "string", false), + ]), + args => { + let name = args["name"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 name 参数"))?; + let description = args["description"].as_str().unwrap_or(""); + let repo = df_storage::crud::ProjectRepo::new(&db); + let record = ProjectRecord { + id: new_id(), name: name.to_string(), description: description.to_string(), + status: ProjectStatus::Planning, idea_id: None, + path: None, stack: None, + created_at: now_millis(), updated_at: now_millis(), + }; + let id = record.id.clone(); + repo.insert(record).await?; + // path 缺省:保持原行为(仅创建,不绑定);有 path 则复用公共绑定闭包(create_project 与 bind_directory 同源) + let path_opt = args.get("path").and_then(|v| v.as_str()).filter(|s| !s.is_empty()); + if let Some(path) = path_opt { + let stack_opt = args.get("stack").and_then(|v| v.as_str()).filter(|s| !s.is_empty()); + let stack = bind_dir_to_project(&repo, &id, path, stack_opt).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" })) + } + } + ); + + // ── bind_directory (Medium) ── + declare_tool!( + registry, + db: Arc, + "bind_directory", + "为项目绑定代码目录(自动探测技术栈,防重复绑定)。path 应为**项目根路径**(单仓库=仓库根;多工程 monorepo=工作区根,非单个子工程路径;子工程信息走 list_project_modules/update_project_module 维护)。多次调用会覆盖 path 字段,monorepo 场景勿为每个子工程重复绑定。可选 stack 手动指定技术栈(如 'Go / Gin / Vue3'),不传则自动探测。", + RiskLevel::Medium, + schema: object_schema(vec![("id", "string", true), ("path", "string", true), ("stack", "string", false)]), + args => { + let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id 参数"))?; + let path = args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?; + let stack_opt = args.get("stack").and_then(|v| v.as_str()).filter(|s| !s.is_empty()); + let repo = df_storage::crud::ProjectRepo::new(&db); + let stack = bind_dir_to_project(&repo, id, path, stack_opt).await?; + // F-260619-03: 项目绑定目录加 AI 白名单由 reload_allowed_dirs 自动读 projects.bind_directory + // 统一处理(优雅:绑定 = 自动授权,reload 时合并)。bind handler 不直接写 allowed_dirs + // (register_project_tools 无 allowed_dirs 参数,且 reload 集中更易维护)。 + // 绑定后触发 reload:通过 app_handle emit 事件让 state reload,或下次工具调用前 reload 兜底。 + Ok(serde_json::json!({ "id": id, "path": path, "stack": stack, "bound": true })) + } + ); + + // ── delete_project (High) ── + declare_tool!( + registry, + db: Arc, + "delete_project", + "删除项目(移入回收站,可恢复。永久删除用 purge_project)", + RiskLevel::High, + 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::ProjectRepo::new(&db); + let deleted = repo.soft_delete(id).await?; + Ok(serde_json::json!({ "deleted": deleted, "id": id })) + } + ); + + // ── restore_project (High) ── + declare_tool!( + registry, + db: Arc, + "restore_project", + "从回收站恢复已删除项目", + RiskLevel::High, + 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::ProjectRepo::new(&db); + let restored = repo.restore(id).await?; + Ok(serde_json::json!({ "restored": restored, "id": id })) + } + ); + + // ── purge_project (High) ── + declare_tool!( + registry, + db: Arc, + "purge_project", + "永久删除项目及关联数据(不可恢复)", + RiskLevel::High, + 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::ProjectRepo::new(&db); + let purged = repo.purge_with_descendants(id).await?; + Ok(serde_json::json!({ "purged": purged, "id": id })) + } + ); + + // ── get_project_count (Low risk, F-260616-08) ── + declare_tool!( + registry, + db: Arc, + "get_project_count", + "获取项目总数(未删除项目),用于分页策略判断。返回 { total: usize }", + RiskLevel::Low, + schema: object_schema(vec![]), + _args => { + let repo = df_storage::crud::ProjectRepo::new(&db); + let items = repo.list_active().await?; + Ok(serde_json::json!({ "total": items.len() })) + } + ); +}