//! get_app_config AI 工具 — 查 DevFlow 自身当前生效的 AI 配置(只读 + 脱敏)。 //! //! ## 治什么症状 //! //! 实测 AI 想看「当前 provider/model/agent 设置」时,因没原生「查配置」工具,只能 run_command //! 执行 PowerShell 内联 node/python 脚本查 db(node sqlite/...),命令行引号三重嵌套必失败 //! (PowerShell 单引号包 node -e 内含 JS 模板字符串/双引号/db 路径,转义层数爆炸,复盘实证)。 //! 本工具直接返当前配置 JSON,LLM 一调即得,根治 run_command 绕行。 //! //! ## 安全(只读 + 脱敏) //! //! - **只读**:纯 list_all/load 内存值,无任何写库/写文件副作用。RiskLevel::Low。 //! - **api_key 脱敏**:复用 [`mask_api_key`](crate::commands::ai::commands::mask_api_key) //! (前 4 + `••••` + 后 4),迁移后 DB api_key 空列 → 经 keyring 解析真实 key 再脱敏, //! 绝不向 LLM 回灌明文 key(对齐 ai_list_providers IPC 脱敏口径,单点维护)。 //! - **app_settings 不返敏感 KV**:仅返与 AI 行为直接相关的已知无害 key //! (knowledge/timeout/custom_prompt 存在性 + 长度),自定义指令原文不回灌防指令注入。 //! //! ## 数据来源(全部经 GetAppConfigCtx 捕获句柄,无 AppState 自引用) //! //! - providers / default_provider:`AiProviderRepo::new(&ctx.db).list_all()` + keyring 解析 //! - agent:`ctx.agent_max_iterations` 等内存原子量当前 load 值(热改即反映) //! - app_settings:`SettingsRepo::new(&ctx.db).get_all()` 后白名单筛选 //! //! ## 为何不直接捕获 &AppState //! //! `build_ai_tool_registry` 在 `AppState::init` 内、AppState 构建中被调用(state.rs), //! 此时无法传 `&AppState`(自引用 + 循环依赖,对齐 resolver 持 Arc 非 AppState 的 //! 既有设计)。故把所需 Arc 句柄(db + agent 配置原子量 + LlmConcurrency)打包成 //! [`GetAppConfigCtx`],state.rs init 先建这些 Arc(不依赖 db)再 build registry 注入。 use std::sync::atomic::Ordering; use std::sync::Arc; use std::sync::atomic::{AtomicU64, AtomicUsize}; use serde_json::{json, Value}; use df_storage::crud::{AiProviderRepo, SettingsRepo}; use crate::state::LlmConcurrency; /// 复用 IPC 层脱敏规则(单点维护,与 ai_list_providers 同口径)。 use crate::commands::ai::commands::mask_api_key; /// keyring 异步解析(Tauri 单线程 runtime,同步 keyring 调用会卡)。 use crate::commands::ai::secret::get_provider_secret_async; /// app_settings KV 白名单(仅这些 key 进 LLM 视图,其余敏感/无关 KV 一律不返)。 /// /// 选取标准:与 AI 行为直接相关 + 无敏感凭证内容。`custom_prompt` 只返长度不返原文 /// (用户可能写入含指令的私有提示,原文回灌进 LLM 上下文有指令注入风险 + 撑爆 prompt)。 const ALLOWED_SETTING_KEYS: &[&str] = &[ // 知识库行为配置(摘要/提炼开关,JSON 结构;值含开关不含凭证) "df-knowledge-config", // 审批超时(ms 数值,持久化版,与 AppState.approval_timeout_minutes 互补) "df-approval-timeout", ]; /// get_app_config 工具注册期捕获的句柄集合(绕 AppState 自引用)。 /// /// 各字段均为 `Arc`(或内部全 Arc 的 Clone 廉价类型),clone 进工具闭包后与 AppState /// 字段共享同一底层原子量/Semaphore,故 handler 读到的永远是「当前生效值」 /// (经 ai_set_concurrency_config / ai_set_agent_max_iterations 等 IPC 热改后立即反映)。 /// /// 字段对应 AppState 同名字段,state.rs init 先建这些 Arc 再组装 state(顺序调整, /// 因 build_ai_tool_registry 在 AppState 构建中被调,无法传 &AppState)。 /// /// `Clone` 廉价(全 Arc 字段):declare_tool! 宏把 ctx clone 进工具闭包,每次工具执行 /// 再 clone 一份进 async move 块(宏展开语义,见 crates/df-ai/src/ai_tools_decl.rs)。 #[derive(Clone)] pub struct GetAppConfigCtx { /// 数据库句柄(读 ai_providers / app_settings) pub db: Arc, /// Agentic 循环最大轮次(热改即生效) pub agent_max_iterations: Arc, /// 流式对话失败自动重试次数(热改即生效) pub agent_max_retries: Arc, /// 审批超时分钟数(热改即生效) pub approval_timeout_minutes: Arc, /// LLM 并发控制(内部全 Arc,Clone 廉价,共享同一组 Semaphore) pub llm_concurrency: LlmConcurrency, } /// 单个 provider 的脱敏视图(无 api_key 字段,绝不向 LLM 暴露任何 key 痕迹)。 /// /// 列表用:返所有 provider 概要(name/type/url/model/是否默认/是否启用),AI 知道有哪些 /// provider 可用,不返 model_configs(详组能力配置,体积大且对「查当前配置」无必要)。 fn provider_summary(p: &df_storage::models::AiProviderRecord) -> Value { json!({ "name": p.name, "provider_type": p.provider_type, "base_url": p.base_url, "default_model": p.default_model, "is_default": p.is_default, "enabled": p.enabled, }) } /// 默认 provider 的扩展视图(含 api_key_masked,供 AI 判断 key 是否配置/有效)。 /// /// 仅默认这一条返脱敏 key(其他 provider 列表项不带 key 字段),平衡「AI 需知道 key 在不在」 /// 与「最小披露」。空 key → 空串(明确告知 key 缺失,而非误导性地返 mask 占位)。 fn default_provider_view( p: &df_storage::models::AiProviderRecord, real_key: &str, ) -> Value { let mut view = provider_summary(p); // 作为对象才能插入字段(provider_summary 返的是 Object)。 if let Some(obj) = view.as_object_mut() { let masked = if real_key.is_empty() { String::new() } else { mask_api_key(real_key) }; obj.insert("api_key_masked".into(), json!(masked)); } view } /// get_app_config 工具 handler 入口。 /// /// 返回结构: /// ```jsonc /// { /// "default_provider": { name, provider_type, base_url, default_model, is_default, enabled, api_key_masked: "sk-l••••3Yab" } | null, /// "providers": [{ name, provider_type, base_url, default_model, is_default, enabled }, ...], /// "agent": { max_iterations, max_retries, approval_timeout_minutes, concurrency: { per_conv } }, /// "app_settings": { "": "", "custom_prompt_present": bool, "custom_prompt_length"?: number } /// } /// ``` /// /// 全程只读:`list_all` / `load` / `get_all`,无任何 mutate 调用,无 IO 副作用。 pub(crate) async fn execute_get_app_config(ctx: &GetAppConfigCtx) -> anyhow::Result { let providers_repo = AiProviderRepo::new(&ctx.db); let settings_repo = SettingsRepo::new(&ctx.db); // ── providers:全部列表 + 默认项(带脱敏 key)── let providers = providers_repo .list_all() .await .map_err(|e| anyhow::anyhow!("读 ai_providers 失败: {}", e))?; // 默认 provider:取首条 is_default=true(list_all 已按 created_at DESC 排序,首条即最新)。 // 不做 is_default 收敛写库(只读工具,收敛是 ai_list_providers IPC 的职责),脏数据照原样读。 let default_idx = providers.iter().position(|p| p.is_default); // 解析默认 provider 的真实 key(keyring 异步,仅这一条调 keyring,避免列表全量解析拖慢)。 let default_real_key = match default_idx { Some(i) => { let p = &providers[i]; if !p.api_key.is_empty() { p.api_key.clone() // 未迁移(老明文) } else { get_provider_secret_async(p.id.clone()) .await .unwrap_or_default() // 迁移后从 keyring } } None => String::new(), }; let default_provider = match default_idx { Some(i) => json!(default_provider_view(&providers[i], &default_real_key)), None => Value::Null, }; // 列表:全部 provider 概要(无 key 字段)。 let providers_view: Vec = providers.iter().map(provider_summary).collect(); // ── agent:内存原子量当前生效值(热改即反映,无需重启)── let max_iterations = ctx.agent_max_iterations.load(Ordering::SeqCst); let max_retries = ctx.agent_max_retries.load(Ordering::SeqCst); let approval_timeout_minutes = ctx.approval_timeout_minutes.load(Ordering::SeqCst); // concurrency:per_conv permits 可读(AtomicUsize);global 无 getter(Semaphore permits // 封装在 Arc>> 内层无读取接口),只返 per_conv 当前生效值。 // global 默认 3,经 ai_set_concurrency_config 热改后内存生效但不暴露读取,故此处省略 // 不返猜测值,避免误导 LLM(若 LLM 需 global 当前值,后续可在 LlmConcurrency 补 getter)。 let per_conv_permits = ctx.llm_concurrency.current_per_conv_permits(); let agent = json!({ "max_iterations": max_iterations, "max_retries": max_retries, "approval_timeout_minutes": approval_timeout_minutes, "concurrency": { "per_conv": per_conv_permits, }, }); // ── app_settings:白名单筛选(防敏感 KV 泄露 + 防 prompt 噪音)── let all_settings = settings_repo .get_all() .await .map_err(|e| anyhow::anyhow!("读 app_settings 失败: {}", e))?; let mut app_settings = serde_json::Map::new(); for (key, value) in all_settings { if ALLOWED_SETTING_KEYS.contains(&key.as_str()) { app_settings.insert(key, json!(value)); } } // custom_prompt 仅返长度 + 是否存在,不返原文(防指令注入 + 防撑 prompt)。 match settings_repo.get("custom_prompt").await { Ok(Some(cp)) => { let len = cp.chars().count(); app_settings.insert("custom_prompt_present".into(), json!(true)); app_settings.insert("custom_prompt_length".into(), json!(len)); } _ => { app_settings.insert("custom_prompt_present".into(), json!(false)); } } Ok(json!({ "default_provider": default_provider, "providers": providers_view, "agent": agent, "app_settings": app_settings, })) } #[cfg(test)] mod tests { use super::*; use df_storage::crud::AiProviderRepo; use df_storage::db::Database; use df_storage::models::AiProviderRecord; /// 构造测试用 GetAppConfigCtx(默认值,handler 实际执行读真实 db 数据)。 fn make_ctx(db: Arc) -> GetAppConfigCtx { GetAppConfigCtx { db, agent_max_iterations: Arc::new(AtomicUsize::new(10)), agent_max_retries: Arc::new(AtomicUsize::new(3)), approval_timeout_minutes: Arc::new(AtomicU64::new(15)), llm_concurrency: LlmConcurrency::new(3, 2), } } /// 插入一条测试 provider(老明文路径:DB api_key 非空,不触发 keyring,测试无 OS 副作用)。 async fn insert_provider( db: &Arc, id: &str, name: &str, api_key: &str, is_default: bool, enabled: bool, ) { let repo = AiProviderRepo::new(db); repo.insert(AiProviderRecord { id: id.to_string(), name: name.to_string(), provider_type: "openai_compat".to_string(), api_key: api_key.to_string(), base_url: "https://api.example.com".to_string(), default_model: "glm-4-flash".to_string(), models: None, model_configs: Vec::new(), is_default, config: None, created_at: "0".to_string(), updated_at: "0".to_string(), enabled, weight: 50, }) .await .expect("insert provider"); } /// 返回结构完整:四顶层键齐全 + providers 是数组。 #[tokio::test] async fn returns_full_structure() { let db = Arc::new(Database::open_in_memory().await.unwrap()); insert_provider(&db, "p1", "默认", "sk-abcdef123456", true, true).await; let ctx = make_ctx(db); let res = execute_get_app_config(&ctx).await.unwrap(); // 四顶层键齐全 assert!(res.get("default_provider").is_some(), "缺 default_provider"); assert!(res.get("providers").is_some(), "缺 providers"); assert!(res.get("agent").is_some(), "缺 agent"); assert!(res.get("app_settings").is_some(), "缺 app_settings"); // providers 是数组 assert!(res["providers"].is_array(), "providers 应为数组"); // agent 含四字段 assert_eq!(res["agent"]["max_iterations"], 10); assert_eq!(res["agent"]["max_retries"], 3); assert_eq!(res["agent"]["approval_timeout_minutes"], 15); assert_eq!(res["agent"]["concurrency"]["per_conv"], 2); } /// api_key 脱敏:默认 provider 返前 4 + •••• + 后 4,非空 key 不返明文。 #[tokio::test] async fn default_provider_api_key_masked() { let db = Arc::new(Database::open_in_memory().await.unwrap()); // 13 字符 key:前 4 = sk-a, 后 4 = 3456, 中间 •••• insert_provider(&db, "p1", "默认", "sk-abcdef123456", true, true).await; let ctx = make_ctx(db); let res = execute_get_app_config(&ctx).await.unwrap(); let masked = res["default_provider"]["api_key_masked"] .as_str() .expect("default_provider 应含 api_key_masked"); assert_eq!(masked, "sk-a••••3456", "13 字符 key 应脱敏为 前4+••••+后4"); // 绝不含完整明文 assert!(!masked.contains("abcdef"), "脱敏值绝不含明文中段"); assert!(!masked.contains("1234"), "脱敏值绝不含明文尾段(除最后 4)"); } /// 列表项 providers 不含 api_key 字段(最小披露:仅默认项带脱敏 key)。 #[tokio::test] async fn providers_list_has_no_api_key_field() { let db = Arc::new(Database::open_in_memory().await.unwrap()); insert_provider(&db, "p1", "默认", "sk-abcdef123456", true, true).await; insert_provider(&db, "p2", "备用", "sk-xxxxxxxxxxxx", false, true).await; let ctx = make_ctx(db); let res = execute_get_app_config(&ctx).await.unwrap(); let providers = res["providers"].as_array().unwrap(); assert_eq!(providers.len(), 2, "应有 2 个 provider"); for p in providers { assert!( p.get("api_key_masked").is_none(), "providers 列表项不应含 api_key_masked(仅默认项带)" ); assert!( p.get("api_key").is_none(), "providers 列表项绝不含 api_key 字段" ); // 列表项含必要概要字段 assert!(p.get("name").is_some()); assert!(p.get("provider_type").is_some()); assert!(p.get("base_url").is_some()); assert!(p.get("default_model").is_some()); assert!(p.get("is_default").is_some()); assert!(p.get("enabled").is_some()); } } /// 空 key(未配置)→ api_key_masked 为空串,明确告知缺失而非误导性占位。 #[tokio::test] async fn empty_api_key_returns_empty_string() { let db = Arc::new(Database::open_in_memory().await.unwrap()); // DB api_key 空且 keyring 无此 id → resolve 返空 → handler 返空串 // (keyring get_provider_secret_async 对不存在 id 返 None → unwrap_or_default 空串) insert_provider(&db, "p1", "无key", "", true, true).await; let ctx = make_ctx(db); let res = execute_get_app_config(&ctx).await.unwrap(); let masked = res["default_provider"]["api_key_masked"] .as_str() .expect("default_provider 应含 api_key_masked"); assert_eq!(masked, "", "空 key 应返空串(明确缺失),非误导性 mask 占位"); } /// 无默认 provider → default_provider 为 null(不 panic,不臆造)。 #[tokio::test] async fn no_default_provider_returns_null() { let db = Arc::new(Database::open_in_memory().await.unwrap()); insert_provider(&db, "p1", "非默认", "sk-abcdef123456", false, true).await; let ctx = make_ctx(db); let res = execute_get_app_config(&ctx).await.unwrap(); assert!( res["default_provider"].is_null(), "无默认 provider 时 default_provider 应为 null" ); // providers 列表仍正常返 assert_eq!(res["providers"].as_array().unwrap().len(), 1); } /// 短 key(≤8 字符)全脱敏为 •,不泄露长度外的信息(对齐 mask_api_key 规则)。 #[tokio::test] async fn short_api_key_fully_masked() { let db = Arc::new(Database::open_in_memory().await.unwrap()); insert_provider(&db, "p1", "短key", "sk-ab", true, true).await; let ctx = make_ctx(db); let res = execute_get_app_config(&ctx).await.unwrap(); let masked = res["default_provider"]["api_key_masked"] .as_str() .unwrap(); assert_eq!(masked, "•••••", "5 字符 key 应全脱敏为 5 个 •(≤8 全脱敏)"); } }