//! 项目类 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); // 透传 affected:0 行(id 不存在 / 已软删)→ updated=false + name 空, // 让 AI 知道 id 不对而非假成功(对齐 update_idea / update_task)。 let updated = repo.update_field(id, field, value).await?; // 返回 name 供前端卡片友好展示(对齐 update_task 返 title);update_field 返 bool 不含 // record,单独 get_by_id 取 name;0 行时 get_by_id 返 None→name 空,配合 updated:false。 let name = repo.get_by_id(id).await?.map(|p| p.name).unwrap_or_default(); Ok(serde_json::json!({ "id": id, "name": name, "field": field, "updated": updated })) } ); // ── 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() })) } ); }