diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5146442..5d4056f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -60,7 +60,7 @@ keyring = { workspace = true } # 复用 df-ai 同款 reqwest 0.12(同版本锁定,避免双 TLS 后端)。 # 使用 rustls-tls(非 native-tls),避免 Windows SChannel 同步阻塞 tokio 工作线程 # 致 BUG-2026-07-17(aichat 流式调用永久 hang,外层 tokio timeout 亦无法推进计时器)。 -reqwest = { version = "0.12", default-features = false, features = ["json", "gzip", "brotli", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "gzip", "brotli", "rustls-tls", "stream"] } # fetch_url AI 工具:URL → markdown 文档嗅探(GET HTML → htmd 转 markdown → 去噪音 + 截断)。 # 替代 http_request 拿原始 HTML(噪声大、爆 token)。turndown.js 移植,只读 GET,与 http_request 共享 SSRF 防护。 htmd = "0.5" diff --git a/src-tauri/src/commands/ai/commands/provider.rs b/src-tauri/src/commands/ai/commands/provider.rs index 9a06de1..f5023ba 100644 --- a/src-tauri/src/commands/ai/commands/provider.rs +++ b/src-tauri/src/commands/ai/commands/provider.rs @@ -37,8 +37,11 @@ use super::super::secret::{ // 提供商管理 // ============================================================ -/// api_key 脱敏:IPC 不传明文给前端(FR-S1),保留首尾各 4 字符便于辨识 -fn mask_api_key(key: &str) -> String { +/// api_key 脱敏:IPC 不传明文给前端(FR-S1),保留首尾各 4 字符便于辨识。 +/// +/// `pub(crate)` 供 get_app_config AI 工具复用(查 DevFlow 自身 provider 配置时同样脱敏, +/// 不向 LLM 回灌明文 key,与 ai_list_providers IPC 走同一套脱敏规则,单点维护)。 +pub(crate) fn mask_api_key(key: &str) -> String { let chars: Vec = key.chars().collect(); if chars.len() <= 8 { return "•".repeat(chars.len()); diff --git a/src-tauri/src/commands/ai/download_file.rs b/src-tauri/src/commands/ai/download_file.rs new file mode 100644 index 0000000..175e022 --- /dev/null +++ b/src-tauri/src/commands/ai/download_file.rs @@ -0,0 +1,308 @@ +//! download_file AI 工具 — URL → 文件(跨平台流式下载,无 shell/PowerShell 陷阱) +//! +//! 设计目标:让 LLM 把远程文件(URL)落到本地磁盘,替代 `run_command` 里的 +//! `curl -o` / `wget` / `Invoke-WebRequest`。后者在 Windows 上踩 alias、编码、 +//! 跨平台陷阱(PowerShell alias 表不同、GBK 解码、Invoke-WebRequest 不在 PS Core 外可用、 +//! curl 在 Win 是 alias = Invoke-WebRequest 而非真 curl)。本工具纯 reqwest + tokio::fs, +//! 跨平台一致,无 shell spawn。 +//! +//! ## 与 http_request / fetch_url 的分工 +//! +//! - `http_request`:结构化 API 调用,返回 body(截断 50KB),不落盘。 +//! - `fetch_url`:网页 → markdown 文档嗅探,返回 markdown,不落盘。 +//! - `download_file`:**URL → 落盘文件**(二进制/大文件友好,流式写,不进 prompt)。 +//! +//! ## 安全 +//! +//! 1. **SSRF 防护**:复用 `commands::ai::http` 的 `validate_url` / `resolve_and_check_host` / +//! `build_client` / `execute_with_redirects`(协议白名单 + 私网 IP 黑名单 + DNS resolve 后 +//! 校验防 rebinding + 重定向 ≤3 跳每跳重校验)。只发 GET,无 body。 +//! 2. **路径校验**:复用 `tool_registry::validate_path` + `resolve_workspace_path_with_allowed` +//! (URL 解码防 %2e%2e 遍历 + 词法层越界兜底 + canonicalize symlink 逃逸拦截 + 授权目录白名单)。 +//! 与 write_file 同款,output_path 必须落在授权目录内。 +//! 3. **大小上限**:默认 200MB(可调 const),流式边下边累计,超限立即中止 + 删半成品,防磁盘炸。 +//! 4. **原子写**:tmp → rename(同目录,不跨卷),写一半崩溃不留半成品文件(对齐 write_file FR-S7)。 +//! +//! ## 风险:High +//! +//! 下载文件有副作用(写磁盘 + 触发外发网络 + 可能拿到可执行/敏感内容),统一 High 须人工批准, +//! 与 write_file(Medium)区别在于:download 的来源是外部不可信网络,不可预知内容。 + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; +use tokio::io::AsyncWriteExt; + +use crate::commands::ai::http::{ + build_client, execute_with_redirects, resolve_and_check_host, validate_url, MAX_REDIRECTS, +}; +use crate::commands::ai::tool_registry::{ + DEFAULT_RUN_COMMAND_TIMEOUT_SECS, MAX_RUN_COMMAND_TIMEOUT_SECS, +}; + +/// 下载文件大小硬上限(字节)。200MB —— 防超大文件(ISO/镜像/视频)撑爆磁盘。 +/// 流式边下边累计,超限立即中止 + 删半成品。需更大文件走专门工具/分片,不在此。 +const MAX_DOWNLOAD_BYTES: u64 = 200 * 1024 * 1024; + +/// download_file 工具 handler 入口(供 tools/download_file.rs register 调用)。 +/// +/// 参数: +/// - url: 必填,http/https(SSRF 防护拒私网/localhost) +/// - output_path: 必填,落盘路径(必须落在授权目录内,复用 write_file 同款路径校验) +/// - timeout_secs: 可选,默认 DEFAULT_RUN_COMMAND_TIMEOUT_SECS(60),clamp ≤MAX(600) +/// +/// 返回 {path, bytes_written, status, url} +/// +/// 注:handler 不直接拿 allowed_dirs(tools/download_file.rs 闭包捕获并在调用前解析路径)。 +/// 本函数接收「已解析的绝对路径字符串」(resolved_path),路径校验在调用方完成(闭包持有 allowed_dirs, +/// 解析后传字符串进来,与 write_file 模式一致 —— resolve_workspace_path_with_allowed 需 allowed_dirs +/// 快照,故解析留在闭包内,本函数专注网络 + 落盘)。 +pub(crate) async fn execute_download_file( + args: Value, + resolved_output_path: String, +) -> anyhow::Result { + // ── 参数解析 ── + let url_raw = args.get("url").and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("缺少 url 参数"))? + .trim() + .to_string(); + if url_raw.is_empty() { + anyhow::bail!("url 不能为空"); + } + if resolved_output_path.trim().is_empty() { + anyhow::bail!("output_path 不能为空"); + } + let timeout_secs = args.get("timeout_secs").and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS) + .clamp(1, MAX_RUN_COMMAND_TIMEOUT_SECS); + + // ── SSRF 校验(三层:词法 URL → DNS resolve IP → 每跳重定向重复) ── + let (_scheme, host, port) = validate_url(&url_raw)?; + resolve_and_check_host(&host, port).await?; + + // ── 构建限制 client + 执行 GET(手动重定向循环复用 http.rs) ── + let client = build_client(Duration::from_secs(timeout_secs))?; + let started = Instant::now(); + let resp = execute_with_redirects( + &client, + reqwest::Method::GET, + url_raw.clone(), + &HashMap::new(), // download_file 不带自定义头(纯 GET 文件) + &None, // 无 body + MAX_REDIRECTS, + ) + .await?; + + let status = resp.status().as_u16(); + let final_url = resp.url().to_string(); + if !resp.status().is_success() { + anyhow::bail!( + "下载失败:HTTP {} {}({})", + status, + resp.status().canonical_reason().unwrap_or(""), + final_url + ); + } + + // ── 流式写文件(避免大文件 OOM) ── + // response.bytes_stream() 需 reqwest "stream" feature(src-tauri/Cargo.toml 已声明)。 + // 边下边写 + 累计字节,超 MAX_DOWNLOAD_BYTES 立即中止 + 删半成品。 + let target = std::path::Path::new(&resolved_output_path); + + // 父目录必须存在且在授权目录内(防 path=授权根时 parent 越界 create_dir_all)。 + // 路径白名单校验已在调用方(resolve_workspace_path_with_allowed)完成,这里只补 parent 授权 + + // create_dir_all(对齐 write_file FR-S8)。parent 授权复检由调用方解析时已覆盖(resolved 已过白名单), + // create_dir_all 仅针对尚不存在的 parent 目录创建。 + if let Some(parent) = target.parent() { + tokio::fs::create_dir_all(parent).await + .map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?; + } + + // 原子写:tmp → rename(同目录,不跨卷)。写一半崩溃/超限中止均删 tmp,不留半成品。 + let tmp = format!("{}.tmp-download", resolved_output_path); + let mut file = tokio::fs::File::create(&tmp).await + .map_err(|e| anyhow::anyhow!("创建临时文件失败 ({}): {}", tmp, e))?; + + let mut stream = resp.bytes_stream(); + let mut written: u64 = 0; + let mut exceeded = false; + use futures::StreamExt; + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| anyhow::anyhow!("读取响应流失败: {}", e))?; + written += chunk.len() as u64; + if written > MAX_DOWNLOAD_BYTES { + exceeded = true; + break; + } + file.write_all(&chunk).await + .map_err(|e| anyhow::anyhow!("写入文件失败: {}", e))?; + } + // flush + 关闭(file drop 前 flush,确保数据落盘再 rename) + file.flush().await + .map_err(|e| anyhow::anyhow!("flush 文件失败: {}", e))?; + drop(file); + + if exceeded { + let _ = tokio::fs::remove_file(&tmp).await; // 删半成品 + anyhow::bail!( + "下载超过 {} 字节上限,已中止并清理临时文件", + MAX_DOWNLOAD_BYTES + ); + } + + // tmp → 目标(原子替换,同目录不跨卷)。覆盖既有文件时旧文件被替换。 + if let Err(e) = tokio::fs::rename(&tmp, &resolved_output_path).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(anyhow::anyhow!("原子替换失败: {}", e)); + } + + let elapsed_ms = started.elapsed().as_millis() as u64; + + Ok(json!({ + "path": resolved_output_path, + "bytes_written": written, + "status": status, + "url": final_url, + "elapsed_ms": elapsed_ms, + })) +} + +// ============================================================ +// 单元测试 +// +// 覆盖: +// ① SSRF 拦截私网(validate_url 拒 127.0.0.1/10.x/169.254):走 handler 入口早期拒绝, +// 断言错误信息含关键词(对齐 http.rs / fetch_url.rs 测试策略)。 +// ② 路径越界拒:validate_path 纯函数,断言 .. / 敏感目录被拒。 +// 真实下载集成走 #[ignore](CI 无网时跳过,本地手跑)。 +// ============================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::ai::tool_registry::validate_path; + + // ── validate_path 纯函数:路径越界 / 敏感目录拒(可单测,零依赖) ── + + #[test] + fn test_validate_path_rejects_traversal() { + // .. 段被拒(分段归一化,不误伤 my..file) + assert!(validate_path("../etc/passwd").is_err()); + assert!(validate_path("foo/../../bar").is_err()); + assert!(validate_path("a/..").is_err()); + // URL 编码绕过被解码后拦截(%2e%2e = ..) + assert!(validate_path("%2e%2e/secret").is_err()); + assert!(validate_path("%2e%2e%2fetc").is_err()); + } + + #[test] + fn test_validate_path_accepts_normal_relative() { + // 正常相对路径通过(my..file 不误伤) + assert!(validate_path("downloads/file.zip").is_ok()); + assert!(validate_path("my..file.txt").is_ok()); + assert!(validate_path("a/b/c").is_ok()); + } + + #[test] + fn test_validate_path_rejects_sensitive_system_dirs() { + // .ssh / .aws / .gnupg 等敏感目录被拒(走 is_in_system_blacklist) + assert!(validate_path("/root/.ssh/id_rsa").is_err()); + assert!(validate_path("C:\\Users\\x\\.aws\\credentials").is_err()); + } + + // ── handler 参数边界 ── + + #[tokio::test] + async fn test_handler_missing_url_errors() { + let args = json!({ "output_path": "/tmp/x" }); + let err = execute_download_file(args, "/tmp/x".to_string()).await.unwrap_err(); + assert!(format!("{}", err).contains("缺少 url")); + } + + #[tokio::test] + async fn test_handler_empty_url_errors() { + let args = json!({ "url": " ", "output_path": "/tmp/x" }); + let err = execute_download_file(args, "/tmp/x".to_string()).await.unwrap_err(); + assert!(format!("{}", err).contains("url 不能为空")); + } + + #[tokio::test] + async fn test_handler_empty_output_path_errors() { + let args = json!({ "url": "https://example.com/", "output_path": " " }); + let err = execute_download_file(args, " ".to_string()).await.unwrap_err(); + assert!(format!("{}", err).contains("output_path 不能为空")); + } + + // ── SSRF 拦截:handler 入口 validate_url 拒私网/localhost/非 http 协议(不发请求) ── + + #[tokio::test] + async fn test_handler_rejects_localhost() { + let args = json!({ "url": "http://localhost:8080/secret", "output_path": "/tmp/x" }); + let err = execute_download_file(args, "/tmp/x".to_string()).await.unwrap_err(); + assert!(format!("{}", err).contains("localhost") || format!("{}", err).contains("SSRF")); + } + + #[tokio::test] + async fn test_handler_rejects_private_ip_loopback() { + // 127.0.0.1 环回 + let args = json!({ "url": "http://127.0.0.1/admin", "output_path": "/tmp/x" }); + let err = execute_download_file(args, "/tmp/x".to_string()).await.unwrap_err(); + assert!(format!("{}", err).contains("私网") || format!("{}", err).contains("SSRF")); + } + + #[tokio::test] + async fn test_handler_rejects_private_ip_rfc1918_10() { + // 10.x RFC1918 A 类私网 + let args = json!({ "url": "http://10.0.0.1/", "output_path": "/tmp/x" }); + let err = execute_download_file(args, "/tmp/x".to_string()).await.unwrap_err(); + assert!(format!("{}", err).contains("私网") || format!("{}", err).contains("SSRF")); + } + + #[tokio::test] + async fn test_handler_rejects_metadata_endpoint() { + // 169.254.169.254 云元数据服务(SSRF 头号目标) + let args = json!({ "url": "http://169.254.169.254/latest/meta-data/", "output_path": "/tmp/x" }); + let err = execute_download_file(args, "/tmp/x".to_string()).await.unwrap_err(); + assert!(format!("{}", err).contains("私网") || format!("{}", err).contains("SSRF")); + } + + #[tokio::test] + async fn test_handler_rejects_non_http_scheme() { + // file/ftp/data 等非 http 协议被拒 + let args = json!({ "url": "file:///etc/passwd", "output_path": "/tmp/x" }); + let err = execute_download_file(args, "/tmp/x".to_string()).await.unwrap_err(); + assert!(format!("{}", err).contains("协议")); + } + + #[tokio::test] + async fn test_handler_timeout_clamped_no_panic() { + // timeout_secs 超大值应 clamp 到 MAX,不 panic(用 localhost 触发 SSRF 早期拒绝, + // 验证 clamp 在拒绝前不 panic) + let args = json!({ "url": "http://localhost/", "output_path": "/tmp/x", "timeout_secs": 999999 }); + let _ = execute_download_file(args, "/tmp/x".to_string()).await; // 不 panic 即通过 + } + + // ── 真实下载集成(#[ignore]:CI 无网时跳过,本地手跑) ── + + #[tokio::test] + #[ignore = "需真实网络 + 临时目录,CI 无网时跳过:cargo test -- --ignored"] + async fn integration_download_example_com() { + // 下载 example.com 首页到临时文件,验证 happy path。 + let tmp_dir = std::env::temp_dir().join("devflow-download-test"); + tokio::fs::create_dir_all(&tmp_dir).await.ok(); + let out = tmp_dir.join("example.html"); + let out_str = out.to_string_lossy().to_string(); + let args = json!({ "url": "https://example.com/", "output_path": out_str }); + let result = execute_download_file(args, out_str.clone()).await.expect("下载应成功"); + assert_eq!(result["status"].as_u64().unwrap(), 200); + assert!(result["bytes_written"].as_u64().unwrap() > 0); + assert!(result["url"].as_str().unwrap().contains("example.com")); + // 文件确实落盘 + let meta = tokio::fs::metadata(&out_str).await.expect("文件应存在"); + assert!(meta.len() > 0); + // 清理 + let _ = tokio::fs::remove_file(&out_str).await; + let _ = tokio::fs::remove_dir(&tmp_dir).await; + } +} diff --git a/src-tauri/src/commands/ai/fetch_search.rs b/src-tauri/src/commands/ai/fetch_search.rs new file mode 100644 index 0000000..d294eec --- /dev/null +++ b/src-tauri/src/commands/ai/fetch_search.rs @@ -0,0 +1,781 @@ +//! fetch_search AI 工具 — 搜索引擎查询(DuckDuckGo HTML 免 key,GET → 解析结果列表) +//! +//! 设计目标:让 LLM 拿到「搜索」能力。用户给"调研 X"时,AI 需先搜索找文档入口, +//! 再 fetch_url 抓。当前无搜索工具,LLM 只能瞎猜 URL。本工具 GET DuckDuckGo HTML 端点 +//! (免 API key),解析结果列表(标题+URL+摘要),让 AI 据此挑入口用 fetch_url 深读。 +//! +//! ## 与 fetch_url / http_request 的分工 +//! +//! - `http_request`:结构化 API 调用(POST/鉴权/JSON),拿原始响应 body。 +//! - `fetch_url`:**已知 URL** → markdown 文档嗅探,LLM 拿到正文。 +//! - `fetch_search`:**未知 URL** → 搜索引擎查询,LLM 拿到候选 URL 列表 + 摘要, +//! 再据结果挑入口用 fetch_url 深读。补「搜索」缺口。 +//! +//! ## HTML 解析方式:手写字符串扫描(非 scraper/html5ever) +//! +//! DDG HTML 端点结构稳定且单一来源,引入 scraper(html5ever + selectors + cssparser, +//! 重依赖 ~5MB 编译产物)不值。对齐 fetch_url::extract_title 的简单字符串扫描风格: +//! 在 HTML 中找 `class="result__a"` 锚点 → 取 href + 文本;找 `result__snippet` → 取摘要。 +//! 解析纯函数化(extract_results),零网络依赖,单测用 fixture HTML 字符串验证。 +//! +//! ## DDG 重定向 URL 解包 +//! +//! DDG 把结果 URL 包成 `https://duckduckgo.com/l/?uddg=&rut=...` +//! 重定向跳板(统计/反爬)。本工具解析 `uddg=` 查询参数,percent-decode 还原真实 URL, +//! LLM 拿到的就是可直接 fetch_url 的目标地址。解析失败(无 uddg 参数 / 非 DDG 跳板 URL) +//! 保留原 href 兜底(可能是 //duckduckgo.com/... 内部页,LLM 至少能看到)。 +//! +//! ## 安全(SSRF 防护 — 与 http_request / fetch_url 共享同一套) +//! +//! 复用 `http.rs` 的 `validate_url` / `resolve_and_check_host` / `build_client` / +//! `execute_with_redirects`:① 协议白名单(仅 http/https)② 私网 IP 黑名单 ③ DNS resolve +//! 后校验 IP(防 rebinding)④ 重定向 ≤3 跳每跳重校验。fetch_search 只发 GET 到固定 DDG 端点。 +//! +//! ## 反爬评估 +//! +//! DDG HTML 端点(html.duckduckgo.com/html/)对带浏览器 UA 的请求通常友好(无 key/无 token)。 +//! 本工具默认带主流浏览器 UA(与 fetch_url 一致)。若反爬升级(403/空结果),不硬上 —— +//! handler 把 HTTP 状态/空结果如实返回,LLM 据此判断(不改用代理/绕过,避免对抗反爬)。 +//! 间歇限流:单次工具调用是单 GET,无高频请求,不内置 rate limit(并发由 LLM loop 控制, +//! 搜索是低频操作,不至触发频控)。 + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; + +/// DuckDuckGo HTML 搜索端点(免 API key,GET 返回结果列表 HTML)。 +const DDG_HTML_ENDPOINT: &str = "https://html.duckduckgo.com/html/"; + +/// 默认返回结果数。LLM 单次搜索 5 条足以挑入口(太多撑爆 context)。 +const DEFAULT_MAX_RESULTS: usize = 5; +/// 返回结果数下限。防 LLM 传极小值拿到无意义单条。 +const MIN_MAX_RESULTS: usize = 1; +/// 返回结果数上限。防 LLM 传极大值仍撑爆 context(10 条覆盖多数场景)。 +const MAX_MAX_RESULTS: usize = 10; + +/// 默认请求超时(秒)。与 http_request / fetch_url 一致。 +const DEFAULT_TIMEOUT_SECS: u64 = 30; +/// 超时硬上限(秒)。与 http_request / fetch_url 一致。 +const MAX_TIMEOUT_SECS: u64 = 60; + +/// 响应 body 字节上限。DDG HTML 结果页通常 <500KB,2MB 足够且防 OOM(对齐 fetch_url)。 +const MAX_HTML_BYTES: usize = 2 * 1024 * 1024; + +/// fetch_search 工具 handler 入口(供 tools/fetch_search.rs register 调用)。 +/// +/// 参数: +/// - query: 必填,搜索关键词 +/// - max_results: 可选,返回结果数(默认 5,clamp [1, 10]) +/// +/// 返回 {query, results:[{title, url, snippet}], count} +pub(crate) async fn execute_fetch_search(args: Value) -> anyhow::Result { + // ── 参数解析 ── + let query = args.get("query").and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("缺少 query 参数"))? + .trim() + .to_string(); + if query.is_empty() { + anyhow::bail!("query 不能为空"); + } + let max_results = (args.get("max_results").and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_MAX_RESULTS as u64) as usize) + .clamp(MIN_MAX_RESULTS, MAX_MAX_RESULTS); + let timeout_secs = args.get("timeout_secs").and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_TIMEOUT_SECS) + .min(MAX_TIMEOUT_SECS) + .max(1); + + // ── 构造请求 URL:percent-encode query 拼 DDG 端点 ?q= ── + // DDG HTML 端点接受 GET ?q=。encode 严格 RFC 3986(query 段)。 + let encoded_query = percent_encode_query(&query); + let search_url = format!("{}?q={}", DDG_HTML_ENDPOINT, encoded_query); + + // ── SSRF 校验(对固定 DDG 端点也走标准防护:协议/host/DNS resolve IP) ── + let (_scheme, host, port) = crate::commands::ai::http::validate_url(&search_url)?; + crate::commands::ai::http::resolve_and_check_host(&host, port).await?; + + // ── 构建限制 client + 执行 GET(复用 http.rs 重定向循环,带浏览器 UA) ── + // DDG HTML 端点对非浏览器 UA 可能返回简化页/拒,带主流 UA 拿完整结果列表(对齐 fetch_url)。 + let mut headers: HashMap = HashMap::new(); + headers.insert( + "User-Agent".to_string(), + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36".to_string(), + ); + // Accept-Language:让 DDG 返回匹配用户语言偏好的结果(中英文兼顾)。 + headers.insert( + "Accept-Language".to_string(), + "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7".to_string(), + ); + + let client = crate::commands::ai::http::build_client(Duration::from_secs(timeout_secs))?; + let started = Instant::now(); + let resp = crate::commands::ai::http::execute_with_redirects( + &client, + reqwest::Method::GET, + search_url.clone(), + &headers, + &None, + crate::commands::ai::http::MAX_REDIRECTS, + ) + .await?; + let elapsed_ms = started.elapsed().as_millis() as u64; + + let final_url = resp.url().to_string(); + let status = resp.status().as_u16(); + if !resp.status().is_success() { + anyhow::bail!( + "搜索失败:HTTP {} {}({})。DDG 可能限流/反爬,稍后重试或换关键词", + status, + resp.status().canonical_reason().unwrap_or(""), + final_url + ); + } + + // ── 读取 body + 大小挡板 ── + let bytes = resp.bytes().await + .map_err(|e| anyhow::anyhow!("读取响应 body 失败: {}", e))?; + let total_bytes = bytes.len(); + if total_bytes > MAX_HTML_BYTES { + anyhow::bail!( + "响应过大:{} 字节超过 {} 上限,fetch_search 拿到异常大响应", + total_bytes, MAX_HTML_BYTES + ); + } + let html = String::from_utf8_lossy(&bytes).into_owned(); + + // ── 解析结果列表(纯函数,单测用 fixture HTML 验证) ── + let mut results = extract_results(&html); + // 截断到 max_results(解析可能拿到更多,DDG 单页通常 ~30 条) + results.truncate(max_results); + + let count = results.len(); + tracing::info!( + "fetch_search: query={:?} 拿到 {} 条结果(HTML {} 字节,{}ms)", + query, count, total_bytes, elapsed_ms + ); + + Ok(json!({ + "query": query, + "results": results, + "count": count, + "status": status, + "elapsed_ms": elapsed_ms, + })) +} + +/// 从 DDG HTML 结果页解析结果列表(标题+URL+摘要)。 +/// +/// DDG HTML 每条结果结构(简化): +/// ```text +///
+///

+/// Title +///

+/// Snippet text... +///
+/// ``` +/// +/// 解析策略:扫描所有 `class="result__a"` 锚点 → 取 href + 内联文本;同序找 +/// `class="result__snippet"` → 取摘要。两者各扫一遍再按出现顺序对齐(DDG 标题与摘要 +/// 在每条结果内同序出现,故按位置一一对应;若数量不等则按较少者对齐)。 +/// +/// URL 解包:DDG 把真实 URL 包在 `uddg=` 查询参数(跳板重定向), +/// 此处还原(percent-decode)让 LLM 拿到可直 fetch_url 的目标 URL。 +fn extract_results(html: &str) -> Vec { + let titles_with_url: Vec<(String, String)> = extract_result_anchors(html); + let snippets: Vec = extract_snippets(html); + + let n = titles_with_url.len().max(snippets.len()); + let mut results: Vec = Vec::with_capacity(n); + for i in 0..n { + let title_url = titles_with_url.get(i); + let snippet = snippets.get(i); + match (title_url, snippet) { + (Some((title, href)), snip) => { + let url = unwrap_ddg_redirect(href); + results.push(json!({ + "title": title, + "url": url, + "snippet": snip.cloned().unwrap_or_default(), + })); + } + (None, Some(snip)) => { + // 残留 snippet 无对应标题(罕见,DDG 结构异常时兜底):跳过,无标题无 URL 无意义。 + // 不构造半残条目,保持每条都有 title+url(让 LLM 能 fetch_url)。 + } + (None, None) => break, + } + } + results +} + +/// 提取所有 `class="result__a"` 锚点的 (内联文本, href),保持文档顺序。 +/// +/// 用字符串扫描(对齐 fetch_url::extract_title 风格),不引 scraper 重依赖。 +/// class 属性顺序不固定(DDG 偶有 `` 或 +/// ``),扫 `class="result__a"` 子串命中锚点起点,再从该点向后 +/// 找最近的 `` 起标签尾(`>`),取 href 值 + 标签内文本(到 ``)。 +fn extract_result_anchors(html: &str) -> Vec<(String, String)> { + // MARKER 用 `result__a`(裸 class 名),命中后检查后续字符做边界判定: + // 必须后跟非标识符字符(" 或空格,即 class 值结束或多个 class 间的分隔),避免 `result__a` + // 子串误命中 `result__article` / `result__a_extra` 等同类 class(DDG 有 result__* 系列)。 + const MARKER: &str = "result__a"; + let marker_end_byte = MARKER.len(); + let mut out: Vec<(String, String)> = Vec::new(); + let mut search_from = 0usize; + while search_from < html.len() { + // 找下一个 "result__a" 出现位置(锚点 class 标记) + let Some(rel_class) = html[search_from..].find(MARKER) else { break }; + let class_pos = search_from + rel_class; + let after_marker = class_pos + marker_end_byte; + // 边界检查:MARKER 后的字符必须是非标识符字符(" 或空格 或 >,即 class 值结束), + // 否则可能是 `result__article` 这类以 `result__a` 为前缀的 class,跳过。 + let next_char = html[after_marker..].chars().next(); + let is_boundary = match next_char { + Some(c) => !(c.is_ascii_alphanumeric() || c == '_' || c == '-'), + None => true, // 字符串尾(罕见,视为边界) + }; + if !is_boundary { + // 非边界(如 result__article):跳过本命中继续找 + search_from = after_marker; + continue; + } + + // 从 class 位置向前找最近的 ` 标签内) + // 倒扫到 class_pos 之前的最后一个 `` + let a_open_end_rel = html[a_open_rel..].find('>'); + let Some(a_open_end_offset) = a_open_end_rel else { + search_from = after_marker; + continue; + }; + let a_open_end = a_open_rel + a_open_end_offset; // `>` 位置 + let a_open_tag = &html[a_open_rel..=a_open_end]; // 含 `` + + // 提取 href 值(href="..." 或 href='...') + let href = extract_attr(a_open_tag, "href").unwrap_or_default(); + + // 取标签内文本(从 `>` 后到 ``) + let text_start = a_open_end + 1; + let text_end = html[text_start..] + .find("") + .map(|p| text_start + p) + .unwrap_or(html.len()); + let raw_text = &html[text_start..text_end]; + let title = clean_html_text(raw_text); + + if !title.is_empty() { + out.push((title, href.to_string())); + } + + // 推进搜索位置到本锚点结束之后,避免重复命中同一锚点的 class + search_from = text_end + "".len(); + } + out +} + +/// 提取所有 `class="result__snippet"` 元素的内联文本,保持文档顺序。 +/// +/// DDG snippet 用 `text`(同时也是链接)。 +/// 扫 `result__snippet` 子串 → 向前找 `` → 取到 `` 间文本。 +/// 与 extract_result_anchors 同款逻辑,仅 marker 不同。 +fn extract_snippets(html: &str) -> Vec { + const MARKER: &str = "result__snippet"; + let mut out: Vec = Vec::new(); + let mut search_from = 0usize; + while search_from < html.len() { + let Some(rel) = html[search_from..].find(MARKER) else { break }; + let class_pos = search_from + rel; + + let head = &html[..class_pos]; + let Some(a_open_rel) = head.rfind("') { + Some(o) => o, + None => { + search_from = class_pos + MARKER.len(); + continue; + } + }; + let a_open_end = a_open_rel + a_open_end_offset; + + let text_start = a_open_end + 1; + let text_end = html[text_start..] + .find("") + .map(|p| text_start + p) + .unwrap_or(html.len()); + let raw_text = &html[text_start..text_end]; + let snippet = clean_html_text(raw_text); + if !snippet.is_empty() { + out.push(snippet); + } + search_from = text_end + "".len(); + } + out +} + +/// 从 `` 等开标签中提取指定属性值。 +/// +/// 支持 `attr="..."` / `attr='...'`(单双引号),不支持无引号属性值(DDG href 总是带引号)。 +/// 值内的 HTML entity 不解码(简单场景 DDG href 不含 entity;若需可后续加 decode_html_entities)。 +/// +/// 已知局限:不做属性单词边界检查,故 `data-href="..."` 会被 `attr="href"` 匹配命中 +/// (因 "href=" 是 "data-href=" 的子串)。真实 DDG `` 不同时带 data-href 与 href, +/// 实务无影响;若未来需精确边界,改用前一个字符非字母数字的边界检查。 +fn extract_attr(open_tag: &str, attr: &str) -> Option { + let lower = open_tag.to_lowercase(); + let dq_pattern = format!("{}=\"", attr); + let sq_pattern = format!("{}='", attr); + // 优先双引号形式,其次单引号。matched_quote 记录命中模式对应的引号字符, + // 避免 backtrack 推断(原 starts_with('"') 路径在 attr 出现在开标签首时 saturating_sub 边界不稳)。 + let (value_start_rel, quote_char): (usize, char) = lower + .find(&dq_pattern) + .map(|p| (p + dq_pattern.len(), '"')) + .or_else(|| lower.find(&sq_pattern).map(|p| (p + sq_pattern.len(), '\'')))?; + let value_end_rel = open_tag[value_start_rel..].find(quote_char)?; + Some(open_tag[value_start_rel..value_start_rel + value_end_rel].to_string()) +} + +/// 清理 HTML 内联文本:剥离嵌套标签(如 `highlight`)+ 折叠空白。 +/// +/// DDG 标题/snippet 内常有 `` 高亮匹配关键词,需剥离得到纯文本。 +/// 不解码 entity(& 等),简单场景够用;后续如见 entity 可扩展。 +fn clean_html_text(raw: &str) -> String { + // 剥离所有 `<...>` 子串(嵌套标签如 word → word) + let mut stripped = String::with_capacity(raw.len()); + let mut in_tag = false; + for c in raw.chars() { + match c { + '<' => in_tag = true, + '>' => in_tag = false, + _ if !in_tag => stripped.push(c), + _ => {} + } + } + // 折叠连续空白(含 \n\t)为单空格,trim 首尾 + let cleaned: String = stripped.split_whitespace().collect::>().join(" "); + cleaned +} + +/// 解包 DDG 重定向跳板 URL 还原真实目标 URL。 +/// +/// DDG 把结果 URL 包成 `https://duckduckgo.com/l/?uddg=&rut=...`。 +/// 提取 `uddg` 查询参数值并 percent-decode → 真实 URL(可直接 fetch_url)。 +/// +/// 非 DDG 跳板 URL(无 `uddg=` 参数 / 解码失败)→ 原样返回 href 兜底。 +/// DDG 偶尔返回 `//duckduckgo.com/...` 协议相对内部页(无 uddg),保留原样让 LLM 至少看到。 +fn unwrap_ddg_redirect(href: &str) -> String { + // 在 href 中找 `uddg=` 参数(可能在 ? 或 & 后) + let Some(uddg_pos) = href.find("uddg=") else { + return href.to_string(); + }; + let value_start = uddg_pos + "uddg=".len(); + let rest = &href[value_start..]; + // 取到下一个 `&` 或字符串尾 + let value_end = rest.find('&').unwrap_or(rest.len()); + let encoded = &rest[..value_end]; + // percent-decode(query 段,将 + 转空格 DDG 不用,uddg 值内真实 URL 的空格已 %20 编码) + use percent_encoding::percent_decode_str; + let decoded = percent_decode_str(encoded).decode_utf8_lossy().into_owned(); + if decoded.is_empty() { + href.to_string() + } else { + decoded + } +} + +/// percent-encode 搜索关键词(query 段 RFC 3986)。 +/// +/// 不引 urlencoding crate(http.rs 测试用同款手写),覆盖 query 段需转义字符: +/// 空格 → %20、&=?# 等保留字符转义。字母/数字/常见安全符号(-._~)保留。 +fn percent_encode_query(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for &b in s.as_bytes() { + // RFC 3986 unreserved + 几个 query-safe 字符保留 + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' + | b'-' | b'.' | b'_' | b'~' => out.push(b as char), + _ => out.push_str(&format!("%{:02X}", b)), + } + } + out +} + +// ============================================================ +// 单元测试 +// +// 覆盖纯函数(extract_results / extract_result_anchors / extract_snippets / +// extract_attr / clean_html_text / unwrap_ddg_redirect / percent_encode_query), +// 确定性零网络。handler 集成层走真实网络 #[ignore](对齐 http.rs / fetch_url 测试策略)。 +// +// fixture HTML:基于 DDG HTML 端点真实结构简化(保留 result__a / result__snippet / +// uddg 重定向跳板等关键标记),让解析逻辑可零网络验证。 +// ============================================================ + +#[cfg(test)] +mod tests { + use super::*; + + /// 真实 DDG HTML 结构简化 fixture(含 2 条结果,标题/snippet/uddg 重定向齐全)。 + /// 用于 extract_results 端到端解析验证(零网络)。 + const FIXTURE_DDG_HTML: &str = r#" + +"#; + + // ── extract_results:端到端解析(标题 + uddg 解包 URL + 摘要) ── + + #[test] + fn test_extract_results_end_to_end_two_results() { + let results = extract_results(FIXTURE_DDG_HTML); + assert_eq!(results.len(), 2, "应解析 2 条结果"); + + // 第 1 条 + let r0 = &results[0]; + assert_eq!(r0["title"].as_str().unwrap(), "The Rust Programming Language book"); + // uddg 解包后应是真实 URL,非 DDG 跳板 + assert_eq!(r0["url"].as_str().unwrap(), "https://doc.rust-lang.org/book/"); + assert!(r0["snippet"].as_str().unwrap().contains("introductory")); + } + + #[test] + fn test_extract_results_second_result() { + let results = extract_results(FIXTURE_DDG_HTML); + let r1 = &results[1]; + assert_eq!(r1["title"].as_str().unwrap(), "Tokio Tutorial - Asynchronous Rust"); + assert_eq!(r1["url"].as_str().unwrap(), "https://tokio.rs/tutorial/"); + assert!(r1["snippet"].as_str().unwrap().contains("Tokio runtime")); + } + + #[test] + fn test_extract_results_strips_b_tags_in_title() { + // DDG 标题内常有 高亮匹配关键词,应剥离为纯文本 + let html = r#"Hello World"#; + let anchors = extract_result_anchors(html); + assert_eq!(anchors.len(), 1); + assert_eq!(anchors[0].0, "Hello World"); + } + + // ── 截断 ── + + #[test] + fn test_execute_search_truncates_results() { + // 不发网络:仅验证 extract_results + truncate 的截断逻辑(纯函数路径) + // 构造 3 条 fixture,模拟 max_results=2 截断 + let html = r#" +A +snip A +B +snip B +C +snip C +"#; + let mut results = extract_results(html); + results.truncate(2); + assert_eq!(results.len(), 2, "应截断到 2 条"); + assert_eq!(results[0]["title"].as_str().unwrap(), "A"); + assert_eq!(results[1]["title"].as_str().unwrap(), "B"); + } + + // ── extract_attr:开标签属性提取 ── + + #[test] + fn test_extract_attr_double_quote() { + let tag = r#""#; + assert_eq!(extract_attr(tag, "href").as_deref(), Some("https://example.com/")); + } + + #[test] + fn test_extract_attr_single_quote() { + let tag = r#""#; + assert_eq!(extract_attr(tag, "href").as_deref(), Some("https://example.com/")); + } + + #[test] + fn test_extract_attr_missing_returns_none() { + let tag = r#""#; + assert_eq!(extract_attr(tag, "href"), None); + } + + #[test] + fn test_extract_attr_known_limitation_data_href() { + // 已知局限记录:extract_attr 不做单词边界检查,"data-href=" 含 "href=" 子串会被命中。 + // 真实 DDG `` 不同时带 data-href 与 href,实务无影响。本测试锁定当前行为供回归守护: + // 若未来加边界检查改为不命中,此测试会红,提醒同步更新文档注释。 + let tag = r#""#; + assert_eq!( + extract_attr(tag, "href").as_deref(), + Some("https://hidden.com/"), + "已知局限:data-href 含 href= 子串会被命中(无单词边界检查)" + ); + } + + // ── clean_html_text:剥离嵌套标签 + 折叠空白 ── + + #[test] + fn test_clean_html_text_strips_tags() { + assert_eq!(clean_html_text("Hello World!"), "Hello World!"); + } + + #[test] + fn test_clean_html_text_collapses_whitespace() { + assert_eq!(clean_html_text(" multiple\n spaces\t here "), "multiple spaces here"); + } + + #[test] + fn test_clean_html_text_empty_after_strip() { + assert_eq!(clean_html_text(""), ""); + assert_eq!(clean_html_text(" "), ""); + } + + #[test] + fn test_clean_html_text_no_tags_passthrough() { + assert_eq!(clean_html_text("plain text"), "plain text"); + } + + // ── unwrap_ddg_redirect:uddg 参数解包 ── + + #[test] + fn test_unwrap_ddg_redirect_decodes_uddg_param() { + let href = "https://duckduckgo.com/l/?uddg=https%3A%2F%2Fdoc.rust-lang.org%2F&rut=abc"; + assert_eq!( + unwrap_ddg_redirect(href), + "https://doc.rust-lang.org/" + ); + } + + #[test] + fn test_unwrap_ddg_redirect_no_uddg_returns_original() { + // 非 DDG 跳板(无 uddg 参数)→ 原样返回 + let href = "https://example.com/direct"; + assert_eq!(unwrap_ddg_redirect(href), "https://example.com/direct"); + } + + #[test] + fn test_unwrap_ddg_redirect_uddg_last_param_no_amp() { + // uddg 是最后一个参数(无尾随 &)→ 取到字符串尾 + let href = "https://duckduckgo.com/l/?uddg=https%3A%2F%2Ftokio.rs"; + assert_eq!(unwrap_ddg_redirect(href), "https://tokio.rs"); + } + + #[test] + fn test_unwrap_ddg_redirect_chinese_url_decoded() { + // 中文 URL 经 percent-encode,decode 后应还原 + let href = "https://duckduckgo.com/l/?uddg=https%3A%2F%2Fzh.wikipedia.org%2Fwiki%2FRust&x=1"; + assert_eq!( + unwrap_ddg_redirect(href), + "https://zh.wikipedia.org/wiki/Rust" + ); + } + + #[test] + fn test_unwrap_ddg_redirect_empty_encoded_returns_original() { + // uddg= 空 → 解码空 → 返回原 href 兜底 + let href = "https://duckduckgo.com/l/?uddg=&rut=x"; + assert_eq!(unwrap_ddg_redirect(href), href); + } + + // ── percent_encode_query:搜索关键词编码 ── + + #[test] + fn test_percent_encode_query_space() { + assert_eq!(percent_encode_query("rust async"), "rust%20async"); + } + + #[test] + fn test_percent_encode_query_chinese() { + // 中文每字节转 %XX + let encoded = percent_encode_query("Rust 语言"); + // "Rust " 保留 + "语"(3 字节 UTF-8)+ "言"(3 字节) + assert!(encoded.starts_with("Rust%20")); + assert!(encoded.contains("%E8%AF%AD")); // "语" 的 UTF-8 首三字节之一 + } + + #[test] + fn test_percent_encode_query_safe_chars_preserved() { + // unreserved 字母数字 -._~ 保留 + assert_eq!(percent_encode_query("abc-123_test.~"), "abc-123_test.~"); + } + + #[test] + fn test_percent_encode_query_special_chars_escaped() { + // 保留字符 & = ? # 转义(防注入 query 解析) + let encoded = percent_encode_query("a&b=c?d"); + assert_eq!(encoded, "a%26b%3Dc%3Fd"); + } + + // ── extract_result_anchors:多结果顺序 + 跳过空标题 ── + + #[test] + fn test_extract_result_anchors_preserves_order() { + let html = r#" +First +Second +Third +"#; + let anchors = extract_result_anchors(html); + assert_eq!(anchors.len(), 3); + assert_eq!(anchors[0].0, "First"); + assert_eq!(anchors[1].0, "Second"); + assert_eq!(anchors[2].0, "Third"); + } + + #[test] + fn test_extract_result_anchors_skips_empty_title() { + // 空标题(标签内仅空白)跳过,不构造半残条目 + let html = r#" + +Valid +"#; + let anchors = extract_result_anchors(html); + assert_eq!(anchors.len(), 1, "空标题应跳过"); + assert_eq!(anchors[0].0, "Valid"); + } + + #[test] + fn test_extract_result_anchors_handles_rel_nofollow_first() { + // DDG 真实顺序:``(rel 在 class 前) + let html = r#"Title"#; + let anchors = extract_result_anchors(html); + assert_eq!(anchors.len(), 1); + assert_eq!(anchors[0].1, "https://x.com/"); + } + + #[test] + fn test_extract_result_anchors_no_results_returns_empty() { + // 无 result__a 标记 → 空结果(无 panic) + let html = "no results here"; + let anchors = extract_result_anchors(html); + assert!(anchors.is_empty()); + } + + #[test] + fn test_extract_result_anchors_boundary_check_rejects_result_article() { + // 边界检查:`result__article`(以 result__a 为前缀)不应被误命中为 result__a。 + // 防止 DDG 其他 result__* class(如 result__article)污染标题列表。 + let html = r#" +Should Not Match +Right Title +"#; + let anchors = extract_result_anchors(html); + assert_eq!(anchors.len(), 1, "result__article 不应被误命中"); + assert_eq!(anchors[0].0, "Right Title"); + assert_eq!(anchors[0].1, "https://duckduckgo.com/l/?uddg=https%3A%2F%2Fright.com%2F"); + } + + #[test] + fn test_extract_result_anchors_handles_multi_class_value() { + // class 列表含多个 class(`class="result__a result__url"`)仍应命中(result__a 后是空格) + let html = r#"Multi Class Title"#; + let anchors = extract_result_anchors(html); + assert_eq!(anchors.len(), 1); + assert_eq!(anchors[0].0, "Multi Class Title"); + } + + // ── extract_snippets ── + + #[test] + fn test_extract_snippets_basic() { + let html = r#" +First snippet +Second snippet +"#; + let snippets = extract_snippets(html); + assert_eq!(snippets.len(), 2); + assert_eq!(snippets[0], "First snippet"); + assert_eq!(snippets[1], "Second snippet"); + } + + #[test] + fn test_extract_snippets_strips_b_tags() { + let html = r#"Text with highlight word"#; + let snippets = extract_snippets(html); + assert_eq!(snippets.len(), 1); + assert_eq!(snippets[0], "Text with highlight word"); + } + + // ── extract_results:无 snippet 时空字符串 ── + + #[test] + fn test_extract_results_snippet_missing_defaults_empty() { + // 标题有但 snippet 缺(解析时 snippet.get(i) 返 None → 空字符串) + let html = r#" +Title Only +"#; + let results = extract_results(html); + assert_eq!(results.len(), 1); + assert_eq!(results[0]["title"].as_str().unwrap(), "Title Only"); + assert_eq!(results[0]["snippet"].as_str().unwrap(), "", "无 snippet 应默认空字符串"); + } + + // ── handler 参数边界(不发网络,触发早期拒绝) ── + + #[tokio::test] + async fn test_handler_missing_query_errors() { + let args = json!({ "max_results": 5 }); + let err = execute_fetch_search(args).await.unwrap_err(); + assert!(format!("{}", err).contains("缺少 query")); + } + + #[tokio::test] + async fn test_handler_empty_query_errors() { + let args = json!({ "query": " " }); + let err = execute_fetch_search(args).await.unwrap_err(); + assert!(format!("{}", err).contains("query 不能为空")); + } + + #[tokio::test] + async fn test_handler_max_results_clamped_low() { + // max_results=0 应 clamp 到 1(用空 query 触发早期拒绝,验证 clamp 不 panic) + let args = json!({ "query": " ", "max_results": 0 }); + let _ = execute_fetch_search(args).await; // 不 panic 即通过 + } + + #[tokio::test] + async fn test_handler_max_results_clamped_high() { + let args = json!({ "query": " ", "max_results": 999 }); + let _ = execute_fetch_search(args).await; // 不 panic 即通过 + } + + // ── 真实网络集成(#[ignore]:CI 无网时跳过,本地手跑) ── + + #[tokio::test] + #[ignore = "需真实网络(DDG),CI 无网时跳过:cargo test -- --ignored"] + async fn integration_search_rust_async_returns_results() { + let args = json!({ "query": "rust async programming", "max_results": 3 }); + let result = execute_fetch_search(args).await.expect("搜索应成功"); + let count = result["count"].as_u64().unwrap_or(0); + assert!(count >= 1, "至少应返回 1 条结果,实际 {}", count); + let results = result["results"].as_array().expect("results 应为数组"); + // 每条应有非空 title + url + for r in results { + assert!(!r["title"].as_str().unwrap_or("").is_empty(), "title 不应为空"); + let url = r["url"].as_str().unwrap_or(""); + assert!(!url.is_empty(), "url 不应为空"); + // uddg 应已解包,非 DDG 跳板 URL(除非 DDG 返回内部页) + // 不强制断言非 duckduckgo.com(偶尔 DDG 返回内部结果),但应能 fetch_url + } + // query 应回显 + assert_eq!(result["query"].as_str().unwrap(), "rust async programming"); + } +} diff --git a/src-tauri/src/commands/ai/generate_image.rs b/src-tauri/src/commands/ai/generate_image.rs new file mode 100644 index 0000000..878a23a --- /dev/null +++ b/src-tauri/src/commands/ai/generate_image.rs @@ -0,0 +1,587 @@ +//! generate_image AI 工具 — 调 provider 的 OpenAI 兼容图像生成端点生成图片并下载落地。 +//! +//! 设计目标:让 LLM 调用图像生成模型(默认 SenseNova U1 Fast,也支持 DALL-E 等 OpenAI 兼容 +//! 图像端点)生成图片并落到工作区磁盘。图像生成模型不是 chat 模型,走 `/v1/images/generations` +//! 端点(非 chat completions),故不能经主对话链路调用,必须用本工具显式触发。 +//! +//! ## 与 http_request / fetch_url / download_file 的分工 +//! +//! - `http_request`:结构化 API 调用,返回 body(截断 50KB),不落盘 —— 通用 HTTP,不感知图像端点。 +//! - `fetch_url`:网页 → markdown 文档嗅探,不落盘。 +//! - `download_file`:URL → 落盘文件(已知 URL)。 +//! - `generate_image`:**调 provider 图像端点 + 自动下载落地**(封装图像生成的 provider 凭证 +//! 解析 + 端点拼接 + 响应解析 + 图片下载全链路,LLM 只需给 prompt/model/output_path)。 +//! +//! ## 风险:High +//! +//! ① 付费 API(图像生成按张计费);② 写磁盘(落盘 output_path);③ 外发网络(provider 端点 +//! + 图片 URL 下载)。统一 High 须人工批准。 +//! +//! ## 安全 +//! +//! 1. **provider 凭证**:从 db.ai_providers 筛 `enabled && provider_type=="openai_compat"` 的 +//! provider,经 `df_storage::secret::resolve_provider_secret` 解析 key(DB 优先 fallback keyring)。 +//! anthropic provider 不参与(图像端点是 OpenAI 风格)。 +//! 2. **端点拼接**:`build_images_url` 智能 base_url(`/v1`/`/v4` 后缀直接补 `/images/generations`, +//! 否则补 `/v1/images/generations`),对齐 `model_fetch_helpers::build_models_url` 思路。 +//! 3. **provider 端点域名**:provider 已知域名(api.sensenova / api.openai / ...),非用户输入, +//! SSRF 风险低,故 provider POST 直接 reqwest::Client 不走 SSRF 防护。 +//! 4. **图片 URL 下载**:图片 URL 来自 provider 响应,**可能被恶意 provider 篡改指向内网** +//! (provider 域名虽可信但响应内容不可信),故下载图片 URL 复用 SSRF 防护 +//! (validate_url + resolve_and_check_host + build_client,与 download_file 同源)。 +//! 5. **路径校验**:output_path 走 `resolve_workspace_path_with_allowed`(白名单 + symlink 逃逸 +//! 拦截 + 路径遍历拒),与 write_file/download_file 同源;默认路径锚定首个持久授权目录。 +//! 6. **大小上限**:50MB(图片够用),流式边下边累计,超限中止 + 删半成品(对齐 download_file)。 + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use serde_json::{json, Value}; +use tokio::io::AsyncWriteExt; +use tokio::sync::RwLock; + +use df_storage::crud::AiProviderRepo; +use df_storage::db::Database; +use df_storage::models::AiProviderRecord; +use df_storage::secret::resolve_provider_secret; + +use crate::commands::ai::http::{ + build_client, execute_with_redirects, resolve_and_check_host, validate_url, MAX_REDIRECTS, +}; +use crate::commands::ai::tool_registry::resolve_workspace_path_with_allowed; +use crate::state::AllowedDirs; + +/// 下载图片大小硬上限(字节)。50MB —— 覆盖高分辨率图片(4K壁纸级 < 20MB),超限中止。 +/// 图片 URL 来自 provider 响应可能被篡改指向超大文件,流式边下边累计防磁盘炸。 +const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024; + +/// 生成图片 POST 请求超时(秒)。图像生成模型耗时较高(高分辨率 10-30s),给 120s 余量。 +const GENERATE_TIMEOUT_SECS: u64 = 120; + +/// generate_image 工具 handler 入口(供 tools/generate_image.rs register 调用)。 +/// +/// 参数: +/// - prompt: 必填,图片描述(中英文均可,空串报错) +/// - model: 默认 `sensenova-u1-fast`(SenseNova U1 信息图生成,走 /v1/images/generations) +/// - size: 可选,图片尺寸字符串(SenseNova U1 常用 `2752x1536`/`1536x2752`,DALL-E 常用 `1024x1024`) +/// - n: 默认 1,生成数量,clamp [1, 4](多张图取首张 url/b64 落地,余下仅返回 url 列表) +/// - provider_id: 可选,指定 provider(多 provider 时);不传则按 model 名匹配厂商 host +/// - output_path: 可选,落盘路径;不传则默认 `{首个持久授权目录}/generated_images/{model}-{时间戳}.png` +/// +/// 返回 {path, url(或 null 若仅 b64), model, provider_id, bytes_written, elapsed_ms} +/// +/// 注:handler 接收 db + allowed_dirs(经 declare_tool! 闭包捕获 tuple Arc 传入)。 +/// 路径校验在本函数内完成(需 allowed_dirs 快照,与 http_request output_file 同款)。 +pub(crate) async fn execute_generate_image( + args: Value, + db: &Arc, + allowed_dirs: &Arc>, +) -> anyhow::Result { + let started = Instant::now(); + + // ── 参数解析 ── + let prompt = args.get("prompt").and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("缺少 prompt 参数"))? + .trim() + .to_string(); + if prompt.is_empty() { + anyhow::bail!("prompt 不能为空"); + } + let model = args.get("model").and_then(|v| v.as_str()) + .unwrap_or("sensenova-u1-fast") + .trim() + .to_string(); + if model.is_empty() { + anyhow::bail!("model 不能为空"); + } + let size = args.get("size").and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let n = args.get("n").and_then(|v| v.as_u64()) + .unwrap_or(1) + .clamp(1, 4) as u32; + let provider_id = args.get("provider_id").and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let output_path_raw = args.get("output_path").and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + // ── 选 provider(enabled && openai_compat,按 model 名匹配厂商 host 兜底首个) ── + let providers = AiProviderRepo::new(db).list_all().await?; + let provider = select_image_provider(&providers, &model, provider_id.as_deref())?; + let provider_id_resolved = provider.id.clone(); + + // ── 解析 api_key(DB 优先 fallback keyring,空则报错) ── + let api_key = resolve_provider_secret(&provider); + let api_key = api_key.trim(); + if api_key.is_empty() { + anyhow::bail!( + "provider「{}」未配置 api_key,请在设置填入并保存", + provider.name + ); + } + + // ── 拼端点 URL ── + let endpoint = build_images_url(&provider.base_url); + + // ── POST 请求(provider 域名非用户输入,SSRF 风险低,直接 reqwest) ── + let body = { + let mut m = serde_json::Map::new(); + m.insert("model".into(), json!(model)); + m.insert("prompt".into(), json!(prompt)); + m.insert("n".into(), json!(n)); + if let Some(sz) = &size { + m.insert("size".into(), json!(sz)); + } + Value::Object(m) + }; + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(GENERATE_TIMEOUT_SECS)) + .connect_timeout(Duration::from_secs(15)) + .build() + .map_err(|e| anyhow::anyhow!("HTTP client 构建失败: {}", e))?; + let resp = client.post(&endpoint) + .header("Authorization", format!("Bearer {}", api_key)) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .map_err(|e| anyhow::anyhow!("调用图像生成端点失败 ({}): {}", endpoint, e))?; + let status = resp.status(); + if !status.is_success() { + // 错误响应读 body 摘要(截断 500 chars)帮助定位 + let err_text = resp.text().await.unwrap_or_default(); + let snippet: String = err_text.chars().take(500).collect(); + anyhow::bail!( + "图像生成端点返回 HTTP {}: {}", + status.as_u16(), + snippet + ); + } + let resp_body: Value = resp.json().await + .map_err(|e| anyhow::anyhow!("解析图像生成响应 JSON 失败: {}", e))?; + + // ── 解析响应:优先 data[0].url,次选 data[0].b64_json ── + let data0 = resp_body.get("data").and_then(|d| d.get(0)) + .ok_or_else(|| { + let snippet = body_snippet(&resp_body); + anyhow::anyhow!("图像生成响应缺 data[0](响应摘要: {})", snippet) + })?; + let image_url_opt = data0.get("url").and_then(|u| u.as_str()) + .filter(|s| !s.trim().is_empty()) + .map(|s| s.trim().to_string()); + let b64_opt = data0.get("b64_json").and_then(|b| b.as_str()) + .filter(|s| !s.trim().is_empty()) + .map(|s| s.trim().to_string()); + + if image_url_opt.is_none() && b64_opt.is_none() { + let snippet = body_snippet(&resp_body); + anyhow::bail!( + "图像生成响应 data[0] 既无 url 也无 b64_json(响应摘要: {})", + snippet + ); + } + + // ── 解析 output_path(LLM 传则走白名单校验;不传则默认 generated_images/ 时间戳名) ── + let snap = allowed_dirs.read().await.clone(); + let final_path = if let Some(raw) = &output_path_raw { + resolve_workspace_path_with_allowed(raw, &snap)? + .to_str() + .ok_or_else(|| anyhow::anyhow!("output_path 含非法字符"))? + .to_string() + } else { + // 默认:首个持久授权目录/generated_images/{model}-{YYYYMMDD-HHMMSS}.png + let root = snap.first_persistent_dir() + .ok_or_else(|| anyhow::anyhow!( + "未传 output_path 且无持久授权目录,请先绑定项目目录或显式传 output_path" + ))?; + let ts = chrono::Local::now().format("%Y%m%d-%H%M%S"); + // model 含 / 等路径分隔符风险:替换为 - 防穿透到子目录 + let safe_model = model.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "-"); + let fname = format!("{}-{}.png", safe_model, ts); + root.join("generated_images").join(fname) + .to_str() + .ok_or_else(|| anyhow::anyhow!("默认 output_path 含非法字符"))? + .to_string() + }; + + // ── 落盘:url 走 SSRF 防护下载流式写;b64 直接写解码后字节 ── + let written_bytes = if let Some(image_url) = &image_url_opt { + download_image_with_ssrf(image_url, &final_path).await? + } else { + // b64 路径:解码后直接写 + let b64 = b64_opt.as_ref().unwrap(); + let decoded = STANDARD.decode(b64) + .map_err(|e| anyhow::anyhow!("b64_json 解码失败: {}", e))?; + if (decoded.len() as u64) > MAX_IMAGE_BYTES { + anyhow::bail!( + "b64_json 解码后 {} 字节超过 {} 上限", + decoded.len(), + MAX_IMAGE_BYTES + ); + } + write_atomic(&final_path, &decoded).await?; + decoded.len() as u64 + }; + + let elapsed_ms = started.elapsed().as_millis() as u64; + + Ok(json!({ + "path": final_path, + "url": image_url_opt, + "model": model, + "provider_id": provider_id_resolved, + "bytes_written": written_bytes, + "elapsed_ms": elapsed_ms, + })) +} + +/// 从 provider 列表筛选图像生成可用 provider。 +/// +/// 筛选条件:`enabled && provider_type == "openai_compat"`(图像端点是 OpenAI 风格, +/// anthropic provider 排除)。 +/// +/// 选择策略: +/// 1. LLM 传 `provider_id` → 按 id 精确匹配(找不到报错)。 +/// 2. 否则按 model 名匹配厂商 host 提示: +/// - model 含 `sensenova` → 找 base_url 含 `sensenova` +/// - model 含 `dall-e` → 找 base_url 含 `openai` +/// - model 含 `google`/`imagen` → 找 base_url 含 `googleapis` +/// 3. host 命中 → 用首个匹配;host 未命中 → 兜底取筛选结果首个。 +/// 4. 筛选后空 → 报错(无可用 openai_compat provider)。 +fn select_image_provider( + providers: &[AiProviderRecord], + model: &str, + provider_id: Option<&str>, +) -> anyhow::Result { + // 筛 enabled + openai_compat + let candidates: Vec<&AiProviderRecord> = providers.iter() + .filter(|p| p.enabled && p.provider_type == "openai_compat") + .collect(); + + // 路径1:LLM 指定 provider_id,精确匹配 + if let Some(pid) = provider_id { + let found = candidates.iter().find(|p| p.id == pid) + .ok_or_else(|| anyhow::anyhow!( + "未找到指定 provider(id={},且需 enabled + openai_compat),请检查 provider_id 参数", + pid + ))?; + return Ok((*found).clone()); + } + + if candidates.is_empty() { + anyhow::bail!("无可用 openai_compat provider,请先在设置配置支持图像生成的 provider"); + } + + // 路径2:按 model 名匹配厂商 host + let model_lower = model.to_lowercase(); + let host_hint: Option<&str> = if model_lower.contains("sensenova") { + Some("sensenova") + } else if model_lower.contains("dall-e") { + Some("openai") + } else if model_lower.contains("google") || model_lower.contains("imagen") { + Some("googleapis") + } else { + None + }; + + if let Some(hint) = host_hint { + let hit = candidates.iter() + .find(|p| p.base_url.to_lowercase().contains(hint)); + if let Some(p) = hit { + return Ok((*p).clone()); + } + } + + // 路径3:兜底首个候选 + Ok(candidates[0].clone()) +} + +/// 拼接图像生成端点 URL。 +/// +/// 规则(base_url 先去尾 `/`,对齐 `model_fetch_helpers::build_models_url` 思路, +/// 但目标是 `/images/generations`): +/// - 以 `/v1` 或 `/v4` 结尾 → 直接补 `/images/generations`(避免 `/v1/v1/images/...`) +/// - 否则 → 补 `/v1/images/generations`(最常见:用户填 `https://api.sensenova.cn`) +fn build_images_url(base_url: &str) -> String { + let url = base_url.trim_end_matches('/'); + if url.ends_with("/v1") || url.ends_with("/v4") { + format!("{url}/images/generations") + } else { + format!("{url}/v1/images/generations") + } +} + +/// 流式下载图片 URL 到 output_path,带 SSRF 防护 + 大小上限 + 原子写。 +/// +/// 图片 URL 来自 provider 响应可能被恶意 provider 篡改指向内网,故复用 SSRF 防护 +/// (validate_url + resolve_and_check_host + build_client + execute_with_redirects)。 +async fn download_image_with_ssrf(image_url: &str, output_path: &str) -> anyhow::Result { + // SSRF 三层校验:词法 URL → DNS resolve IP → 每跳重定向重复 + let (_scheme, host, port) = validate_url(image_url)?; + resolve_and_check_host(&host, port).await?; + + let client = build_client(Duration::from_secs(GENERATE_TIMEOUT_SECS))?; + let resp = execute_with_redirects( + &client, + reqwest::Method::GET, + image_url.to_string(), + &HashMap::new(), // 图片下载不带自定义头 + &None, + MAX_REDIRECTS, + ) + .await?; + + if !resp.status().is_success() { + anyhow::bail!( + "下载图片失败:HTTP {} {}({})", + resp.status().as_u16(), + resp.status().canonical_reason().unwrap_or(""), + image_url + ); + } + + // 流式写文件(避免大图 OOM),边下边累计,超 MAX_IMAGE_BYTES 立即中止 + 删半成品。 + // 父目录不存在自动创建。原子写:tmp → rename(同目录不跨卷)。 + let target = std::path::Path::new(output_path); + if let Some(parent) = target.parent() { + tokio::fs::create_dir_all(parent).await + .map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?; + } + + let tmp = format!("{}.tmp-img", output_path); + let mut file = tokio::fs::File::create(&tmp).await + .map_err(|e| anyhow::anyhow!("创建临时文件失败 ({}): {}", tmp, e))?; + + let mut stream = resp.bytes_stream(); + let mut written: u64 = 0; + let mut exceeded = false; + use futures::StreamExt; + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| anyhow::anyhow!("读取图片流失败: {}", e))?; + written += chunk.len() as u64; + if written > MAX_IMAGE_BYTES { + exceeded = true; + break; + } + file.write_all(&chunk).await + .map_err(|e| anyhow::anyhow!("写入图片失败: {}", e))?; + } + file.flush().await + .map_err(|e| anyhow::anyhow!("flush 图片文件失败: {}", e))?; + drop(file); + + if exceeded { + let _ = tokio::fs::remove_file(&tmp).await; + anyhow::bail!( + "图片超过 {} 字节上限,已中止并清理临时文件", + MAX_IMAGE_BYTES + ); + } + + if let Err(e) = tokio::fs::rename(&tmp, output_path).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(anyhow::anyhow!("原子替换失败: {}", e)); + } + + Ok(written) +} + +/// 原子写字节到指定路径(tmp → rename,同目录不跨卷)。 +/// b64 路径用此函数(已全部解码在内存,无需流式)。 +async fn write_atomic(output_path: &str, data: &[u8]) -> anyhow::Result<()> { + let target = std::path::Path::new(output_path); + if let Some(parent) = target.parent() { + tokio::fs::create_dir_all(parent).await + .map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?; + } + let tmp = format!("{}.tmp-img", output_path); + tokio::fs::write(&tmp, data).await + .map_err(|e| { + let _ = std::fs::remove_file(&tmp); + anyhow::anyhow!("写入图片失败: {}", e) + })?; + tokio::fs::rename(&tmp, output_path).await + .map_err(|e| { + let _ = std::fs::remove_file(&tmp); + anyhow::anyhow!("原子替换失败: {}", e) + })?; + Ok(()) +} + +/// 响应 body 截断为最多 500 chars 的字符串(用于错误信息),供定位用。 +fn body_snippet(v: &Value) -> String { + let s = v.to_string(); + s.chars().take(500).collect() +} + +// ============================================================ +// 单元测试 +// +// 覆盖: +// ① build_images_url:端点拼接规则(纯函数,零依赖) +// ② select_image_provider:provider 筛选逻辑(纯函数,内存构造 record) +// ③ handler 参数边界(prompt 缺失/空、provider_id 不存在) +// 真实 API 调用走 #[ignore](CI 无凭证/无网时跳过,本地手跑)。 +// ============================================================ + +#[cfg(test)] +mod tests { + use super::*; + + // ── build_images_url:端点拼接规则 ── + + #[test] + fn build_images_url_v1_suffix() { + // /v1 结尾 → 直接补 /images/generations + assert_eq!( + build_images_url("https://api.openai.com/v1"), + "https://api.openai.com/v1/images/generations" + ); + // 不应产生 /v1/v1/images/... + assert!(!build_images_url("https://api.openai.com/v1").contains("/v1/v1/")); + } + + #[test] + fn build_images_url_v4_suffix() { + // GLM v4 风格 base + assert_eq!( + build_images_url("https://open.bigmodel.cn/api/paas/v4"), + "https://open.bigmodel.cn/api/paas/v4/images/generations" + ); + } + + #[test] + fn build_images_url_bare_base() { + // 用户只填根 → 补 /v1/images/generations + assert_eq!( + build_images_url("https://api.sensenova.cn"), + "https://api.sensenova.cn/v1/images/generations" + ); + } + + #[test] + fn build_images_url_strips_trailing_slash() { + assert_eq!( + build_images_url("https://api.x.com/v1/"), + "https://api.x.com/v1/images/generations" + ); + } + + // ── select_image_provider:筛选 + 匹配逻辑 ── + + /// 构造测试用 AiProviderRecord(只填筛选关键字段)。 + fn mk_provider(id: &str, enabled: bool, ptype: &str, base_url: &str) -> AiProviderRecord { + AiProviderRecord { + id: id.into(), + name: id.into(), + provider_type: ptype.into(), + api_key: String::new(), + base_url: base_url.into(), + default_model: String::new(), + models: None, + model_configs: Vec::new(), + is_default: false, + config: None, + created_at: "0".into(), + updated_at: "0".into(), + enabled, + weight: 50, + } + } + + #[test] + fn select_provider_no_candidates_errors() { + // 全 disabled 或非 openai_compat → 报错 + let providers = vec![ + mk_provider("p1", false, "openai_compat", "https://x"), + mk_provider("p2", true, "anthropic", "https://y"), + ]; + let err = select_image_provider(&providers, "any-model", None).unwrap_err(); + assert!(format!("{}", err).contains("无可用")); + } + + #[test] + fn select_provider_provider_id_match() { + let providers = vec![ + mk_provider("p1", true, "openai_compat", "https://api.sensenova.cn"), + mk_provider("p2", true, "openai_compat", "https://api.openai.com"), + ]; + let got = select_image_provider(&providers, "any", Some("p2")).unwrap(); + assert_eq!(got.id, "p2"); + } + + #[test] + fn select_provider_provider_id_not_found_errors() { + let providers = vec![mk_provider("p1", true, "openai_compat", "https://x")]; + let err = select_image_provider(&providers, "any", Some("nonexistent")).unwrap_err(); + assert!(format!("{}", err).contains("未找到指定 provider")); + } + + #[test] + fn select_provider_host_match_sensenova() { + // model 含 sensenova → 匹配 base_url 含 sensenova 的 provider + let providers = vec![ + mk_provider("openai", true, "openai_compat", "https://api.openai.com"), + mk_provider("sense", true, "openai_compat", "https://api.sensenova.cn"), + ]; + let got = select_image_provider(&providers, "sensenova-u1-fast", None).unwrap(); + assert_eq!(got.id, "sense"); + } + + #[test] + fn select_provider_host_match_dalle() { + // model 含 dall-e → 匹配 base_url 含 openai 的 provider + let providers = vec![ + mk_provider("glm", true, "openai_compat", "https://open.bigmodel.cn"), + mk_provider("oai", true, "openai_compat", "https://api.openai.com"), + ]; + let got = select_image_provider(&providers, "dall-e-3", None).unwrap(); + assert_eq!(got.id, "oai"); + } + + #[test] + fn select_provider_host_unmatched_falls_back_first() { + // model 名无厂商提示 → 兜底取筛选结果首个 + let providers = vec![ + mk_provider("first", true, "openai_compat", "https://api.unknown.com"), + mk_provider("second", true, "openai_compat", "https://api.other.com"), + ]; + let got = select_image_provider(&providers, "my-custom-model", None).unwrap(); + assert_eq!(got.id, "first"); + } + + // ── handler 参数边界(走 db 需 in-memory db) ── + + #[tokio::test] + async fn handler_missing_prompt_errors() { + // 缺 prompt → 参数解析阶段报错(不发请求) + let db = Arc::new(Database::open_in_memory().await.unwrap()); + let allowed = Arc::new(RwLock::new(AllowedDirs::default())); + let args = json!({ "model": "sensenova-u1-fast" }); + let err = execute_generate_image(args, &db, &allowed).await.unwrap_err(); + assert!(format!("{}", err).contains("prompt")); + } + + #[tokio::test] + async fn handler_empty_prompt_errors() { + let db = Arc::new(Database::open_in_memory().await.unwrap()); + let allowed = Arc::new(RwLock::new(AllowedDirs::default())); + let args = json!({ "prompt": " " }); + let err = execute_generate_image(args, &db, &allowed).await.unwrap_err(); + assert!(format!("{}", err).contains("prompt 不能为空")); + } + + #[tokio::test] + async fn handler_no_provider_errors() { + // 无 openai_compat provider → 报错(不发请求) + let db = Arc::new(Database::open_in_memory().await.unwrap()); + let allowed = Arc::new(RwLock::new(AllowedDirs::default())); + let args = json!({ "prompt": "一只猫" }); + let err = execute_generate_image(args, &db, &allowed).await.unwrap_err(); + assert!(format!("{}", err).contains("无可用") || format!("{}", err).contains("provider")); + } +} diff --git a/src-tauri/src/commands/ai/get_app_config.rs b/src-tauri/src/commands/ai/get_app_config.rs new file mode 100644 index 0000000..e72c3c2 --- /dev/null +++ b/src-tauri/src/commands/ai/get_app_config.rs @@ -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 非 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 全脱敏)"); + } +} diff --git a/src-tauri/src/commands/ai/http.rs b/src-tauri/src/commands/ai/http.rs index cdc590f..ef62423 100644 --- a/src-tauri/src/commands/ai/http.rs +++ b/src-tauri/src/commands/ai/http.rs @@ -13,7 +13,9 @@ //! 4. **重定向限制 ≤3 跳**:每跳重新校验目标 host/IP(防 302 绕过到内网)。reqwest 默认跟 ≤10, //! 改用手动 redirect(Policy::none) + 自管循环。 //! 5. **超时**:默认 30s,clamp ≤60s(对齐 run_command 思路,但封顶更紧——HTTP 调用应快速返回)。 -//! 6. **响应截断**:body ≤50KB(对齐 T-05),超长截断尾部保留 + truncated 标记。 +//! 6. **响应截断**:body 默认 ≤50KB(对齐 T-05),超长截断尾部保留 + truncated 标记。 +//! 截断阈值可经 `max_response_chars` 参数覆盖(clamp [1KB, 10MB]);或经 `output_file` +//! 将响应全量落盘(不截断),body 字段置 null + path/bytes_written 返回。 //! //! ## 风险分级 //! @@ -26,12 +28,22 @@ use std::collections::HashMap; use std::net::IpAddr; +use std::path::Path; use std::time::{Duration, Instant}; use serde_json::{json, Value}; -/// 响应 body 截断上限(字节)。对齐 T-05 文档约定,防大响应撑爆 LLM context。 -const MAX_BODY_BYTES: usize = 50 * 1024; +use crate::state::AllowedDirs; + +/// 响应 body 截断默认上限(字节)。对齐 T-05 文档约定,防大响应撑爆 LLM context。 +/// +/// 2026-08-01:由硬编码改为默认值,LLM 可经 `max_response_chars` 参数覆盖(clamp [1024, 10MB])。 +/// 无 `max_response_chars` 时回退此默认(保旧行为零变更)。 +const DEFAULT_MAX_BODY_BYTES: usize = 50 * 1024; +/// `max_response_chars` 参数 clamp 下限(字节)。防 LLM 传过小值截到无意义残片。 +const MIN_MAX_RESPONSE_CHARS: usize = 1024; +/// `max_response_chars` 参数 clamp 上限(字节,10MB)。防 LLM 传超大值撑爆 LLM context。 +const MAX_MAX_RESPONSE_CHARS: usize = 10 * 1024 * 1024; /// 默认请求超时(秒)。LLM 可经 args timeout_secs 覆盖。 const DEFAULT_TIMEOUT_SECS: u64 = 30; @@ -246,9 +258,18 @@ fn build_request( /// - body: string,可选(GET 通常不传;POST/PUT/PATCH 传) /// - timeout_secs: 默认 30,clamp ≤60 /// - parse: "json"|"text"|"auto"(默认 auto),控制 body 解析展示 +/// - output_file: string 可选,响应 body 写入指定文件路径(而非返回 JSON body), +/// 适合大响应。路径走工具文件路径校验(validate_path + resolve_workspace_path_with_allowed +/// + parent 授权目录校验,与 write_file 同源),全量写不截断。 +/// - max_response_chars: integer 可选,默认 50KB(51200),clamp [1024, 10MB]。 +/// body 截断阈值,替代硬编码上限。无 output_file 时控制返回 body 的截断;有 output_file 时不截断。 /// -/// 返回 {status, status_text, headers, body, elapsed_ms, truncated, url(最终重定向后)} -pub(crate) async fn execute_http_request(args: Value) -> anyhow::Result { +/// `allowed`:授权目录快照(由 handler 闭包 read lock clone 传入),仅 output_file 落盘路径校验用。 +/// 无 output_file 时路径校验不触发,allowed 可为空快照(默认)。 +/// +/// 返回 {status, status_text, headers, body, elapsed_ms, truncated, url(最终重定向后)}。 +/// 有 output_file 时:body 字段置 null(已落盘),增 path/bytes_written,且 truncated=false(全量写)。 +pub(crate) async fn execute_http_request(args: Value, allowed: &AllowedDirs) -> anyhow::Result { // ── 参数解析 ── let method_str = args.get("method").and_then(|v| v.as_str()).unwrap_or("GET").to_uppercase(); let method = match method_str.as_str() { @@ -286,6 +307,25 @@ pub(crate) async fn execute_http_request(args: Value) -> anyhow::Result { .min(MAX_TIMEOUT_SECS) .max(1); // clamp [1, 60] let parse_mode = args.get("parse").and_then(|v| v.as_str()).unwrap_or("auto").to_lowercase(); + // output_file:响应 body 落盘路径(可选)。存在时走文件路径校验 + 全量写,不返回 body。 + let output_file = args.get("output_file").and_then(|v| v.as_str()).map(|s| s.to_string()); + // max_response_chars:body 截断阈值(字节)。默认 DEFAULT_MAX_BODY_BYTES,clamp [1024, 10MB]。 + let max_response_chars = args.get("max_response_chars").and_then(|v| v.as_u64()) + .map(|n| (n as usize).clamp(MIN_MAX_RESPONSE_CHARS, MAX_MAX_RESPONSE_CHARS)) + .unwrap_or(DEFAULT_MAX_BODY_BYTES); + // output_file 路径校验(若提供):validate_path + workspace 授权 + symlink 逃逸 + + // parent 授权目录(落盘写需要,对齐 write_file FR-S8)。落盘时再次 resolve(复用同一校验)。 + if let Some(of) = &output_file { + if of.trim().is_empty() { + anyhow::bail!("output_file 不能为空"); + } + let resolved = super::tool_registry::resolve_workspace_path_with_allowed(of, allowed)?; + if let Some(parent) = Path::new(&resolved).parent() { + if !allowed.is_authorized(parent) { + anyhow::bail!("禁止在项目目录之外写入文件"); + } + } + } // ── SSRF 校验(三层:词法 URL → DNS resolve IP → 每跳重定向重复) ── let (scheme, host, port) = validate_url(&url_raw)?; @@ -314,11 +354,55 @@ pub(crate) async fn execute_http_request(args: Value) -> anyhow::Result { .or_insert(val); } - // body 读取 + 截断 + // body 读取 let full_body = resp.bytes().await .map_err(|e| anyhow::anyhow!("读取响应 body 失败: {}", e))?; let total_bytes = full_body.len(); - let (body_text, truncated) = truncate_body(&full_body, MAX_BODY_BYTES); + + // ── output_file 分支:全量写盘,不截断,不返回 body ── + if let Some(of) = &output_file { + let resolved = super::tool_registry::resolve_workspace_path_with_allowed(of, allowed)?; + let path = resolved.to_str() + .ok_or_else(|| anyhow::anyhow!("output_file 路径含非法字符"))? + .to_string(); + let target = Path::new(&path); + // 父目录授权(对齐 write_file FR-S8)+ 自动创建(create_dir_all) + if let Some(parent) = target.parent() { + if !allowed.is_authorized(parent) { + anyhow::bail!("禁止在项目目录之外写入文件"); + } + tokio::fs::create_dir_all(parent).await + .map_err(|e| anyhow::anyhow!("创建输出目录失败: {}", e))?; + } + // 全量写(原子:tmp→rename,避免写一半崩溃留半成品,与 write_file 同思路) + let tmp = format!("{}.tmp-http", path); + tokio::fs::write(&tmp, &full_body).await + .map_err(|e| { + let _ = std::fs::remove_file(&tmp); + anyhow::anyhow!("写入 output_file 失败: {}", e) + })?; + tokio::fs::rename(&tmp, &path).await + .map_err(|e| { + let _ = std::fs::remove_file(&tmp); + anyhow::anyhow!("output_file 原子重命名失败: {}", e) + })?; + return Ok(json!({ + "method": method_str, + "url": final_url, + "status": status, + "status_text": status_text, + "scheme": scheme, + "headers": resp_headers, + "body": null, + "path": path, + "bytes_written": total_bytes, + "truncated": false, + "elapsed_ms": elapsed_ms, + })); + } + + // ── 默认分支:截断 + parse 格式化 + 返回 body ── + let (body_text, truncated) = truncate_body(&full_body, max_response_chars); // parse 处理:json → pretty;auto → 尝试 json 失败回退 text;二进制(含 \0)跳过 let parsed_body = format_body(&body_text, total_bytes, &parse_mode, &resp_headers); @@ -594,14 +678,14 @@ mod tests { #[tokio::test] async fn test_handler_missing_url_errors() { let args = json!({ "method": "GET" }); - let err = execute_http_request(args).await.unwrap_err(); + let err = execute_http_request(args, &AllowedDirs::default()).await.unwrap_err(); assert!(format!("{}", err).contains("缺少 url")); } #[tokio::test] async fn test_handler_invalid_method_errors() { let args = json!({ "method": "TRACE", "url": "https://example.com/" }); - let err = execute_http_request(args).await.unwrap_err(); + let err = execute_http_request(args, &AllowedDirs::default()).await.unwrap_err(); assert!(format!("{}", err).contains("不支持的 method")); } @@ -609,21 +693,21 @@ mod tests { async fn test_handler_rejects_localhost_url() { // SSRF 防护:handler 入口 validate_url 拒 localhost,不发请求 let args = json!({ "method": "GET", "url": "http://localhost:8080/admin" }); - let err = execute_http_request(args).await.unwrap_err(); + let err = execute_http_request(args, &AllowedDirs::default()).await.unwrap_err(); assert!(format!("{}", err).contains("localhost") || format!("{}", err).contains("SSRF")); } #[tokio::test] async fn test_handler_rejects_private_ip_url() { let args = json!({ "method": "GET", "url": "http://169.254.169.254/latest/meta-data/" }); - let err = execute_http_request(args).await.unwrap_err(); + let err = execute_http_request(args, &AllowedDirs::default()).await.unwrap_err(); assert!(format!("{}", err).contains("私网") || format!("{}", err).contains("SSRF")); } #[tokio::test] async fn test_handler_rejects_non_http_scheme() { let args = json!({ "url": "file:///etc/passwd" }); - let err = execute_http_request(args).await.unwrap_err(); + let err = execute_http_request(args, &AllowedDirs::default()).await.unwrap_err(); assert!(format!("{}", err).contains("协议")); } @@ -632,7 +716,44 @@ mod tests { // timeout_secs=9999 应 clamp 到 60;此处只验证不因超大值 panic(handler 内会走网络, // 故本测试不期望成功完成——用 localhost 触发 SSRF 早期拒绝,验证 clamp 在拒绝前不 panic) let args = json!({ "url": "http://localhost/", "timeout_secs": 9999 }); - let _ = execute_http_request(args).await; // 不 panic 即通过 + let _ = execute_http_request(args, &AllowedDirs::default()).await; // 不 panic 即通过 + } + + // ── output_file:路径校验拒绝(提前在 SSRF/请求前触发,不发网络) ── + + #[tokio::test] + async fn test_output_file_rejects_path_traversal() { + // output_file 含 .. → validate_path 拒绝(在发请求前,故无需真实网络) + let args = json!({ + "url": "https://example.com/", + "output_file": "../escape.txt" + }); + let err = execute_http_request(args, &AllowedDirs::default()).await.unwrap_err(); + assert!(format!("{}", err).contains("路径遍历")); + } + + #[tokio::test] + async fn test_output_file_rejects_empty() { + let args = json!({ + "url": "https://example.com/", + "output_file": " " + }); + let err = execute_http_request(args, &AllowedDirs::default()).await.unwrap_err(); + assert!(format!("{}", err).contains("output_file 不能为空")); + } + + #[tokio::test] + async fn test_output_file_rejects_relative_without_allowed() { + // 相对路径 + 空白名单 → resolve_workspace_path_with_allowed 引导报错(在发请求前) + let args = json!({ + "url": "https://example.com/", + "output_file": "out.txt" + }); + let err = execute_http_request(args, &AllowedDirs::default()).await.unwrap_err(); + // 无授权目录时 resolve 报"请先绑定项目目录" + let msg = format!("{}", err); + assert!(msg.contains("绑定项目目录") || msg.contains("授权目录"), + "期望授权引导错误,实际: {}", msg); } // ── 真实网络集成(#[ignore]:CI 无网时跳过,本地手跑) ── @@ -641,7 +762,7 @@ mod tests { #[ignore = "需真实网络(example.com),CI 无网时跳过:cargo test -- --ignored"] async fn integration_get_example_com_200() { let args = json!({ "method": "GET", "url": "https://example.com/" }); - let result = execute_http_request(args).await.expect("GET example.com 应成功"); + let result = execute_http_request(args, &AllowedDirs::default()).await.expect("GET example.com 应成功"); let status = result["status"].as_u64().unwrap(); assert!(status == 200, "期望 200,实际 {}", status); assert!(result["body"].as_str().unwrap_or("").contains("Example Domain")); @@ -649,13 +770,58 @@ mod tests { assert!(result["elapsed_ms"].as_u64().unwrap_or(0) > 0); } + #[tokio::test] + #[ignore = "需真实网络,本地手跑"] + async fn integration_max_response_chars_truncates() { + // 默认 50KB 不截断 example.com(<1KB);max_response_chars=100 强制截断验证参数生效 + let args = json!({ + "url": "https://example.com/", + "max_response_chars": 100 + }); + let result = execute_http_request(args, &AllowedDirs::default()).await + .expect("GET example.com 应成功"); + assert_eq!(result["truncated"], true, "应被 max_response_chars 截断"); + // 默认(无 max_response_chars)不截断 + let args2 = json!({ "url": "https://example.com/" }); + let result2 = execute_http_request(args2, &AllowedDirs::default()).await.unwrap(); + assert_eq!(result2["truncated"], false); + } + + #[tokio::test] + #[ignore = "需真实网络 + 临时目录,本地手跑"] + async fn integration_output_file_writes_body() { + // output_file 落盘:body 应为 null,path/bytes_written 存在,truncated=false。 + // 路径校验需授权目录,故构建含 temp_dir 的 AllowedDirs(对齐 write_file 测试思路)。 + let dir = std::env::temp_dir(); + let out_path = dir.join("devflow_http_test_output.html"); + let out_str = out_path.to_string_lossy().to_string(); + let mut allowed = AllowedDirs::default(); + allowed.persistent.insert(dir.clone()); + let args = json!({ + "url": "https://example.com/", + "output_file": out_str + }); + let result = execute_http_request(args, &allowed).await + .expect("GET example.com 应成功"); + assert_eq!(result["status"], 200); + assert_eq!(result["body"], serde_json::Value::Null, "body 应置 null(已落盘)"); + assert_eq!(result["truncated"], false, "落盘不截断"); + let bytes_written = result["bytes_written"].as_u64().unwrap(); + assert!(bytes_written > 0, "应写入非零字节"); + assert_eq!(result["path"], out_str); + // 校验文件内容确实是 example.com 响应 + let written = std::fs::read_to_string(&out_path).unwrap(); + assert!(written.contains("Example Domain")); + let _ = std::fs::remove_file(&out_path); + } + #[tokio::test] #[ignore = "需真实网络,本地手跑"] async fn integration_get_follows_redirect() { // httpbin.org/redirect-to 重定向到 example.com,验证重定向链 + SSRF 每跳校验通过 let target = urlencoding("https://example.com/"); let args = json!({ "url": format!("https://httpbin.org/redirect-to?url={}", target) }); - let result = execute_http_request(args).await.expect("重定向链应成功"); + let result = execute_http_request(args, &AllowedDirs::default()).await.expect("重定向链应成功"); let status = result["status"].as_u64().unwrap_or(0); // 最终落地 200(经重定向) assert!(status >= 200 && status < 400, "重定向后状态: {}", status); diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index 4c41f41..abda885 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -37,6 +37,10 @@ pub mod conversation; pub mod event_bus; pub mod http; pub mod fetch_url; +pub mod fetch_search; +pub mod download_file; +pub(crate) mod generate_image; +pub mod get_app_config; pub mod knowledge_inject; pub mod prompt; pub mod provider_pool; diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index 0d66dce..fa743f9 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -301,6 +301,52 @@ pub(crate) fn compute_file_hash(meta: &std::fs::Metadata) -> String { format!("{}_{}", modified.unwrap_or(0), meta.len()) } +/// 将原始字节解码为 UTF-8 字符串,优先识别 UTF-16 LE/BE BOM 解码转 UTF-8。 +/// +/// 背景(P1 修复):PowerShell `Out-File`/`>` 重定向默认产 UTF-16 LE BOM 文件, +/// 每个 ASCII 字符高字节为 0x00(NUL)。旧 read_file 用 read_to_string 直接读 → +/// (a) read_to_string 对含 NUL 字节内容硬失败 InvalidData;或 (b) 即便侥幸读到, +/// 后续 is_binary / patch_file 的 `\0` 检测把 UTF-16 文本误判二进制拒读 → +/// run_command(Out-File) → 文件 → read_file 链路在 Windows 断裂。 +/// +/// 本 helper 替代 read_to_string 的纯字节→字符串转换: +/// 1. UTF-16 LE BOM(FF FE):strip BOM 后 u16::from_le_bytes + String::from_utf16 解码; +/// 2. UTF-16 BE BOM(FE FF):同上 from_be_bytes; +/// 3. 无 BOM:走 String::from_utf8(等价原 read_to_string 的 UTF-8 校验)。 +/// 注:UTF-8 BOM(EF BB BF)是合法 UTF-8 序列,read_to_string 原本就能读(内容首字符多 U+FEFF), +/// 不在本 helper 误判路径内,无需特判。 +/// +/// 解码失败(真二进制/非 UTF-8 非 BOM 内容)返 io::Error(InvalidData),与 read_to_string +/// 同语义——调用方保留原 `if e.kind() == InvalidData { 返 binary 标记 }` 分支不变, +/// 真二进制(图片/编译产物)仍被拦截,UTF-16 文本因 BOM 优先识别不再进 \x00 误判路径。 +/// +/// 奇数字节 UTF-16(尾字节落单):drop 最后一个残字节后解码(对齐 PowerShell 偶发写半字符场景, +/// 不 bail 让多数可读内容通过)。Lone surrogate 交 String::from_utf16 lossy 兜底不 panic。 +pub(crate) fn decode_bytes_to_string(bytes: &[u8]) -> std::io::Result { + // UTF-16 LE BOM: FF FE + if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE { + let body = &bytes[2..]; + let units: Vec = body + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + // from_utf16 遇 lone surrogate 返 Err(此处用 lossy 兜底保内容可读,不阻断) + return Ok(String::from_utf16_lossy(&units)); + } + // UTF-16 BE BOM: FE FF + if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF { + let body = &bytes[2..]; + let units: Vec = body + .chunks_exact(2) + .map(|c| u16::from_be_bytes([c[0], c[1]])) + .collect(); + return Ok(String::from_utf16_lossy(&units)); + } + // 无 BOM:走 UTF-8 校验(等价 read_to_string,失败 InvalidData → 调用方判二进制) + String::from_utf8(bytes.to_vec()) + .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "非 UTF-8 / 非 UTF-16 BOM 文本")) +} + /// 阶段4(容错/恢复,开关 `df-ai-approval-retry`):跨盘/跨卷文件移动统一降级 helper。 /// /// 背景:Windows 跨盘符(C→E)或跨卷时 `tokio::fs::rename` 报 `os error 17` @@ -448,16 +494,26 @@ pub(crate) async fn bind_dir_to_project( /// F-260619-03 Phase A: 新增 `allowed_dirs: &Arc>` 形参, /// 文件工具闭包 clone 进闭包,在 resolve_workspace_path_with_allowed 校验动态白名单。 /// data/http 工具不涉及文件路径,不传白名单。 +/// +/// get_app_config(2026-08-02):新增 `get_app_config_ctx: GetAppConfigCtx` 形参, +/// 该工具查 DevFlow 自身当前 AI 配置(provider/agent/settings),需读 AppState 内存原子量 +/// (agent_max_iterations 等)+ db。因本函数在 AppState::init 内、AppState 构建中被调, +/// 无法传 &AppState(自引用),故把所需 Arc 句柄打包成 GetAppConfigCtx 传入(state.rs +/// init 先建这些 Arc 再 build registry 再组装 state,顺序调整)。 pub fn build_ai_tool_registry( db: &Arc, allowed_dirs: &Arc>, data_dir: PathBuf, + get_app_config_ctx: super::get_app_config::GetAppConfigCtx, ) -> AiToolRegistry { let mut registry = AiToolRegistry::new(); register_data_tools(&mut registry, db); register_file_tools(&mut registry, allowed_dirs, data_dir); - register_http_tools(&mut registry); + register_http_tools(&mut registry, allowed_dirs); register_fetch_url_tool(&mut registry); + register_fetch_search_tool(&mut registry); + register_generate_image_tool(&mut registry, db, allowed_dirs); + register_get_app_config_tool(&mut registry, get_app_config_ctx); registry } @@ -473,8 +529,11 @@ pub fn build_ai_tool_registry( /// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托 /// super::tools::http::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count /// 仍断言 48 总量 + 工具名集合稳定。 -fn register_http_tools(registry: &mut AiToolRegistry) { - super::tools::http::register(registry); +/// +/// 2026-08-01 output_file 参数:响应可落盘,路径走文件路径校验,故透传 allowed_dirs 给 +/// tools::http::register(由其闭包捕获,handler 内 read lock clone 传 execute_http_request)。 +fn register_http_tools(registry: &mut AiToolRegistry, allowed_dirs: &Arc>) { + super::tools::http::register(registry, allowed_dirs); } /// fetch_url AI 工具注册(1 个:URL → markdown 文档嗅探)— 只读 GET 网页文档。 @@ -490,6 +549,63 @@ fn register_fetch_url_tool(registry: &mut AiToolRegistry) { super::tools::fetch_url::register(registry); } +/// fetch_search AI 工具注册(1 个:搜索引擎查询)— DuckDuckGo HTML 端点免 key 搜索。 +/// 不持 db,纯 reqwest GET + HTML 字符串扫描解析。SSRF 防护复用 commands/ai/http.rs +/// (validate_url / resolve_and_check_host / build_client / execute_with_redirects)。 +/// +/// 风险:Low(只读 GET 搜索,无副作用,与 fetch_url 同级)。与 fetch_url 分工:fetch_url 输出 +/// 已知 URL 的 markdown 正文,fetch_search 输出未知关键词的候选 URL 列表(标题+摘要), +/// 供 LLM 挑选入口再 fetch_url 深读。补「fetch_url 只抓已知 URL,无搜索能力」缺口。 +/// +/// HTML 解析:手写字符串扫描(对齐 fetch_url::extract_title 风格),不引 scraper 重依赖 +/// (html5ever+selectors+cssparser 编译产物大,DDG 单一来源不值)。基线测试守护总量 + 工具名集合。 +fn register_fetch_search_tool(registry: &mut AiToolRegistry) { + super::tools::fetch_search::register(registry); +} + +/// generate_image AI 工具注册(1 个:调 provider 图像生成端点 + 下载落地)。 +/// +/// 工具职责:调 provider 的 OpenAI 兼容 `/v1/images/generations` 端点生成图片(默认 SenseNova +/// U1 Fast,也支持 DALL-E 等),自动下载到工作区 output_path。图像生成模型非 chat,走独立 +/// 端点非 chat completions,故不能经主对话链路调用,必须用本工具显式触发。 +/// +/// 涉网络(provider POST + 图片 URL 下载)+ 付费 API(按张计费)+ 写文件(落盘 output_path), +/// 与 download_file 同源(均持 allowed_dirs 做 output_path 白名单校验 + 默认路径锚定)。 +/// risk=High(付费 + 写磁盘 + 外发)。基线测试 test_build_ai_tool_registry_baseline_tool_count +/// 守护总量 + 工具名集合稳定。 +/// +/// handler 在 commands/ai/generate_image.rs,声明式注册在 tools/generate_image.rs。 +/// declare_tool! 单捕获限制:tools 层把 (db, allowed_dirs) 包进 tuple Arc 在闭包内解构。 +fn register_generate_image_tool( + registry: &mut AiToolRegistry, + db: &Arc, + allowed_dirs: &Arc>, +) { + super::tools::generate_image::register(registry, db, allowed_dirs); +} + +/// get_app_config AI 工具注册(1 个:查 DevFlow 自身当前 AI 配置,只读 + 脱敏)— +/// 返 {default_provider, providers, agent, app_settings} JSON。 +/// +/// 治症状:AI 想看「当前 provider/model/agent 设置」时,因没原生查配置工具,只能 run_command +/// 执行 PowerShell 内联 node/python 脚本查 db,引号三重嵌套必失败。本工具一调即得,根治绕行。 +/// +/// 风险:Low(纯只读 list/load,无写副作用)。安全:api_key 经 mask_api_key 脱敏 +/// (前 4 + •••• + 后 4),绝不返明文;app_settings 走白名单不返敏感 KV。 +/// +/// handler 在 commands/ai/get_app_config.rs,声明式注册在 tools/get_app_config.rs。 +/// declare_tool! 单捕获:把 db + 3 个 agent 配置 Arc + LlmConcurrency 包进 GetAppConfigCtx +/// struct(Clone 廉价,全 Arc 字段),闭包内 &ctx 转调 handler。 +/// +/// ctx 字段与 AppState 同名字段共享同一底层原子量(经 ai_set_concurrency_config / +/// ai_set_agent_max_iterations 等 IPC 热改后,handler 读到的永远是当前生效值)。 +fn register_get_app_config_tool( + registry: &mut AiToolRegistry, + ctx: super::get_app_config::GetAppConfigCtx, +) { + super::tools::get_app_config::register(registry, ctx); +} + /// 数据层 AI 工具注册(25 个持 db 的 CRUD/状态机/工作流/知识图谱工具)——从 build_ai_tool_registry 抽出。 /// /// 工具闭包捕获 `db: &Arc` Arc 重建 Repo(列表/创建/更新/删除/状态推进/工作流/任务关联)。 @@ -639,6 +755,10 @@ fn register_file_tools( data_dir: PathBuf, ) { super::tools::file::register(registry, allowed_dirs, data_dir); + // download_file:跨平台 URL→文件流式下载(reqwest bytes_stream + tokio::fs)。 + // 写文件需路径授权(与 write_file 同源),故在 register_file_tools 注册(已有 allowed_dirs)。 + // SSRF 复用 commands/ai/http.rs,大小上限 200MB + 原子写(tmp→rename)。 + super::tools::download_file::register(registry, allowed_dirs); } /// 探测可执行文件路径(which/where 风格),8s 超时兜底。 @@ -1122,6 +1242,28 @@ pub(crate) fn search_files_recursive<'a>( mod tests { use super::*; use std::fs; + // get_app_config baseline 测试辅助:构造 GetAppConfigCtx(与 AppState init 同款默认值, + // 仅用于 build_ai_tool_registry 注册阶段定义完整性断言,handler 不实际执行故无需真实配置)。 + use std::sync::atomic::{AtomicU64, AtomicUsize}; + use crate::state::LlmConcurrency; + + /// 测试用 GetAppConfigCtx 构造器(默认值对齐 AppState::init:iter=10/retries=3/timeout=15/concurrency 3,2)。 + /// + /// build_ai_tool_registry 第 4 参,5 处测试调用共用,避免每处内联 5 行重复。 + /// Arc 句柄独立于 AppState(测试无 AppState),仅满足注册期类型签名,handler 不执行。 + fn make_test_get_app_config_ctx(db: &Arc) -> super::get_app_config::GetAppConfigCtx { + super::get_app_config::GetAppConfigCtx { + db: db.clone(), + agent_max_iterations: Arc::new(AtomicUsize::new( + crate::commands::ai::agentic::DEFAULT_MAX_AGENT_ITERATIONS, + )), + agent_max_retries: Arc::new(AtomicUsize::new( + crate::commands::ai::agentic::DEFAULT_MAX_AGENT_RETRIES, + )), + approval_timeout_minutes: Arc::new(AtomicU64::new(15)), + llm_concurrency: LlmConcurrency::new(3, 2), + } + } // ============================================================ // 工具注册基线测试(SMELL-P0-2 拆分防护) @@ -1131,7 +1273,7 @@ mod tests { // 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。 // ============================================================ - /// build_ai_tool_registry 应注册恰好 51 个工具(36 data + 13 file + 1 http + 1 fetch_url),且工具名集合稳定。 + /// build_ai_tool_registry 应注册恰好 55 个工具(36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config),且工具名集合稳定。 /// /// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db, // 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。 @@ -1142,7 +1284,7 @@ mod tests { // F-260619-03 Phase A: build_ai_tool_registry 新增 allowed_dirs 形参, // 测试用 default_with_root(仅 workspace_root),零回归(白名单含 workspace_root)。 let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root())); - let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from("")); + let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db)); // 总量基线:41(27 data + 13 file + 1 http)。拆分前后必须一致。 // F-260621: file 层 10→11(新增 grep 跨文件内容搜索工具)。 @@ -1168,15 +1310,31 @@ mod tests { // fetch_url(2026-08-01): http 层 1→2(新增 URL→markdown 文档嗅探,只读 GET, // htmd HTML→markdown + 去噪音 + 截断,SSRF 复用 http.rs)。 // 51 = 36 data + 13 file + 1 http + 1 fetch_url。 + // download_file(2026-08-01): file 层 13→14(新增 URL→文件跨平台流式下载, + // reqwest bytes_stream + tokio::fs,替代 curl/wget alias 陷阱。写文件需 allowed_dirs + // 故注册在 register_file_tools;SSRF 复用 http.rs + 路径校验复用 validate_path)。 + // 52 = 36 data + 14 file + 1 http + 1 fetch_url。 + // fetch_search(2026-08-02): fetch_search 层 1→2(新增搜索引擎查询,DuckDuckGo HTML 免 key, + // GET + HTML 字符串扫描解析结果列表,补 fetch_url 只抓已知 URL 缺口。SSRF 复用 http.rs)。 + // 53 = 36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search。 + // generate_image(2026-08-02): generate_image 层 1→1(新增调 provider 图像生成端点 + + // 下载落地,默认 sensenova-u1-fast,也支持 DALL-E。SSRF 复用 http.rs + 路径校验复用 + // resolve_workspace_path_with_allowed,declare_tool! 单捕获绕法用 tuple Arc)。 + // 54 = 36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image。 + // get_app_config(2026-08-02): get_app_config 层 1→1(新增查 DevFlow 自身当前 AI 配置, + // 只读 + api_key 脱敏,治 AI 查配置绕 run_command PowerShell 内联脚本引号嵌套失败。 + // build_ai_tool_registry 加第 4 参 GetAppConfigCtx 绕 AppState 自引用,declare_tool! + // 单捕获用 struct ctx 包 db + 3 agent Arc + LlmConcurrency,共享 AppState 同名句柄)。 + // 55 = 36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config。 assert_eq!( registry.len(), - 51, - "工具总数应为 51(36 data + 13 file + 1 http + 1 fetch_url),实际 {}", registry.len() + 55, + "工具总数应为 55(36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config),实际 {}", registry.len() ); // 工具名集合基线:防 rename / 漏注册 / 误删除。 // data 层 36 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流 + 基础设施配置 + git 工具 - // file 层 13 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测/符号解析 + // file 层 14 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测/符号解析/下载 // http 层 1 个(不持 db):http_request let mut expected: Vec<&str> = vec![ // ── data 层 (36) ── @@ -1200,17 +1358,27 @@ mod tests { "git_status", "git_diff", "git_log", // Git 写工具 "git_commit", "git_branch", "git_merge", - // ── file 层 (13) ──(run_command 注册顺序已移至末位降低 LLM 偏好, + // ── file 层 (14) ──(run_command 注册顺序已移至末位降低 LLM 偏好, // 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621; - // detect_environment 新增 L1 环境感知 设计 §2.1;read_symbol 新增 AST 代码智能) + // detect_environment 新增 L1 环境感知 设计 §2.1;read_symbol 新增 AST 代码智能; + // download_file 新增跨平台 URL→文件流式下载) "read_file", "read_symbol", "list_directory", "write_file", "patch_file", "file_info", "append_file", "delete_file", "rename_file", "search_files", "run_command", "grep", "detect_environment", + // download_file(URL→文件流式下载,写文件需 allowed_dirs,注册在 register_file_tools) + "download_file", // ── http 层 (1) ── "http_request", // ── fetch_url 层 (1) ──(URL → markdown 文档嗅探,只读 GET,与 http_request 分工) "fetch_url", + // ── fetch_search 层 (1) ──(搜索引擎查询 DDG HTML 免 key,只读 GET,补 fetch_url 缺口) + "fetch_search", + // ── generate_image 层 (1) ──(调 provider 图像生成端点 + 下载落地,默认 sensenova-u1-fast) + "generate_image", + // ── get_app_config 层 (1) ──(查 DevFlow 自身当前 AI 配置,只读 + api_key 脱敏, + // 治 AI 查配置绕 run_command PowerShell 内联脚本引号嵌套失败) + "get_app_config", ]; expected.sort_unstable(); @@ -1412,7 +1580,7 @@ mod tests { let db = Database::open_in_memory().await.expect("in-memory db 初始化失败"); let db = Arc::new(db); - let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from("")); + let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db)); let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string(); let args = serde_json::json!({ "path": canon_file, "limit": 15 }); let res = registry.execute("read_file", args).await.expect("read_file 执行失败"); @@ -1439,7 +1607,7 @@ mod tests { let db = Database::open_in_memory().await.expect("in-memory db 初始化失败"); let db = Arc::new(db); - let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from("")); + let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db)); let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string(); let args = serde_json::json!({ "path": canon_file }); let res = registry.execute("read_file", args).await.expect("read_file 执行失败"); @@ -1450,6 +1618,114 @@ mod tests { fs::remove_dir_all(&tmp).ok(); } + // ============================================================ + // P1 修复:UTF-16 LE/BE BOM 文件解码(PowerShell Out-File/> 产 UTF-16 LE, + // 旧 read_to_string 因 ASCII 高字节 0x00 判 InvalidData → 误判二进制拒读) + // decode_bytes_to_string 纯函数单测 + read_file 端到端 + 真 binary 仍拒读 + // ============================================================ + + /// decode_bytes_to_string: UTF-16 LE BOM(FF FE) → 可读 UTF-8 + #[test] + fn test_decode_bytes_utf16_le_bom() { + // "hello\n" UTF-16 LE 编码 + let text = "hello\n"; + let mut bytes: Vec = vec![0xFF, 0xFE]; // LE BOM + for u in text.encode_utf16() { + bytes.extend_from_slice(&u.to_le_bytes()); + } + let s = decode_bytes_to_string(&bytes).expect("UTF-16 LE BOM 应解码成功"); + assert_eq!(s, text, "UTF-16 LE BOM 文件应解码为原文本"); + } + + /// decode_bytes_to_string: UTF-16 BE BOM(FE FF) → 可读 UTF-8 + #[test] + fn test_decode_bytes_utf16_be_bom() { + // 中文 + ASCII,UTF-16 BE 编码(验证非 ASCII 码点 + 大端序) + let text = "中文 ascii\n"; + let mut bytes: Vec = vec![0xFE, 0xFF]; // BE BOM + for u in text.encode_utf16() { + bytes.extend_from_slice(&u.to_be_bytes()); + } + let s = decode_bytes_to_string(&bytes).expect("UTF-16 BE BOM 应解码成功"); + assert_eq!(s, text, "UTF-16 BE BOM 文件应解码为原文本(含中文码点)"); + } + + /// decode_bytes_to_string: 无 BOM 真 binary(密集 \x00 非 BOM 前缀) → InvalidData + #[test] + fn test_decode_bytes_real_binary_rejected() { + // 真二进制:图片/编译产物常见,前两字节非 BOM 模式且含密集 NUL + let bin: Vec = vec![0x89, 0x50, 0x4E, 0x47, 0x00, 0x0D, 0x00, 0x0A]; // PNG 头片段 + let err = decode_bytes_to_string(&bin).expect_err("真二进制应解码失败"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData, "真二进制返 InvalidData"); + } + + /// decode_bytes_to_string: 普通 UTF-8 无 BOM → 透传(回归保护) + #[test] + fn test_decode_bytes_plain_utf8_passthrough() { + let bytes = "普通 UTF-8 文本\n".as_bytes(); + let s = decode_bytes_to_string(bytes).expect("UTF-8 无 BOM 应直接通过"); + assert_eq!(s, "普通 UTF-8 文本\n"); + } + + /// read_file 端到端:UTF-16 LE BOM 文件经 registry.execute 读出正常 content + binary=false + #[tokio::test] + async fn test_read_file_utf16_le_bom_decodes() { + let tmp = std::env::temp_dir().join(format!("df_readfile_utf16le_{}", std::process::id())); + let _ = fs::remove_dir_all(&tmp); + fs::create_dir_all(&tmp).unwrap(); + // 造 UTF-16 LE BOM 文件(模拟 PowerShell Out-File 产物) + let text = "line1\nline2\nPowerShell output\n"; + let mut bytes: Vec = vec![0xFF, 0xFE]; + for u in text.encode_utf16() { + bytes.extend_from_slice(&u.to_le_bytes()); + } + let file = tmp.join("ps_out.txt"); + fs::write(&file, &bytes).unwrap(); + + let mut persistent = std::collections::HashSet::new(); + persistent.insert(tmp.clone()); + let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default(), once: Default::default() })); + let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败")); + let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db)); + + let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string(); + let args = serde_json::json!({ "path": canon_file }); + let res = registry.execute("read_file", args).await.expect("read_file 执行失败"); + // 关键断言:不再误判二进制,返正常文本内容 + assert_eq!(res["binary"], serde_json::Value::Null, "UTF-16 LE BOM 不应判二进制"); + let content = res["content"].as_str().expect("应返回 content 字段"); + assert!(content.contains("line1"), "解码内容应含 line1,实际: {}", content); + assert!(content.contains("PowerShell output"), "解码内容应含原文,实际: {}", content); + + fs::remove_dir_all(&tmp).ok(); + } + + /// read_file 端到端:真 binary(密集 \x00 非 BOM)仍返 binary:true(拦截不破) + #[tokio::test] + async fn test_read_file_real_binary_still_rejected() { + let tmp = std::env::temp_dir().join(format!("df_readfile_bin_{}", std::process::id())); + let _ = fs::remove_dir_all(&tmp); + fs::create_dir_all(&tmp).unwrap(); + // PNG 签名 + 密集 NUL(真二进制,前两字节 89 50 非 BOM) + let bin: Vec = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0xFF, 0xFE]; + let file = tmp.join("image.png"); + fs::write(&file, &bin).unwrap(); + + let mut persistent = std::collections::HashSet::new(); + persistent.insert(tmp.clone()); + let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default(), once: Default::default() })); + let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败")); + let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db)); + + let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string(); + let args = serde_json::json!({ "path": canon_file }); + let res = registry.execute("read_file", args).await.expect("read_file 执行失败"); + assert_eq!(res["binary"], true, "真二进制(非 BOM 前缀)应仍被拦截返 binary:true"); + assert_eq!(res["content"], serde_json::Value::Null, "真二进制 content 应为 null"); + + fs::remove_dir_all(&tmp).ok(); + } + // ============================================================ // patch_file 三模式共用底层测试(F-260617-01) // 纯函数 apply_line_range / resolve_anchor_to_lines,不依赖 fs/async/锁。 @@ -1921,7 +2197,7 @@ mod tests { .await .unwrap(); let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root())); - let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from("")); + let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db)); (db, registry) } diff --git a/src-tauri/src/commands/ai/tools/download_file.rs b/src-tauri/src/commands/ai/tools/download_file.rs new file mode 100644 index 0000000..59b0674 --- /dev/null +++ b/src-tauri/src/commands/ai/tools/download_file.rs @@ -0,0 +1,73 @@ +//! download_file AI 工具声明式注册(跨平台 URL → 文件流式下载)。 +//! +//! 工具职责:GET 远程 URL → 流式写本地文件(reqwest bytes_stream + tokio::fs)。 +//! 替代 run_command 的 curl/wget/Invoke-WebRequest(踩 PowerShell alias/编码/跨平台陷阱)。 +//! +//! 风险:High(下载文件副作用:写磁盘 + 触发外发网络 + 来源不可信)。SSRF 防护复用 +//! commands/ai/http.rs(validate_url / resolve_and_check_host / build_client / +//! execute_with_redirects,经 pub(crate) 暴露)。路径校验复用 tool_registry 的 +//! validate_path + resolve_workspace_path_with_allowed(与 write_file 同款,授权目录白名单)。 +//! +//! handler 在 commands/ai/download_file.rs::execute_download_file,声明式注册收敛样板。 +//! 闭包捕获 allowed_dirs:Arc>(download_file 写文件需路径授权,与 write_file 同源), +//! 在闭包内解析 output_path(白名单快照 + symlink 逃逸拦截),把已解析的绝对路径字符串传给 handler。 +//! +//! ## 注册位置说明 +//! +//! download_file 既涉网络(SSRF)又涉文件(路径校验落盘),按「副作用类型」归类: +//! 写文件(授权目录白名单)是核心安全约束,与 write_file/patch_file 同源(均持 allowed_dirs), +//! 故在 register_file_tools 注册(tool_registry.rs),而非 register_http_tools(后者不传 allowed_dirs)。 + +use std::sync::Arc; + +use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; +use df_ai::declare_tool; +use tokio::sync::RwLock; + +use crate::commands::ai::tool_registry::resolve_workspace_path_with_allowed; +use crate::state::AllowedDirs; + +/// 注册 download_file 工具到 `$registry`。 +/// +/// - name/desc/schema 面向 LLM 的工具说明 +/// - risk=High(写磁盘 + 外发网络 + 来源不可信) +/// - handler 捕获 allowed_dirs,解析 output_path 后转调 download_file.rs::execute_download_file +/// (SSRF 防护 + 流式写 + 大小上限 + 原子写全在那) +pub fn register( + registry: &mut AiToolRegistry, + allowed_dirs: &Arc>, +) { + // schema:url(必填)+ output_path(必填,落盘路径)+ timeout_secs(可选)。 + let schema = { + let mut props = serde_json::Map::new(); + props.insert("url".into(), serde_json::json!({ "type": "string", "description": "要下载的文件 URL,仅 http/https(拒私网/localhost,SSRF 防护含 DNS resolve 后校验防重绑定,重定向≤3 跳每跳重校验)" })); + props.insert("output_path".into(), serde_json::json!({ "type": "string", "description": "落盘文件路径(相对路径锚定到授权项目根,绝对路径须在授权目录内)。父目录不存在自动创建,覆盖既有同名文件。拒路径遍历(..)与敏感系统目录" })); + props.insert("timeout_secs".into(), serde_json::json!({ "type": "integer", "description": "超时秒数(默认 60,上限 600)", "minimum": 1, "maximum": 600 })); + serde_json::json!({ + "type": "object", + "properties": props, + "required": ["url", "output_path"], + }) + }; + declare_tool!( + registry, + allowed_dirs: Arc>, + "download_file", + "下载远程 URL 文件到本地磁盘(跨平台流式,替代 curl/wget/Invoke-WebRequest 的 alias/编码/跨平台陷阱)。参数:url(http/https,output_path(落盘路径,须在授权目录内),timeout_secs(默认 60,上限 600)。安全:仅 http/https + 拒私网/保留 IP(SSRF 防护)+ 路径白名单(拒遍历/越界)+ 200MB 大小上限(超限中止删半成品)+ 原子写(tmp→rename)。返回 {path, bytes_written, status, url, elapsed_ms}。下载二进制/大文件优先用此工具(不进 prompt)", + RiskLevel::High, + schema: schema, + args => { + // 路径解析:取 allowed_dirs 快照,解析 output_path(白名单 + symlink 逃逸拦截)。 + // 与 write_file 同款:相对路径锚定首个持久授权目录,绝对路径走白名单校验。 + let snap = allowed_dirs.read().await.clone(); + let output_path_raw = args["output_path"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 output_path 参数"))?; + let resolved = resolve_workspace_path_with_allowed(output_path_raw, &snap)?; + let resolved_str = resolved.to_str() + .ok_or_else(|| anyhow::anyhow!("output_path 含非法字符"))? + .to_string(); + // 转调 download_file.rs handler(SSRF 防护 + 流式写 + 大小上限 + 原子写全在那) + crate::commands::ai::download_file::execute_download_file(args, resolved_str).await + } + ); +} diff --git a/src-tauri/src/commands/ai/tools/fetch_search.rs b/src-tauri/src/commands/ai/tools/fetch_search.rs new file mode 100644 index 0000000..e53db24 --- /dev/null +++ b/src-tauri/src/commands/ai/tools/fetch_search.rs @@ -0,0 +1,51 @@ +//! fetch_search AI 工具声明式注册(搜索引擎查询,DuckDuckGo HTML 免 key)。 +//! +//! 工具职责:GET DuckDuckGo HTML 端点 → 解析结果列表(标题+URL+摘要)→ 截断 max_results +//! → 返回 {query, results, count}。补「fetch_url 只抓已知 URL,无搜索能力」缺口: +//! 用户给"调研 X"时,LLM 需先搜索找文档入口,再 fetch_url 深读。 +//! +//! 风险:Low(只读 GET 搜索,无副作用,与 fetch_url 同级)。SSRF 防护复用 commands/ai/http.rs +//! (validate_url / resolve_and_check_host / build_client / execute_with_redirects)。 +//! DDG HTML 免 API key(html.duckduckgo.com/html/),返回结果列表 HTML,handler 解析提取。 +//! +//! handler 在 commands/ai/fetch_search.rs::execute_fetch_search,声明式注册收敛样板。 +//! 无 db 捕获,纯网络 GET + HTML 字符串扫描解析(对齐 fetch_url::extract_title 风格, +//! 不引 scraper 重依赖 — html5ever+selectors+cssparser 编译产物大,DDG 单一来源不值)。 + +use std::sync::Arc; + +use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; +use df_ai::declare_tool; + +/// 注册 fetch_search 工具到 `$registry`(无 db 捕获,纯网络 GET + HTML 解析)。 +/// +/// - name/desc/schema 面向 LLM 的工具说明 +/// - risk=Low(只读 GET 搜索,无副作用,与 fetch_url 同级) +/// - handler 转调 fetch_search.rs::execute_fetch_search(SSRF 防护 + DDG GET + HTML 解析全在那) +pub fn register(registry: &mut AiToolRegistry) { + // 无捕获:占位 Arc<()>(handler 不持 db,仅转调 fetch_search.rs)。 + let dummy: Arc<()> = Arc::new(()); + // schema:query(必填)+ max_results(可选,默认 5,clamp [1,10])。 + let schema = { + let mut props = serde_json::Map::new(); + props.insert("query".into(), serde_json::json!({ "type": "string", "description": "搜索关键词(必填)。返回 DuckDuckGo HTML 结果列表(标题+URL+摘要),供挑选入口用 fetch_url 深读" })); + props.insert("max_results".into(), serde_json::json!({ "type": "integer", "description": "返回结果数(默认 5,clamp [1, 10])", "minimum": 1, "maximum": 10, "default": 5 })); + serde_json::json!({ + "type": "object", + "properties": props, + "required": ["query"], + }) + }; + declare_tool!( + registry, + dummy: Arc<()>, + "fetch_search", + "搜索引擎查询(免 API key),返回 DuckDuckGo 结果列表(标题+URL+摘要)。补「fetch_url 只抓已知 URL」缺口:用户给「调研 X」时,先用本工具找文档入口,再 fetch_url 深读。参数:query(必填,搜索关键词)、max_results(默认 5,clamp [1,10])。安全:只读 GET,SSRF 防护复用 fetch_url/http_request 同套(仅 http/https + 拒私网 + 重定向≤3 跳)。返回 {query, results:[{title,url,snippet}], count, status, elapsed_ms}。每条 url 已解包 DDG 重定向跳板(uddg 参数 percent-decode),可直接 fetch_url", + RiskLevel::Low, + schema: schema, + args => { + // 转调 fetch_search.rs handler(SSRF 防护 + DDG GET + HTML 解析 + 截断全在那) + crate::commands::ai::fetch_search::execute_fetch_search(args).await + } + ); +} diff --git a/src-tauri/src/commands/ai/tools/generate_image.rs b/src-tauri/src/commands/ai/tools/generate_image.rs new file mode 100644 index 0000000..80cc20a --- /dev/null +++ b/src-tauri/src/commands/ai/tools/generate_image.rs @@ -0,0 +1,85 @@ +//! generate_image AI 工具声明式注册(调 provider 图像生成端点 + 下载落地)。 +//! +//! 工具职责:调 provider 的 OpenAI 兼容 `/v1/images/generations` 端点生成图片, +//! 自动下载到工作区 output_path。默认 model=sensenova-u1-fast(SenseNova U1 信息图生成), +//! 也支持 DALL-E 等 OpenAI 兼容图像端点。 +//! +//! 风险:High(付费 API 按张计费 + 写磁盘 + 外发网络)。 +//! +//! 与 download_file 的分工:download_file 是「已知 URL → 落盘」,generate_image 是 +//! 「调 provider 生成 + 自动落盘」(封装 provider 凭证 + 端点拼接 + 响应解析 + 图片下载全链路)。 +//! 图像生成模型不是 chat 模型,走 /v1/images/generations 非 chat completions, +//! 故不能经主对话链路调用,必须用本工具显式触发。 +//! +//! handler 在 commands/ai/generate_image.rs::execute_generate_image,声明式注册收敛样板。 +//! 闭包捕获 db:Arc(选 provider + 解析凭证)+ allowed_dirs:Arc> +//! (output_path 白名单校验 + 默认路径锚定)。 +//! +//! ## 捕获变量说明(declare_tool! 单捕获限制) +//! +//! declare_tool! 宏只支持单捕获变量(见 crates/df-ai/src/ai_tools_decl.rs),本工具需 db + +//! allowed_dirs 两个,故包进 tuple Arc `(Arc, Arc>)` 在闭包内解构。 +//! 这是宏单捕获限制的标准绕法(零架构改动:不改宏、不改 process_tool_calls 签名)。 +//! +//! ## 注册位置说明 +//! +//! generate_image 涉付费 API(网络)+ 写文件(落盘 output_path),与 download_file 同源 +//! (均持 allowed_dirs),归 register_http_tools 同层(register_generate_image_tool 子函数, +//! 在 register_http_tools 之后调用)。 + +use std::sync::Arc; + +use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; +use df_ai::declare_tool; +use tokio::sync::RwLock; + +use crate::state::AllowedDirs; +use df_storage::db::Database; + +/// generate_image 捕获的 (db, allowed_dirs) tuple 类型(declare_tool! 单捕获绕法)。 +type GenerateImageCtx = (Arc, Arc>); + +/// 注册 generate_image 工具到 `$registry`。 +/// +/// - name/desc/schema 面向 LLM 的工具说明 +/// - risk=High(付费 API + 写磁盘 + 外发网络) +/// - handler 捕获 (db, allowed_dirs) tuple Arc,解构后转调 generate_image.rs::execute_generate_image +/// (provider 选择 + 凭证解析 + 端点拼接 + SSRF 防护下载 + 原子写全在那) +pub fn register( + registry: &mut AiToolRegistry, + db: &Arc, + allowed_dirs: &Arc>, +) { + // 捕获 tuple:declare_tool! 单捕获限制的绕法。 + let ctx: GenerateImageCtx = (db.clone(), allowed_dirs.clone()); + + // schema:prompt(必填)+ model(默认 sensenova-u1-fast)+ size(可选)+ n(默认 1) + // + provider_id(可选)+ output_path(可选)。 + let schema = { + let mut props = serde_json::Map::new(); + props.insert("prompt".into(), serde_json::json!({ "type": "string", "description": "图片描述(中英文均可),越具体生成质量越高。例:'一只戴墨镜的橘猫坐在键盘上,赛博朋克风格,霓虹光'" })); + props.insert("model".into(), serde_json::json!({ "type": "string", "description": "图像生成模型名。默认 sensenova-u1-fast(SenseNova U1 信息图生成,走 /v1/images/generations)。也支持 dall-e-3 等 OpenAI 兼容图像模型。不传用默认", "default": "sensenova-u1-fast" })); + props.insert("size".into(), serde_json::json!({ "type": "string", "description": "可选图片尺寸字符串,不传则让 provider 用默认。SenseNova U1 常用 2752x1536 / 1536x2752(横/竖海报),DALL-E 常用 1024x1024" })); + props.insert("n".into(), serde_json::json!({ "type": "integer", "description": "生成数量(默认 1,clamp [1,4])。多张时取首张 url/b64 落地", "minimum": 1, "maximum": 4, "default": 1 })); + props.insert("provider_id".into(), serde_json::json!({ "type": "string", "description": "可选,指定 provider(多 provider 时);不传则按 model 名自动匹配厂商 host(sensenova→SenseNova,dall-e→OpenAI,google/imagen→Google),匹配不到取首个 openai_compat provider" })); + props.insert("output_path".into(), serde_json::json!({ "type": "string", "description": "可选落盘路径(相对路径锚定到授权项目根,绝对路径须在授权目录内)。不传则默认 {首个持久授权目录}/generated_images/{model}-{时间戳}.png。父目录不存在自动创建,覆盖既有同名文件。注意:这是图像生成模型(非对话模型),通过本工具调用而非主对话链路" })); + serde_json::json!({ + "type": "object", + "properties": props, + "required": ["prompt"], + }) + }; + declare_tool!( + registry, + ctx: GenerateImageCtx, + "generate_image", + "调用图像生成模型(默认 SenseNova U1 Fast 信息图生成,也支持 DALL-E 等 OpenAI 兼容图像端点)生成图片并下载到工作区。参数:prompt(图片描述,必填),model(默认 sensenova-u1-fast),size(可选,SenseNova U1 常用 2752x1536/1536x2752,DALL-E 常用 1024x1024),n(默认 1,clamp [1,4]),provider_id(可选,多 provider 时指定,否则按 model 名自动匹配厂商),output_path(可选,默认 generated_images/{model}-{时间戳}.png)。注意:本工具调用图像生成模型(非对话模型),走 /v1/images/generations 端点。返回 {path, url(或 null), model, provider_id, bytes_written, elapsed_ms}。风险:付费 API 按张计费 + 写磁盘", + RiskLevel::High, + schema: schema, + args => { + // 解构 tuple 捕获:db 选 provider + 解析凭证,allowed_dirs 解析 output_path 白名单 + let (db, allowed_dirs) = ctx; + crate::commands::ai::generate_image::execute_generate_image(args, &db, &allowed_dirs).await + } + ); +} diff --git a/src-tauri/src/commands/ai/tools/get_app_config.rs b/src-tauri/src/commands/ai/tools/get_app_config.rs new file mode 100644 index 0000000..6327719 --- /dev/null +++ b/src-tauri/src/commands/ai/tools/get_app_config.rs @@ -0,0 +1,46 @@ +//! get_app_config AI 工具声明式注册(查 DevFlow 自身当前 AI 配置,只读 + 脱敏)。 +//! +//! 工具职责:返当前生效的 provider/default_provider/agent 配置/app_settings 概要 JSON, +//! 让 LLM 一调即得「现在用什么 provider/model/迭代上限」,根治 run_command PowerShell +//! 内联 node/python 脚本查 db(引号三重嵌套必失败)的绕行。 +//! +//! 风险:Low(纯只读 list/load,无写副作用)。安全:api_key 经 mask_api_key 脱敏 +//! (前 4 + `••••` + 后 4),绝不返明文;app_settings 走白名单不返敏感 KV。 +//! +//! handler body 在 commands/ai/get_app_config.rs::execute_get_app_config, +//! 声明式注册收敛样板(对齐 fetch_url/generate_image 模式)。 +//! +//! ## 捕获变量说明 +//! +//! declare_tool! 宏单捕获限制:本工具需 db + 3 个 agent 配置 Arc + LlmConcurrency 共 5 项, +//! 包进 [`GetAppConfigCtx`] struct(各字段 Arc/Clone-廉价),闭包内不解构直接 +//! `&ctx` 转调 handler。ctx 字段与 AppState 同名字段共享同一底层原子量(热改即反映)。 + +use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; +use df_ai::declare_tool; + +use crate::commands::ai::get_app_config::GetAppConfigCtx; + +/// 注册 get_app_config 工具到 `$registry`。 +/// +/// - name/desc/schema 面向 LLM 的工具说明(无参数,返全部当前配置) +/// - risk=Low(纯只读) +/// - handler 捕获 GetAppConfigCtx(共享 AppState 同名 Arc 字段),转调 get_app_config.rs +pub fn register(registry: &mut AiToolRegistry, ctx: GetAppConfigCtx) { + // schema:无参数(object_schema 空参返仅 type:object 无 required)。 + // 不加可选 section 参数(provider/agent/settings 选返):返全部配置最简,LLM 自行读所需字段; + // 加 section 反增参数解析分支 + schema 复杂度,违背「简洁」约束。 + let schema = df_ai::ai_tools::object_schema(vec![]); + declare_tool!( + registry, + ctx: GetAppConfigCtx, + "get_app_config", + "获取 DevFlow 自身当前生效的 AI 配置(只读,无参数)。返回 {default_provider, providers, agent, app_settings}:default_provider 含 name/provider_type/base_url/default_model/is_default/enabled/api_key_masked(api_key 脱敏,空串=未配置 key);providers 是全部 provider 概要列表(无 api_key);agent 含 max_iterations/max_retries/approval_timeout_minutes/concurrency.per_conv(当前热改生效值);app_settings 含知识库/审批超时等 AI 相关 KV(custom_prompt 只返长度不返原文)。查「现在用什么 provider/model」「agent 迭代上限多少」「key 是否配置」用本工具,不要 run_command 执行 PowerShell 内联脚本查 db(引号嵌套易失败)。", + RiskLevel::Low, + schema: schema, + _args => { + // 转调 get_app_config.rs handler(providers/settings/agent 配置读取 + 脱敏全在那) + crate::commands::ai::get_app_config::execute_get_app_config(&ctx).await + } + ); +} diff --git a/src-tauri/src/commands/ai/tools/http.rs b/src-tauri/src/commands/ai/tools/http.rs index c22cac9..a1e8546 100644 --- a/src-tauri/src/commands/ai/tools/http.rs +++ b/src-tauri/src/commands/ai/tools/http.rs @@ -2,16 +2,22 @@ //! //! 迁自 `tool_registry.rs::register_http_tools`(原 1 个 http_request),改用 `declare_tool!` 宏。 //! -//! 迁移策略(handler 逻辑零变更): -//! - handler body 逐字照搬原 async move 块(转调 `crate::commands::ai::http::execute_http_request`), -//! 仅闭包包装改由 `declare_tool!` 宏生成。 +//! 迁移策略(原 handler 逻辑零变更,2026-08-01 增 output_file/max_response_chars + allowed_dirs 捕获): +//! - handler body 转调 `crate::commands::ai::http::execute_http_request`,闭包包装由 `declare_tool!` 宏生成。 +//! output_file 参数引入后,handler 内增 `allowed_dirs.read().await.clone()` 快照传入(路径校验用, +//! 无 output_file 时校验不触发,行为零变更)。 //! - name/desc/schema/risk 与原手写定义逐字一致(headers 是对象 map,object_schema 仅支持扁平 -//! 标量三元组,故保留原手工拼 serde_json::Map schema 表达式)。 -//! - 无捕获:原 handler 不 clone db(handler 仅转调 http.rs),用占位 `Arc<()>` 捕获(宏要求 capture 形参)。 +//! 标量三元组,故保留原手工拼 serde_json::Map schema 表达式);新增 output_file/max_response_chars 两 prop。 +//! - 捕获:handler 不持 db(纯转调 http.rs),但需 allowed_dirs 做 output_file 路径校验, +//! 故捕获 `Arc>`(宏要求 capture 形参)。 //! //! 风险:GET=Medium(只读但触发外发)/ 写方法=High(须人工批准),一个工具名两种 risk 不支持(register //! 单一 risk_level),故按最高风险 High 注册(写方法 High 兜底;GET 也走 High 审批更保守)。 //! +//! 2026-08-01 `output_file` 参数:响应可落盘(全量写不截断),路径走文件路径校验 +//! (validate_path + resolve_workspace_path_with_allowed + parent 授权,与 write_file 同源), +//! 故 register 增 `allowed_dirs` 捕获,转调 execute_http_request 时读快照传入。 +//! //! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 + //! 工具名集合稳定(防 rename / 漏注册)。 @@ -19,18 +25,21 @@ use std::sync::Arc; use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; use df_ai::declare_tool; +use tokio::sync::RwLock; -/// 注册 http_request 工具到 `$registry`(无 db 捕获,纯 reqwest 调用)。 +use crate::state::AllowedDirs; + +/// 注册 http_request 工具到 `$registry`(无 db 捕获,但捕获 allowed_dirs 供 output_file 路径校验)。 /// -/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1: -/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义 +/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1(2026-08-01 增 output_file / +/// max_response_chars 两参数 + allowed_dirs 捕获): +/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义(新增两 prop) /// - risk=High(写方法兜底,GET 同走审批更保守) -/// - handler body 与原 async move 块逐字一致(逻辑零变更) +/// - handler body 与原 async move 块逐字一致(逻辑零变更),新增 allowed_dirs read lock clone +/// 传入 execute_http_request(output_file 路径校验用,无 output_file 时不触发) /// /// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。 -pub fn register(registry: &mut AiToolRegistry) { - // 无捕获:占位 Arc<()>(原 handler 不持 db,仅转调 http.rs)。 - let dummy: Arc<()> = Arc::new(()); +pub fn register(registry: &mut AiToolRegistry, allowed_dirs: &Arc>) { // schema:headers 是对象 map,object_schema 仅支持扁平标量三元组,故手工拼 serde_json::Map。 // 提取为 let 绑定:declare_tool! 的 schema: $schema:expr 形参对花括号块表达式解析有歧义, // 先求值到局部变量再传入,语义等价(原手写 register 亦以此 Map 作为 schema 实参)。 @@ -42,6 +51,8 @@ pub fn register(registry: &mut AiToolRegistry) { props.insert("body".into(), serde_json::json!({ "type": "string", "description": "请求体(POST/PUT/PATCH 用),原样发送,Content-Type 须在 headers 显式指定" })); props.insert("timeout_secs".into(), serde_json::json!({ "type": "integer", "description": "超时秒数(默认 30,上限 60)", "minimum": 1, "maximum": 60 })); props.insert("parse".into(), serde_json::json!({ "type": "string", "description": "响应 body 解析:json(pretty 格式化)/text(原样)/auto(按 Content-Type 自动,默认)", "enum": ["json", "text", "auto"] })); + props.insert("output_file".into(), serde_json::json!({ "type": "string", "description": "响应 body 写入指定文件路径(而非返回 JSON body),适合大响应。路径走文件路径校验 + 授权目录,全量写不截断。提供时返回 body=null + path/bytes_written/truncated=false" })); + props.insert("max_response_chars".into(), serde_json::json!({ "type": "integer", "description": "body 截断阈值(字节,默认 51200/50KB,clamp [1024, 10485760])。替代默认截断上限;有 output_file 时不截断", "minimum": 1024, "maximum": 10485760 })); serde_json::json!({ "type": "object", "properties": props, @@ -50,14 +61,16 @@ pub fn register(registry: &mut AiToolRegistry) { }; declare_tool!( registry, - dummy: Arc<()>, + allowed_dirs: Arc>, "http_request", - "发起结构化 HTTP 请求(GET/POST/PUT/PATCH/DELETE),用于查询外部 API。参数:method(默认 GET)、url(http/https)、headers(对象 map)、body(请求体字符串)、timeout_secs(默认 30,上限 60)、parse(json/text/auto,默认 auto)。安全:仅 http/https 协议,拒绝私网/保留 IP(SSRF 防护含 DNS resolve 后校验防重绑定),重定向≤3 跳且每跳重校验。响应 body 截断 50KB。GET 为只读但触发外发网络,POST/PUT/PATCH/DELETE 有副作用,统一按高风险须人工批准", + "发起结构化 HTTP 请求(GET/POST/PUT/PATCH/DELETE),用于查询外部 API。参数:method(默认 GET)、url(http/https)、headers(对象 map)、body(请求体字符串)、timeout_secs(默认 30,上限 60)、parse(json/text/auto,默认 auto)、output_file(响应落盘路径,适合大响应,全量写不截断)、max_response_chars(body 截断阈值字节,默认 50KB,clamp [1KB,10MB])。安全:仅 http/https 协议,拒绝私网/保留 IP(SSRF 防护含 DNS resolve 后校验防重绑定),重定向≤3 跳且每跳重校验。响应 body 默认截断 50KB(可 max_response_chars 覆盖或 output_file 落盘)。GET 为只读但触发外发网络,POST/PUT/PATCH/DELETE 有副作用,统一按高风险须人工批准", RiskLevel::High, schema: schema, args => { - // 转调 http.rs handler(SSRF 防护 + 重定向 + 截断全在那) - crate::commands::ai::http::execute_http_request(args).await + // allowed_dirs read lock clone(output_file 路径校验用;无 output_file 时校验不触发) + let snap = allowed_dirs.read().await.clone(); + // 转调 http.rs handler(SSRF 防护 + 重定向 + 截断 + output_file 落盘全在那) + crate::commands::ai::http::execute_http_request(args, &snap).await } ); } diff --git a/src-tauri/src/commands/ai/tools/mod.rs b/src-tauri/src/commands/ai/tools/mod.rs index 20c7e48..309819c 100644 --- a/src-tauri/src/commands/ai/tools/mod.rs +++ b/src-tauri/src/commands/ai/tools/mod.rs @@ -15,7 +15,11 @@ pub mod task_graph; pub mod git; pub mod http; pub mod fetch_url; +pub mod fetch_search; +pub mod generate_image; +pub mod get_app_config; pub mod workflow; pub mod idea; pub mod trash; pub mod file; +pub mod download_file; diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 11ab296..487e680 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -233,8 +233,28 @@ impl AppState { // 文件工具闭包捕获后 resolve_workspace_path_with_allowed 校验动态白名单。 // 此处用 default_with_root 占位,下方 init 尾部 reload_allowed_dirs 从 Settings KV 覆盖。 let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default())); + + // get_app_config(2026-08-02):agent 配置 Arc + LlmConcurrency 先建,供 build_ai_tool_registry + // 注入 GetAppConfigCtx(查 DevFlow 自身配置工具需读这些内存原子量,绕 AppState 自引用)。 + // 这些 Arc 不依赖 db,故可提前到 build_ai_tool_registry 之前;下方 state struct 字段复用 + // 同一 Arc(共享底层原子量,IPC 热改后 get_app_config 工具读到的即当前生效值)。 + let llm_concurrency = LlmConcurrency::new(3, 2); + let agent_max_iterations = Arc::new(AtomicUsize::new( + crate::commands::ai::agentic::DEFAULT_MAX_AGENT_ITERATIONS, + )); + let agent_max_retries = Arc::new(AtomicUsize::new( + crate::commands::ai::agentic::DEFAULT_MAX_AGENT_RETRIES, + )); + let approval_timeout_minutes = Arc::new(AtomicU64::new(15)); + let get_app_config_ctx = crate::commands::ai::get_app_config::GetAppConfigCtx { + db: db.clone(), + agent_max_iterations: agent_max_iterations.clone(), + agent_max_retries: agent_max_retries.clone(), + approval_timeout_minutes: approval_timeout_minutes.clone(), + llm_concurrency: llm_concurrency.clone(), + }; let ai_tools = Arc::new(crate::commands::ai::build_ai_tool_registry( - &db, &allowed_dirs, data_dir.clone(), + &db, &allowed_dirs, data_dir.clone(), get_app_config_ctx, )); // Input Augmentation 层(核心设计2):ResolverRegistry 启动期注册四 resolver。 // resolver 持 Arc(非 AppState,避免循环依赖:AppState 持 Arc), @@ -272,14 +292,11 @@ impl AppState { knowledge_config: Arc::new(Mutex::new(KnowledgeConfig::default())), data_dir: data_dir.clone(), settings: SettingsRepo::new(&db), - llm_concurrency: LlmConcurrency::new(3, 2), - agent_max_iterations: Arc::new(AtomicUsize::new( - crate::commands::ai::agentic::DEFAULT_MAX_AGENT_ITERATIONS, - )), - agent_max_retries: Arc::new(AtomicUsize::new( - crate::commands::ai::agentic::DEFAULT_MAX_AGENT_RETRIES, - )), - approval_timeout_minutes: Arc::new(AtomicU64::new(15)), + // 复用上方提前构造的句柄(与 get_app_config 工具共享同一底层原子量/Semaphore)。 + llm_concurrency, + agent_max_iterations, + agent_max_retries, + approval_timeout_minutes, workflow_state_registry: Arc::new(Mutex::new(HashMap::new())), // F-260619-03 Phase A: 与 ai_tools registry 共享同一 Arc(构建时注入同一句柄) allowed_dirs: allowed_dirs.clone(), diff --git a/src-tauri/src/state/llm_concurrency.rs b/src-tauri/src/state/llm_concurrency.rs index 3e25eac..51514dc 100644 --- a/src-tauri/src/state/llm_concurrency.rs +++ b/src-tauri/src/state/llm_concurrency.rs @@ -174,6 +174,15 @@ impl LlmConcurrency { self.per_conv.lock().await.clear(); } + /// 读当前 per_conv permits 生效值(对齐 set_per_conv 的写,补足读侧 getter)。 + /// + /// 用途:get_app_config AI 工具查「当前 LLM 并发配置」时返给 LLM(global 无对称 getter: + /// Semaphore permits 封装在 Arc>> 内层无读取接口,故此处只返 per_conv, + /// global 不返避免误导)。热改(set_per_conv)后立即反映,无需重启。 + pub fn current_per_conv_permits(&self) -> usize { + self.per_conv_permits.load(std::sync::atomic::Ordering::SeqCst) + } + // ============================================================ // Phase3 预留(批2-B): per_sub_flow 层 — 占位未接入调用点 // ============================================================