From c002f0b352ba9c8d41913b07bd8f3976cb0a9ce6 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 12:56:19 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20tool=5Fregistry=20?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=E5=BC=8F=E6=B3=A8=E5=86=8C=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E8=AE=BE=E6=96=BD(declare=5Ftool!=20=E5=AE=8F=20+=20list=5Fpro?= =?UTF-8?q?jects=20=E8=AF=95=E7=82=B9,=E9=9B=B6=E5=9B=9E=E5=BD=92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/df-ai/src/ai_tools_decl.rs | 154 ++++++++++++++++++ crates/df-ai/src/lib.rs | 5 + src-tauri/src/commands/ai/mod.rs | 3 + src-tauri/src/commands/ai/tool_registry.rs | 27 +-- .../src/commands/ai/tools/list_projects.rs | 57 +++++++ src-tauri/src/commands/ai/tools/mod.rs | 11 ++ 6 files changed, 239 insertions(+), 18 deletions(-) create mode 100644 crates/df-ai/src/ai_tools_decl.rs create mode 100644 src-tauri/src/commands/ai/tools/list_projects.rs create mode 100644 src-tauri/src/commands/ai/tools/mod.rs diff --git a/crates/df-ai/src/ai_tools_decl.rs b/crates/df-ai/src/ai_tools_decl.rs new file mode 100644 index 0000000..8377383 --- /dev/null +++ b/crates/df-ai/src/ai_tools_decl.rs @@ -0,0 +1,154 @@ +//! 声明式工具注册基础设施(tool_registry 拆分第一步) +//! +//! 背景:`tool_registry.rs` 4224 行,每加一个工具手写一段 +//! `registry.register(name, desc, object_schema(...), RiskLevel::X, { ... Box::new ... })`, +//! 5 段重复样板 + 闭包捕获 + 缩进极易出错。本模块提供声明式宏 `declare_tool!`, +//! 把工具定义收敛为「名字 / 描述 / schema / 风险 / handler 块」五要素一行式声明。 +//! +//! 设计取舍(三选一权衡): +//! - A. proc-macro `#[ai_tool]` 属性宏:需新建 proc-macro crate + syn/quote 重依赖,违反 +//! 「不引新依赖」。否决。 +//! - B. `inventory` crate 自动收集:新外部依赖 + 拉入 linkme/ctor 运行期注册语义, +//! 与现有 `register_*(&mut registry)` 命令式收集并存需双重 source-of-truth。否决。 +//! - C. **`macro_rules!` 声明式宏(本方案)**:零新依赖(纯 std `macro_rules!`), +//! 展开为等价的 `registry.register(...)` 调用——与现有 48 个 `register` 调用**完全并存**, +//! 旧工具零改动,新工具可选声明式。handler 块就地书写,`$db`/`$registry` 等捕获变量 +//! 原样透传,语义 1:1 等价(schema/risk/handler 同源不变)。 +//! +//! 试点:`list_projects` 已迁至声明式(见 commands/ai/tools/list_projects.rs),验证编译过 + +//! 行为等价 + 48 工具基线不破。其余 47 个工具后续批次渐进迁移,不在此步。 +//! +//! 宏展开示例(输入): +//! ```ignore +//! declare_tool!(registry, db: Arc, "list_projects", +//! "列出所有项目...", RiskLevel::Low, +//! schema: object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]), +//! args => { +//! let repo = ProjectRepo::new(&db); +//! let items = repo.list_active().await?; +//! // ... +//! Ok(json!({ "items": items, ... })) +//! }); +//! ``` +//! 展开后等价于现有手写的: +//! ```ignore +//! registry.register( +//! "list_projects", "...", +//! object_schema(vec![...]), RiskLevel::Low, +//! { let db = db.clone(); Box::new(move |args: serde_json::Value| { +//! let db = db.clone(); +//! Box::pin(async move { /* handler body */ }) +//! })}, +//! ); +//! ``` + +/// 声明式注册一个 AI 工具到 `$registry`。 +/// +/// 与手写 `registry.register(name, desc, schema, risk, handler)` 语义 1:1 等价, +/// 仅消除闭包包装样板(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone(); Box::pin(async move { ... }) }) }`)。 +/// +/// 形参: +/// - `$registry`: `&mut AiToolRegistry` 注册表句柄。 +/// - `$capture: $cap_ty`: handler 需捕获的外部变量(如 `db: Arc`)。宏自动 clone 进闭包, +/// handler body 内以 `$capture` 名访问。无捕获工具用 `_` 占位(并保证 body 不引用它)。 +/// - `$name`: 工具名 `&str`。 +/// - `$desc`: 工具描述 `&str`(发给 LLM)。 +/// - `$risk`: `RiskLevel`(如 `RiskLevel::Low`)。 +/// - `$schema`: 参数 JSON Schema(`serde_json::Value`),常用 `object_schema(...)`。 +/// - `$handler`: 一个 **block 表达式**(花括号体),返回 `anyhow::Result`。 +/// 体内在 `$args`(serde_json::Value)与 `$capture`(克隆后的捕获变量)上工作。 +/// 宏负责把它包进 `async move { ... }` 并 `Box::pin`——故调用方只写「同步语义的体」, +/// 不写 `async move`/`Box::pin`/`Box::new` 三层样板。 +/// +/// 注意:此宏不替代 `register`,而是包装它——`register` 仍是 `AiToolRegistry` 的唯一注册入口, +/// 宏仅是语法糖。现有 48 个手写 `register` 调用不动,新工具改用 `declare_tool!`。 +#[macro_export] +macro_rules! declare_tool { + ( + $registry:expr, + $capture:ident : $cap_ty:ty, + $name:expr, + $desc:expr, + $risk:expr, + schema: $schema:expr, + $args:ident => $handler:block + ) => {{ + let __cap: $cap_ty = $capture.clone(); + $registry.register( + $name, $desc, $schema, $risk, + { + let $capture = __cap.clone(); + ::std::boxed::Box::new(move |$args: ::serde_json::Value| { + let $capture = $capture.clone(); + ::std::boxed::Box::pin(async move { + let $capture: $cap_ty = $capture; + $handler + }) + as ::std::pin::Pin<::std::boxed::Box< + dyn ::std::future::Future> + + ::std::marker::Send, + >> + }) + }, + ); + }}; +} + +#[cfg(test)] +mod tests { + use crate::ai_tools::{object_schema, AiToolRegistry, RiskLevel}; + + /// 声明式宏注册的工具与手写 register 行为等价(name/schema/risk/执行结果一致)。 + /// 这是「现有工具零回归 + 语义等价」的最小验证(试点 list_projects 迁移的微观镜像)。 + #[tokio::test] + async fn declare_tool_equivalent_to_register() { + let mut reg = AiToolRegistry::new(); + + // 声明式注册(新基础设施) + let counter: std::sync::Arc = + std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + declare_tool!( + reg, + counter: std::sync::Arc, + "echo_decl", + "声明式 echo 工具", + RiskLevel::Low, + schema: object_schema(vec![("msg", "string", true)]), + args => { + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let msg = args["msg"].as_str().unwrap_or(""); + Ok(serde_json::json!({ "echo": msg })) + } + ); + + // 等价断言:定义层(name 存在 / risk 正确 / schema 透传) + assert_eq!(reg.len(), 1, "声明式宏应注册 1 个工具"); + let tool = reg.get("echo_decl").expect("echo_decl 应已注册"); + assert_eq!(tool.risk_level, RiskLevel::Low); + assert_eq!(tool.definition.function.name, "echo_decl"); + assert_eq!(tool.definition.function.parameters["properties"]["msg"]["type"], "string"); + + // 执行等价:handler 收到 args、捕获变量可访问、返回 handler body 的 Value + let out = reg.execute("echo_decl", serde_json::json!({ "msg": "hi" })).await.unwrap(); + assert_eq!(out["echo"], "hi"); + assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 1, "捕获变量应在执行时递增"); + } + + /// 无捕获工具(纯计算/常量返回)用占位 capture,handler 不引用它。 + #[tokio::test] + async fn declare_tool_no_capture() { + let mut reg = AiToolRegistry::new(); + let dummy: std::sync::Arc<()> = std::sync::Arc::new(()); // 无真实捕获,占位 + declare_tool!( + reg, + dummy: std::sync::Arc<()>, + "const_tool", + "无捕获常量工具", + RiskLevel::Medium, + schema: object_schema(vec![]), + _args => { Ok(serde_json::json!({ "ok": true })) } + ); + let out = reg.execute("const_tool", serde_json::json!({})).await.unwrap(); + assert_eq!(out["ok"], true); + } +} diff --git a/crates/df-ai/src/lib.rs b/crates/df-ai/src/lib.rs index 1f45f72..42b5165 100644 --- a/crates/df-ai/src/lib.rs +++ b/crates/df-ai/src/lib.rs @@ -1,6 +1,11 @@ //! df-ai: AI 编排 — LLM Provider、Agent 协调、上下文管理、流式处理、工具注册 pub mod ai_tools; +// 声明式工具注册宏 `declare_tool!`(tool_registry 拆分第一步基础设施)。 +// macro_rules + #[macro_export]:零新依赖,展开为等价 `register` 调用,与现有 48 工具并存。 +// 试点:list_projects 已迁至声明式(commands/ai/tools/list_projects.rs),验证可行+零回归。 +#[macro_use] +pub mod ai_tools_decl; pub mod anthropic_compat; pub mod anthropic_helpers; pub mod context; diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index 1c4070b..5ba5361 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -44,6 +44,9 @@ pub mod skills; pub mod stream_recv; pub mod title; pub mod tool_registry; +// 声明式工具注册集合(tool_registry 拆分第一步 · 试点容器)。 +// 试点:list_projects 已迁声明式 declare_tool! 宏,其余 47 工具仍在 tool_registry.rs。 +pub mod tools; use agentic::conv_state::{ConvState, ConvStateStore}; diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index c12e9f3..974b890 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -18,7 +18,10 @@ use crate::state::AllowedDirs; /// CRUD list 工具的默认返回上限(防 LLM context 膨胀) /// 用于 list_projects / list_tasks / list_ideas / list_trash -const MAX_LIST_RESULTS: usize = 50; +/// +/// tool_registry 拆分第一步:list_projects 已迁声明式试点(tools/list_projects.rs), +/// 复用本常量保单真相源(避免字面量 50 漂移),故 pub(crate) 暴露给 tools 子模块。 +pub(crate) const MAX_LIST_RESULTS: usize = 50; /// run_command 默认超时(秒)。LLM 可在 args timeout_secs 覆盖此默认值。 /// 提取为常量便于在超时标注处引用同一来源(F-260616-04)。 @@ -522,23 +525,11 @@ fn register_data_tools(registry: &mut AiToolRegistry, db: &Arc) { /// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。 /// 组内顺序保留原相对顺序(list/update/create/bind/delete/restore/purge/get_count)。 fn register_project_tools(registry: &mut AiToolRegistry, db: &Arc) { - registry.register( - "list_projects", "列出所有项目,支持 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_active().await?; // list_active 排除回收站(deleted_at),防 LLM 看到已软删项目 - 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 })) - }) - })}, - ); + // 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)]), diff --git a/src-tauri/src/commands/ai/tools/list_projects.rs b/src-tauri/src/commands/ai/tools/list_projects.rs new file mode 100644 index 0000000..01afdd7 --- /dev/null +++ b/src-tauri/src/commands/ai/tools/list_projects.rs @@ -0,0 +1,57 @@ +//! 试点工具:`list_projects` 声明式注册(tool_registry 拆分第一步 · 迁移试点) +//! +//! 验证 `declare_tool!` 宏基础设施可行:从原 `register_project_tools` 第 1 个 register 调用 +//! (tool_registry.rs:524-541)迁移到声明式,行为逐字等价(schema/risk/handler body 同源)。 +//! +//! 迁移策略(并存零回归): +//! - 原 `register_project_tools` 内的 list_projects register 调用删除,改调本模块 `register`。 +//! - 其余 7 个项目工具(update/create/bind/delete/restore/purge/get_count)仍手写在原处, +//! 不动(本步只迁 1 个试点,后续批次渐进)。 +//! - `MAX_LIST_RESULTS` 由 tool_registry.rs 改 `pub(crate)` 暴露,试点复用同一常量(单真相源, +//! 避免字面量 50 漂移)。 +//! +//! 等价性验证:基线测试 test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量; +//! execute("list_projects", args) 行为不变(list_active 分页 + has_more 语义)。 + +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_projects` 到 `$registry`。 +/// +/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1: +/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义 +/// - risk = RiskLevel::Low(只读,无副作用) +/// - handler body 与原 async move 块逐字一致(list_active 排软删 + offset/limit 分页 + +/// has_more 语义 + 默认/上限对齐 MAX_LIST_RESULTS) +/// +/// 唯一差异:闭包包装(`{ let db = db.clone(); Box::new(move |args| { ... Box::pin(async move {...}) }) }`) +/// 改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。 +pub fn register(registry: &mut AiToolRegistry, db: &Arc) { + declare_tool!( + registry, + db: Arc, + "list_projects", + "列出所有项目,支持 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); + // list_active 排除回收站(deleted_at),防 LLM 看到已软删项目 + let items = repo.list_active().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/mod.rs b/src-tauri/src/commands/ai/tools/mod.rs new file mode 100644 index 0000000..68eb6a4 --- /dev/null +++ b/src-tauri/src/commands/ai/tools/mod.rs @@ -0,0 +1,11 @@ +//! 声明式注册工具集合(tool_registry 拆分第一步 · 试点容器) +//! +//! 容纳迁自 `tool_registry.rs` 手写 register 调用、改用 `declare_tool!` 宏的工具。 +//! 本步(基础设施 + 1 试点)仅迁 `list_projects`,其余 47 个工具仍在 tool_registry.rs 手写, +//! 后续批次渐进迁移(每批 1-3 个,各自独立文件,互不影响)。 +//! +//! 设计:每个工具一个独立文件 + `register(&mut registry, db)` 入口,tool_registry.rs +//! 对应 `register_*` 函数改调本模块(原手写 register 调用删除)。这样工具定义与实现同源、 +//! 文件边界清晰,新增工具只加文件 + 在对应 register_* 接一行,不动 4224 行巨函数主体。 + +pub mod list_projects;