新增: AI 工具(download_file + http output_file + grep 窗口 + fetch_search 搜索 + get_app_config + generate_image)

download_file 跨平台 URL 到文件流式下载;http_request output_file 落盘 + 截断可配;
grep context_chars 大单行窗口;fetch_search DuckDuckGo 免 key 搜索;get_app_config
只读返当前配置(治 AI 查配置绕 PowerShell);generate_image SenseNova 图像生成。
This commit is contained in:
lxy
2026-08-02 02:21:32 +08:00
parent e4f7b432aa
commit b999f79fdf
17 changed files with 2862 additions and 56 deletions
+383
View File
@@ -0,0 +1,383 @@
//! 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<Database> 非 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<df_storage::db::Database>,
/// Agentic 循环最大轮次(热改即生效)
pub agent_max_iterations: Arc<AtomicUsize>,
/// 流式对话失败自动重试次数(热改即生效)
pub agent_max_retries: Arc<AtomicUsize>,
/// 审批超时分钟数(热改即生效)
pub approval_timeout_minutes: Arc<AtomicU64>,
/// 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": { "<allowed_key>": "<value>", "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<Value> {
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<Value> = 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<Mutex<Arc<Semaphore>>> 内层无读取接口),只返 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<Database>) -> 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<Database>,
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 全脱敏)");
}
}