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

download_file 跨平台 URL 到文件流式下载;http_request output_file 落盘 + 截断可配;
grep context_chars 大单行窗口;fetch_search DuckDuckGo 免 key 搜索;get_app_config
只读返当前配置(治 AI 查配置绕 PowerShell);generate_image SenseNova 图像生成。
This commit is contained in:
lxy
2026-08-02 02:21:32 +08:00
parent e4f7b432aa
commit b999f79fdf
17 changed files with 2862 additions and 56 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ keyring = { workspace = true }
# 复用 df-ai 同款 reqwest 0.12(同版本锁定,避免双 TLS 后端)。 # 复用 df-ai 同款 reqwest 0.12(同版本锁定,避免双 TLS 后端)。
# 使用 rustls-tls(非 native-tls),避免 Windows SChannel 同步阻塞 tokio 工作线程 # 使用 rustls-tls(非 native-tls),避免 Windows SChannel 同步阻塞 tokio 工作线程
# 致 BUG-2026-07-17(aichat 流式调用永久 hang,外层 tokio timeout 亦无法推进计时器)。 # 致 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 → 去噪音 + 截断)。 # fetch_url AI 工具:URL → markdown 文档嗅探(GET HTML → htmd 转 markdown → 去噪音 + 截断)。
# 替代 http_request 拿原始 HTML(噪声大、爆 token)。turndown.js 移植,只读 GET,与 http_request 共享 SSRF 防护。 # 替代 http_request 拿原始 HTML(噪声大、爆 token)。turndown.js 移植,只读 GET,与 http_request 共享 SSRF 防护。
htmd = "0.5" htmd = "0.5"
@@ -37,8 +37,11 @@ use super::super::secret::{
// 提供商管理 // 提供商管理
// ============================================================ // ============================================================
/// api_key 脱敏:IPC 不传明文给前端(FR-S1),保留首尾各 4 字符便于辨识 /// api_key 脱敏:IPC 不传明文给前端(FR-S1),保留首尾各 4 字符便于辨识
fn mask_api_key(key: &str) -> String { ///
/// `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<char> = key.chars().collect(); let chars: Vec<char> = key.chars().collect();
if chars.len() <= 8 { if chars.len() <= 8 {
return "".repeat(chars.len()); return "".repeat(chars.len());
+308
View File
@@ -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<Value> {
// ── 参数解析 ──
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;
}
}
+781
View File
@@ -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=<percent-encoded real url>&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<Value> {
// ── 参数解析 ──
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=<encoded> ──
// DDG HTML 端点接受 GET ?q=<url-encoded query>。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<String, String> = 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
/// <div class="result ...">
/// <h2 class="result__title">
/// <a rel="nofollow" class="result__a" href="https://duckduckgo.com/l/?uddg=<encoded>&rut=...">Title</a>
/// </h2>
/// <a class="result__snippet" href="...">Snippet text...</a>
/// </div>
/// ```
///
/// 解析策略:扫描所有 `class="result__a"` 锚点 → 取 href + 内联文本;同序找
/// `class="result__snippet"` → 取摘要。两者各扫一遍再按出现顺序对齐(DDG 标题与摘要
/// 在每条结果内同序出现,故按位置一一对应;若数量不等则按较少者对齐)。
///
/// URL 解包:DDG 把真实 URL 包在 `uddg=<percent-encoded>` 查询参数(跳板重定向),
/// 此处还原(percent-decode)让 LLM 拿到可直 fetch_url 的目标 URL。
fn extract_results(html: &str) -> Vec<Value> {
let titles_with_url: Vec<(String, String)> = extract_result_anchors(html);
let snippets: Vec<String> = extract_snippets(html);
let n = titles_with_url.len().max(snippets.len());
let mut results: Vec<Value> = 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 偶有 `<a rel="nofollow" class="result__a" ...>` 或
/// `<a class="result__a" ...>`),扫 `class="result__a"` 子串命中锚点起点,再从该点向后
/// 找最近的 `<a ...>` 起标签尾(`>`),取 href 值 + 标签内文本(到 `</a>`)。
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 位置向前找最近的 `<a ` 起点(class 属性必在 <a ...> 标签内)
// 倒扫到 class_pos 之前的最后一个 `<a `(`<a` 后接空白表示开标签)
let head = &html[..class_pos];
let Some(a_open_rel) = head.rfind("<a ") else {
search_from = after_marker;
continue;
};
// 从 a 开标签起点向后找标签尾 `>`
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]; // 含 `<a ...>`
// 提取 href 值(href="..." 或 href='...')
let href = extract_attr(a_open_tag, "href").unwrap_or_default();
// 取标签内文本(从 `>` 后到 `</a>`)
let text_start = a_open_end + 1;
let text_end = html[text_start..]
.find("</a>")
.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 + "</a>".len();
}
out
}
/// 提取所有 `class="result__snippet"` 元素的内联文本,保持文档顺序。
///
/// DDG snippet 用 `<a class="result__snippet" ...>text</a>`(同时也是链接)。
/// 扫 `result__snippet` 子串 → 向前找 `<a ` → 向后找 `>` → 取到 `</a>` 间文本。
/// 与 extract_result_anchors 同款逻辑,仅 marker 不同。
fn extract_snippets(html: &str) -> Vec<String> {
const MARKER: &str = "result__snippet";
let mut out: Vec<String> = 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("<a ") else {
search_from = class_pos + MARKER.len();
continue;
};
let a_open_end_offset = match html[a_open_rel..].find('>') {
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("</a>")
.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 + "</a>".len();
}
out
}
/// 从 `<a href="..." ...>` 等开标签中提取指定属性值。
///
/// 支持 `attr="..."` / `attr='...'`(单双引号),不支持无引号属性值(DDG href 总是带引号)。
/// 值内的 HTML entity 不解码(简单场景 DDG href 不含 entity;若需可后续加 decode_html_entities)。
///
/// 已知局限:不做属性单词边界检查,故 `data-href="..."` 会被 `attr="href"` 匹配命中
/// (因 "href=" 是 "data-href=" 的子串)。真实 DDG `<a>` 不同时带 data-href 与 href,
/// 实务无影响;若未来需精确边界,改用前一个字符非字母数字的边界检查。
fn extract_attr(open_tag: &str, attr: &str) -> Option<String> {
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 内联文本:剥离嵌套标签(如 `<b>highlight</b>`)+ 折叠空白。
///
/// DDG 标题/snippet 内常有 `<b>` 高亮匹配关键词,需剥离得到纯文本。
/// 不解码 entity(&amp; 等),简单场景够用;后续如见 entity 可扩展。
fn clean_html_text(raw: &str) -> String {
// 剥离所有 `<...>` 子串(嵌套标签如 <b>word</b> → 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::<Vec<_>>().join(" ");
cleaned
}
/// 解包 DDG 重定向跳板 URL 还原真实目标 URL。
///
/// DDG 把结果 URL 包成 `https://duckduckgo.com/l/?uddg=<percent-encoded real url>&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#"<html><body>
<div class="results">
<div class="result results_links results_links_deep web-result">
<h2 class="result__title">
<a rel="nofollow" class="result__a" href="https://duckduckgo.com/l/?uddg=https%3A%2F%2Fdoc.rust-lang.org%2Fbook%2F&rut=abc">The Rust Programming Language <b>book</b></a>
</h2>
<a class="result__snippet" href="https://duckduckgo.com/l/?uddg=https%3A%2F%2Fdoc.rust-lang.org%2Fbook%2F">An introductory book about Rust covering syntax, ownership and <b>async</b>.</a>
</div>
<div class="result results_links results_links_deep web-result">
<h2 class="result__title">
<a rel="nofollow" class="result__a" href="https://duckduckgo.com/l/?uddg=https%3A%2F%2Ftokio.rs%2Ftutorial%2F&rut=def">Tokio Tutorial - Asynchronous Rust</a>
</h2>
<a class="result__snippet" href="https://duckduckgo.com/l/?uddg=https%3A%2F%2Ftokio.rs%2Ftutorial%2F">Learn how to write async Rust applications with the Tokio runtime.</a>
</div>
</div>
</body></html>"#;
// ── 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 标题内常有 <b> 高亮匹配关键词,应剥离为纯文本
let html = r#"<a class="result__a" href="https://duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2F">Hello <b>World</b></a>"#;
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 class="result__a" href="https://duckduckgo.com/l/?uddg=https%3A%2F%2Fa.com%2F">A</a>
<a class="result__snippet" href="x">snip A</a>
<a class="result__a" href="https://duckduckgo.com/l/?uddg=https%3A%2F%2Fb.com%2F">B</a>
<a class="result__snippet" href="x">snip B</a>
<a class="result__a" href="https://duckduckgo.com/l/?uddg=https%3A%2F%2Fc.com%2F">C</a>
<a class="result__snippet" href="x">snip C</a>
"#;
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#"<a href="https://example.com/" class="x">"#;
assert_eq!(extract_attr(tag, "href").as_deref(), Some("https://example.com/"));
}
#[test]
fn test_extract_attr_single_quote() {
let tag = r#"<a href='https://example.com/'>"#;
assert_eq!(extract_attr(tag, "href").as_deref(), Some("https://example.com/"));
}
#[test]
fn test_extract_attr_missing_returns_none() {
let tag = r#"<a class="x">"#;
assert_eq!(extract_attr(tag, "href"), None);
}
#[test]
fn test_extract_attr_known_limitation_data_href() {
// 已知局限记录:extract_attr 不做单词边界检查,"data-href=" 含 "href=" 子串会被命中。
// 真实 DDG `<a>` 不同时带 data-href 与 href,实务无影响。本测试锁定当前行为供回归守护:
// 若未来加边界检查改为不命中,此测试会红,提醒同步更新文档注释。
let tag = r#"<a data-href="https://hidden.com/">"#;
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 <b>World</b>!"), "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("<b></b>"), "");
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#"
<a class="result__a" href="https://x.com/1">First</a>
<a class="result__a" href="https://x.com/2">Second</a>
<a class="result__a" href="https://x.com/3">Third</a>
"#;
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#"
<a class="result__a" href="https://x.com/1"> </a>
<a class="result__a" href="https://x.com/2">Valid</a>
"#;
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 真实顺序:`<a rel="nofollow" class="result__a" ...>`(rel 在 class 前)
let html = r#"<a rel="nofollow" class="result__a" href="https://x.com/">Title</a>"#;
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 = "<html><body>no results here</body></html>";
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#"
<a class="result__article" href="https://wrong.com/">Should Not Match</a>
<a class="result__a" href="https://duckduckgo.com/l/?uddg=https%3A%2F%2Fright.com%2F">Right Title</a>
"#;
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#"<a class="result__a extra" href="https://x.com/">Multi Class Title</a>"#;
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#"
<a class="result__snippet" href="x">First snippet</a>
<a class="result__snippet" href="y">Second snippet</a>
"#;
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#"<a class="result__snippet" href="x">Text with <b>highlight</b> word</a>"#;
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#"
<a class="result__a" href="https://duckduckgo.com/l/?uddg=https%3A%2F%2Fx.com%2F">Title Only</a>
"#;
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");
}
}
+587
View File
@@ -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<Database>,
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
) -> anyhow::Result<Value> {
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<AiProviderRecord> {
// 筛 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<u64> {
// 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"));
}
}
+383
View File
@@ -0,0 +1,383 @@
//! get_app_config AI 工具 — 查 DevFlow 自身当前生效的 AI 配置(只读 + 脱敏)。
//!
//! ## 治什么症状
//!
//! 实测 AI 想看「当前 provider/model/agent 设置」时,因没原生「查配置」工具,只能 run_command
//! 执行 PowerShell 内联 node/python 脚本查 db(node sqlite/...),命令行引号三重嵌套必失败
//! (PowerShell 单引号包 node -e 内含 JS 模板字符串/双引号/db 路径,转义层数爆炸,复盘实证)。
//! 本工具直接返当前配置 JSON,LLM 一调即得,根治 run_command 绕行。
//!
//! ## 安全(只读 + 脱敏)
//!
//! - **只读**:纯 list_all/load 内存值,无任何写库/写文件副作用。RiskLevel::Low。
//! - **api_key 脱敏**:复用 [`mask_api_key`](crate::commands::ai::commands::mask_api_key)
//! (前 4 + `••••` + 后 4),迁移后 DB api_key 空列 → 经 keyring 解析真实 key 再脱敏,
//! 绝不向 LLM 回灌明文 key(对齐 ai_list_providers IPC 脱敏口径,单点维护)。
//! - **app_settings 不返敏感 KV**:仅返与 AI 行为直接相关的已知无害 key
//! (knowledge/timeout/custom_prompt 存在性 + 长度),自定义指令原文不回灌防指令注入。
//!
//! ## 数据来源(全部经 GetAppConfigCtx 捕获句柄,无 AppState 自引用)
//!
//! - providers / default_provider:`AiProviderRepo::new(&ctx.db).list_all()` + keyring 解析
//! - agent:`ctx.agent_max_iterations` 等内存原子量当前 load 值(热改即反映)
//! - app_settings:`SettingsRepo::new(&ctx.db).get_all()` 后白名单筛选
//!
//! ## 为何不直接捕获 &AppState
//!
//! `build_ai_tool_registry` 在 `AppState::init` 内、AppState 构建中被调用(state.rs),
//! 此时无法传 `&AppState`(自引用 + 循环依赖,对齐 resolver 持 Arc<Database> 非 AppState 的
//! 既有设计)。故把所需 Arc 句柄(db + agent 配置原子量 + LlmConcurrency)打包成
//! [`GetAppConfigCtx`],state.rs init 先建这些 Arc(不依赖 db)再 build registry 注入。
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, AtomicUsize};
use serde_json::{json, Value};
use df_storage::crud::{AiProviderRepo, SettingsRepo};
use crate::state::LlmConcurrency;
/// 复用 IPC 层脱敏规则(单点维护,与 ai_list_providers 同口径)。
use crate::commands::ai::commands::mask_api_key;
/// keyring 异步解析(Tauri 单线程 runtime,同步 keyring 调用会卡)。
use crate::commands::ai::secret::get_provider_secret_async;
/// app_settings KV 白名单(仅这些 key 进 LLM 视图,其余敏感/无关 KV 一律不返)。
///
/// 选取标准:与 AI 行为直接相关 + 无敏感凭证内容。`custom_prompt` 只返长度不返原文
/// (用户可能写入含指令的私有提示,原文回灌进 LLM 上下文有指令注入风险 + 撑爆 prompt)。
const ALLOWED_SETTING_KEYS: &[&str] = &[
// 知识库行为配置(摘要/提炼开关,JSON 结构;值含开关不含凭证)
"df-knowledge-config",
// 审批超时(ms 数值,持久化版,与 AppState.approval_timeout_minutes 互补)
"df-approval-timeout",
];
/// get_app_config 工具注册期捕获的句柄集合(绕 AppState 自引用)。
///
/// 各字段均为 `Arc`(或内部全 Arc 的 Clone 廉价类型),clone 进工具闭包后与 AppState
/// 字段共享同一底层原子量/Semaphore,故 handler 读到的永远是「当前生效值」
/// (经 ai_set_concurrency_config / ai_set_agent_max_iterations 等 IPC 热改后立即反映)。
///
/// 字段对应 AppState 同名字段,state.rs init 先建这些 Arc 再组装 state(顺序调整,
/// 因 build_ai_tool_registry 在 AppState 构建中被调,无法传 &AppState)。
///
/// `Clone` 廉价(全 Arc 字段):declare_tool! 宏把 ctx clone 进工具闭包,每次工具执行
/// 再 clone 一份进 async move 块(宏展开语义,见 crates/df-ai/src/ai_tools_decl.rs)。
#[derive(Clone)]
pub struct GetAppConfigCtx {
/// 数据库句柄(读 ai_providers / app_settings)
pub db: Arc<df_storage::db::Database>,
/// Agentic 循环最大轮次(热改即生效)
pub agent_max_iterations: Arc<AtomicUsize>,
/// 流式对话失败自动重试次数(热改即生效)
pub agent_max_retries: Arc<AtomicUsize>,
/// 审批超时分钟数(热改即生效)
pub approval_timeout_minutes: Arc<AtomicU64>,
/// LLM 并发控制(内部全 Arc,Clone 廉价,共享同一组 Semaphore)
pub llm_concurrency: LlmConcurrency,
}
/// 单个 provider 的脱敏视图(无 api_key 字段,绝不向 LLM 暴露任何 key 痕迹)。
///
/// 列表用:返所有 provider 概要(name/type/url/model/是否默认/是否启用),AI 知道有哪些
/// provider 可用,不返 model_configs(详组能力配置,体积大且对「查当前配置」无必要)。
fn provider_summary(p: &df_storage::models::AiProviderRecord) -> Value {
json!({
"name": p.name,
"provider_type": p.provider_type,
"base_url": p.base_url,
"default_model": p.default_model,
"is_default": p.is_default,
"enabled": p.enabled,
})
}
/// 默认 provider 的扩展视图(含 api_key_masked,供 AI 判断 key 是否配置/有效)。
///
/// 仅默认这一条返脱敏 key(其他 provider 列表项不带 key 字段),平衡「AI 需知道 key 在不在」
/// 与「最小披露」。空 key → 空串(明确告知 key 缺失,而非误导性地返 mask 占位)。
fn default_provider_view(
p: &df_storage::models::AiProviderRecord,
real_key: &str,
) -> Value {
let mut view = provider_summary(p);
// 作为对象才能插入字段(provider_summary 返的是 Object)。
if let Some(obj) = view.as_object_mut() {
let masked = if real_key.is_empty() {
String::new()
} else {
mask_api_key(real_key)
};
obj.insert("api_key_masked".into(), json!(masked));
}
view
}
/// get_app_config 工具 handler 入口。
///
/// 返回结构:
/// ```jsonc
/// {
/// "default_provider": { name, provider_type, base_url, default_model, is_default, enabled, api_key_masked: "sk-l••••3Yab" } | null,
/// "providers": [{ name, provider_type, base_url, default_model, is_default, enabled }, ...],
/// "agent": { max_iterations, max_retries, approval_timeout_minutes, concurrency: { per_conv } },
/// "app_settings": { "<allowed_key>": "<value>", "custom_prompt_present": bool, "custom_prompt_length"?: number }
/// }
/// ```
///
/// 全程只读:`list_all` / `load` / `get_all`,无任何 mutate 调用,无 IO 副作用。
pub(crate) async fn execute_get_app_config(ctx: &GetAppConfigCtx) -> anyhow::Result<Value> {
let providers_repo = AiProviderRepo::new(&ctx.db);
let settings_repo = SettingsRepo::new(&ctx.db);
// ── providers:全部列表 + 默认项(带脱敏 key)──
let providers = providers_repo
.list_all()
.await
.map_err(|e| anyhow::anyhow!("读 ai_providers 失败: {}", e))?;
// 默认 provider:取首条 is_default=true(list_all 已按 created_at DESC 排序,首条即最新)。
// 不做 is_default 收敛写库(只读工具,收敛是 ai_list_providers IPC 的职责),脏数据照原样读。
let default_idx = providers.iter().position(|p| p.is_default);
// 解析默认 provider 的真实 key(keyring 异步,仅这一条调 keyring,避免列表全量解析拖慢)。
let default_real_key = match default_idx {
Some(i) => {
let p = &providers[i];
if !p.api_key.is_empty() {
p.api_key.clone() // 未迁移(老明文)
} else {
get_provider_secret_async(p.id.clone())
.await
.unwrap_or_default() // 迁移后从 keyring
}
}
None => String::new(),
};
let default_provider = match default_idx {
Some(i) => json!(default_provider_view(&providers[i], &default_real_key)),
None => Value::Null,
};
// 列表:全部 provider 概要(无 key 字段)。
let providers_view: Vec<Value> = providers.iter().map(provider_summary).collect();
// ── agent:内存原子量当前生效值(热改即反映,无需重启)──
let max_iterations = ctx.agent_max_iterations.load(Ordering::SeqCst);
let max_retries = ctx.agent_max_retries.load(Ordering::SeqCst);
let approval_timeout_minutes = ctx.approval_timeout_minutes.load(Ordering::SeqCst);
// concurrency:per_conv permits 可读(AtomicUsize);global 无 getter(Semaphore permits
// 封装在 Arc<Mutex<Arc<Semaphore>>> 内层无读取接口),只返 per_conv 当前生效值。
// global 默认 3,经 ai_set_concurrency_config 热改后内存生效但不暴露读取,故此处省略
// 不返猜测值,避免误导 LLM(若 LLM 需 global 当前值,后续可在 LlmConcurrency 补 getter)。
let per_conv_permits = ctx.llm_concurrency.current_per_conv_permits();
let agent = json!({
"max_iterations": max_iterations,
"max_retries": max_retries,
"approval_timeout_minutes": approval_timeout_minutes,
"concurrency": {
"per_conv": per_conv_permits,
},
});
// ── app_settings:白名单筛选(防敏感 KV 泄露 + 防 prompt 噪音)──
let all_settings = settings_repo
.get_all()
.await
.map_err(|e| anyhow::anyhow!("读 app_settings 失败: {}", e))?;
let mut app_settings = serde_json::Map::new();
for (key, value) in all_settings {
if ALLOWED_SETTING_KEYS.contains(&key.as_str()) {
app_settings.insert(key, json!(value));
}
}
// custom_prompt 仅返长度 + 是否存在,不返原文(防指令注入 + 防撑 prompt)。
match settings_repo.get("custom_prompt").await {
Ok(Some(cp)) => {
let len = cp.chars().count();
app_settings.insert("custom_prompt_present".into(), json!(true));
app_settings.insert("custom_prompt_length".into(), json!(len));
}
_ => {
app_settings.insert("custom_prompt_present".into(), json!(false));
}
}
Ok(json!({
"default_provider": default_provider,
"providers": providers_view,
"agent": agent,
"app_settings": app_settings,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use df_storage::crud::AiProviderRepo;
use df_storage::db::Database;
use df_storage::models::AiProviderRecord;
/// 构造测试用 GetAppConfigCtx(默认值,handler 实际执行读真实 db 数据)。
fn make_ctx(db: Arc<Database>) -> GetAppConfigCtx {
GetAppConfigCtx {
db,
agent_max_iterations: Arc::new(AtomicUsize::new(10)),
agent_max_retries: Arc::new(AtomicUsize::new(3)),
approval_timeout_minutes: Arc::new(AtomicU64::new(15)),
llm_concurrency: LlmConcurrency::new(3, 2),
}
}
/// 插入一条测试 provider(老明文路径:DB api_key 非空,不触发 keyring,测试无 OS 副作用)。
async fn insert_provider(
db: &Arc<Database>,
id: &str,
name: &str,
api_key: &str,
is_default: bool,
enabled: bool,
) {
let repo = AiProviderRepo::new(db);
repo.insert(AiProviderRecord {
id: id.to_string(),
name: name.to_string(),
provider_type: "openai_compat".to_string(),
api_key: api_key.to_string(),
base_url: "https://api.example.com".to_string(),
default_model: "glm-4-flash".to_string(),
models: None,
model_configs: Vec::new(),
is_default,
config: None,
created_at: "0".to_string(),
updated_at: "0".to_string(),
enabled,
weight: 50,
})
.await
.expect("insert provider");
}
/// 返回结构完整:四顶层键齐全 + providers 是数组。
#[tokio::test]
async fn returns_full_structure() {
let db = Arc::new(Database::open_in_memory().await.unwrap());
insert_provider(&db, "p1", "默认", "sk-abcdef123456", true, true).await;
let ctx = make_ctx(db);
let res = execute_get_app_config(&ctx).await.unwrap();
// 四顶层键齐全
assert!(res.get("default_provider").is_some(), "缺 default_provider");
assert!(res.get("providers").is_some(), "缺 providers");
assert!(res.get("agent").is_some(), "缺 agent");
assert!(res.get("app_settings").is_some(), "缺 app_settings");
// providers 是数组
assert!(res["providers"].is_array(), "providers 应为数组");
// agent 含四字段
assert_eq!(res["agent"]["max_iterations"], 10);
assert_eq!(res["agent"]["max_retries"], 3);
assert_eq!(res["agent"]["approval_timeout_minutes"], 15);
assert_eq!(res["agent"]["concurrency"]["per_conv"], 2);
}
/// api_key 脱敏:默认 provider 返前 4 + •••• + 后 4,非空 key 不返明文。
#[tokio::test]
async fn default_provider_api_key_masked() {
let db = Arc::new(Database::open_in_memory().await.unwrap());
// 13 字符 key:前 4 = sk-a, 后 4 = 3456, 中间 ••••
insert_provider(&db, "p1", "默认", "sk-abcdef123456", true, true).await;
let ctx = make_ctx(db);
let res = execute_get_app_config(&ctx).await.unwrap();
let masked = res["default_provider"]["api_key_masked"]
.as_str()
.expect("default_provider 应含 api_key_masked");
assert_eq!(masked, "sk-a••••3456", "13 字符 key 应脱敏为 前4+••••+后4");
// 绝不含完整明文
assert!(!masked.contains("abcdef"), "脱敏值绝不含明文中段");
assert!(!masked.contains("1234"), "脱敏值绝不含明文尾段(除最后 4)");
}
/// 列表项 providers 不含 api_key 字段(最小披露:仅默认项带脱敏 key)。
#[tokio::test]
async fn providers_list_has_no_api_key_field() {
let db = Arc::new(Database::open_in_memory().await.unwrap());
insert_provider(&db, "p1", "默认", "sk-abcdef123456", true, true).await;
insert_provider(&db, "p2", "备用", "sk-xxxxxxxxxxxx", false, true).await;
let ctx = make_ctx(db);
let res = execute_get_app_config(&ctx).await.unwrap();
let providers = res["providers"].as_array().unwrap();
assert_eq!(providers.len(), 2, "应有 2 个 provider");
for p in providers {
assert!(
p.get("api_key_masked").is_none(),
"providers 列表项不应含 api_key_masked(仅默认项带)"
);
assert!(
p.get("api_key").is_none(),
"providers 列表项绝不含 api_key 字段"
);
// 列表项含必要概要字段
assert!(p.get("name").is_some());
assert!(p.get("provider_type").is_some());
assert!(p.get("base_url").is_some());
assert!(p.get("default_model").is_some());
assert!(p.get("is_default").is_some());
assert!(p.get("enabled").is_some());
}
}
/// 空 key(未配置)→ api_key_masked 为空串,明确告知缺失而非误导性占位。
#[tokio::test]
async fn empty_api_key_returns_empty_string() {
let db = Arc::new(Database::open_in_memory().await.unwrap());
// DB api_key 空且 keyring 无此 id → resolve 返空 → handler 返空串
// (keyring get_provider_secret_async 对不存在 id 返 None → unwrap_or_default 空串)
insert_provider(&db, "p1", "无key", "", true, true).await;
let ctx = make_ctx(db);
let res = execute_get_app_config(&ctx).await.unwrap();
let masked = res["default_provider"]["api_key_masked"]
.as_str()
.expect("default_provider 应含 api_key_masked");
assert_eq!(masked, "", "空 key 应返空串(明确缺失),非误导性 mask 占位");
}
/// 无默认 provider → default_provider 为 null(不 panic,不臆造)。
#[tokio::test]
async fn no_default_provider_returns_null() {
let db = Arc::new(Database::open_in_memory().await.unwrap());
insert_provider(&db, "p1", "非默认", "sk-abcdef123456", false, true).await;
let ctx = make_ctx(db);
let res = execute_get_app_config(&ctx).await.unwrap();
assert!(
res["default_provider"].is_null(),
"无默认 provider 时 default_provider 应为 null"
);
// providers 列表仍正常返
assert_eq!(res["providers"].as_array().unwrap().len(), 1);
}
/// 短 key(≤8 字符)全脱敏为 •,不泄露长度外的信息(对齐 mask_api_key 规则)。
#[tokio::test]
async fn short_api_key_fully_masked() {
let db = Arc::new(Database::open_in_memory().await.unwrap());
insert_provider(&db, "p1", "短key", "sk-ab", true, true).await;
let ctx = make_ctx(db);
let res = execute_get_app_config(&ctx).await.unwrap();
let masked = res["default_provider"]["api_key_masked"]
.as_str()
.unwrap();
assert_eq!(masked, "•••••", "5 字符 key 应全脱敏为 5 个 •(≤8 全脱敏)");
}
}
+181 -15
View File
@@ -13,7 +13,9 @@
//! 4. **重定向限制 ≤3 跳**:每跳重新校验目标 host/IP(防 302 绕过到内网)。reqwest 默认跟 ≤10, //! 4. **重定向限制 ≤3 跳**:每跳重新校验目标 host/IP(防 302 绕过到内网)。reqwest 默认跟 ≤10,
//! 改用手动 redirect(Policy::none) + 自管循环。 //! 改用手动 redirect(Policy::none) + 自管循环。
//! 5. **超时**:默认 30s,clamp ≤60s(对齐 run_command 思路,但封顶更紧——HTTP 调用应快速返回)。 //! 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::collections::HashMap;
use std::net::IpAddr; use std::net::IpAddr;
use std::path::Path;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use serde_json::{json, Value}; use serde_json::{json, Value};
/// 响应 body 截断上限(字节)。对齐 T-05 文档约定,防大响应撑爆 LLM context。 use crate::state::AllowedDirs;
const MAX_BODY_BYTES: usize = 50 * 1024;
/// 响应 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 覆盖。 /// 默认请求超时(秒)。LLM 可经 args timeout_secs 覆盖。
const DEFAULT_TIMEOUT_SECS: u64 = 30; const DEFAULT_TIMEOUT_SECS: u64 = 30;
@@ -246,9 +258,18 @@ fn build_request(
/// - body: string,可选(GET 通常不传;POST/PUT/PATCH 传) /// - body: string,可选(GET 通常不传;POST/PUT/PATCH 传)
/// - timeout_secs: 默认 30,clamp ≤60 /// - timeout_secs: 默认 30,clamp ≤60
/// - parse: "json"|"text"|"auto"(默认 auto),控制 body 解析展示 /// - 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(最终重定向后)} /// `allowed`:授权目录快照(由 handler 闭包 read lock clone 传入),仅 output_file 落盘路径校验用。
pub(crate) async fn execute_http_request(args: Value) -> anyhow::Result<Value> { /// 无 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<Value> {
// ── 参数解析 ── // ── 参数解析 ──
let method_str = args.get("method").and_then(|v| v.as_str()).unwrap_or("GET").to_uppercase(); let method_str = args.get("method").and_then(|v| v.as_str()).unwrap_or("GET").to_uppercase();
let method = match method_str.as_str() { let method = match method_str.as_str() {
@@ -286,6 +307,25 @@ pub(crate) async fn execute_http_request(args: Value) -> anyhow::Result<Value> {
.min(MAX_TIMEOUT_SECS) .min(MAX_TIMEOUT_SECS)
.max(1); // clamp [1, 60] .max(1); // clamp [1, 60]
let parse_mode = args.get("parse").and_then(|v| v.as_str()).unwrap_or("auto").to_lowercase(); 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 → 每跳重定向重复) ── // ── SSRF 校验(三层:词法 URL → DNS resolve IP → 每跳重定向重复) ──
let (scheme, host, port) = validate_url(&url_raw)?; let (scheme, host, port) = validate_url(&url_raw)?;
@@ -314,11 +354,55 @@ pub(crate) async fn execute_http_request(args: Value) -> anyhow::Result<Value> {
.or_insert(val); .or_insert(val);
} }
// body 读取 + 截断 // body 读取
let full_body = resp.bytes().await let full_body = resp.bytes().await
.map_err(|e| anyhow::anyhow!("读取响应 body 失败: {}", e))?; .map_err(|e| anyhow::anyhow!("读取响应 body 失败: {}", e))?;
let total_bytes = full_body.len(); 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)跳过 // parse 处理:json → pretty;auto → 尝试 json 失败回退 text;二进制(含 \0)跳过
let parsed_body = format_body(&body_text, total_bytes, &parse_mode, &resp_headers); let parsed_body = format_body(&body_text, total_bytes, &parse_mode, &resp_headers);
@@ -594,14 +678,14 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_handler_missing_url_errors() { async fn test_handler_missing_url_errors() {
let args = json!({ "method": "GET" }); 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")); assert!(format!("{}", err).contains("缺少 url"));
} }
#[tokio::test] #[tokio::test]
async fn test_handler_invalid_method_errors() { async fn test_handler_invalid_method_errors() {
let args = json!({ "method": "TRACE", "url": "https://example.com/" }); 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")); assert!(format!("{}", err).contains("不支持的 method"));
} }
@@ -609,21 +693,21 @@ mod tests {
async fn test_handler_rejects_localhost_url() { async fn test_handler_rejects_localhost_url() {
// SSRF 防护:handler 入口 validate_url 拒 localhost,不发请求 // SSRF 防护:handler 入口 validate_url 拒 localhost,不发请求
let args = json!({ "method": "GET", "url": "http://localhost:8080/admin" }); 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")); assert!(format!("{}", err).contains("localhost") || format!("{}", err).contains("SSRF"));
} }
#[tokio::test] #[tokio::test]
async fn test_handler_rejects_private_ip_url() { async fn test_handler_rejects_private_ip_url() {
let args = json!({ "method": "GET", "url": "http://169.254.169.254/latest/meta-data/" }); 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")); assert!(format!("{}", err).contains("私网") || format!("{}", err).contains("SSRF"));
} }
#[tokio::test] #[tokio::test]
async fn test_handler_rejects_non_http_scheme() { async fn test_handler_rejects_non_http_scheme() {
let args = json!({ "url": "file:///etc/passwd" }); 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("协议")); assert!(format!("{}", err).contains("协议"));
} }
@@ -632,7 +716,44 @@ mod tests {
// timeout_secs=9999 应 clamp 到 60;此处只验证不因超大值 panic(handler 内会走网络, // timeout_secs=9999 应 clamp 到 60;此处只验证不因超大值 panic(handler 内会走网络,
// 故本测试不期望成功完成——用 localhost 触发 SSRF 早期拒绝,验证 clamp 在拒绝前不 panic) // 故本测试不期望成功完成——用 localhost 触发 SSRF 早期拒绝,验证 clamp 在拒绝前不 panic)
let args = json!({ "url": "http://localhost/", "timeout_secs": 9999 }); 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 无网时跳过,本地手跑) ── // ── 真实网络集成(#[ignore]:CI 无网时跳过,本地手跑) ──
@@ -641,7 +762,7 @@ mod tests {
#[ignore = "需真实网络(example.com),CI 无网时跳过:cargo test -- --ignored"] #[ignore = "需真实网络(example.com),CI 无网时跳过:cargo test -- --ignored"]
async fn integration_get_example_com_200() { async fn integration_get_example_com_200() {
let args = json!({ "method": "GET", "url": "https://example.com/" }); 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(); let status = result["status"].as_u64().unwrap();
assert!(status == 200, "期望 200,实际 {}", status); assert!(status == 200, "期望 200,实际 {}", status);
assert!(result["body"].as_str().unwrap_or("").contains("Example Domain")); 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); 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] #[tokio::test]
#[ignore = "需真实网络,本地手跑"] #[ignore = "需真实网络,本地手跑"]
async fn integration_get_follows_redirect() { async fn integration_get_follows_redirect() {
// httpbin.org/redirect-to 重定向到 example.com,验证重定向链 + SSRF 每跳校验通过 // httpbin.org/redirect-to 重定向到 example.com,验证重定向链 + SSRF 每跳校验通过
let target = urlencoding("https://example.com/"); let target = urlencoding("https://example.com/");
let args = json!({ "url": format!("https://httpbin.org/redirect-to?url={}", target) }); 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); let status = result["status"].as_u64().unwrap_or(0);
// 最终落地 200(经重定向) // 最终落地 200(经重定向)
assert!(status >= 200 && status < 400, "重定向后状态: {}", status); assert!(status >= 200 && status < 400, "重定向后状态: {}", status);
+4
View File
@@ -37,6 +37,10 @@ pub mod conversation;
pub mod event_bus; pub mod event_bus;
pub mod http; pub mod http;
pub mod fetch_url; 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 knowledge_inject;
pub mod prompt; pub mod prompt;
pub mod provider_pool; pub mod provider_pool;
+289 -13
View File
@@ -301,6 +301,52 @@ pub(crate) fn compute_file_hash(meta: &std::fs::Metadata) -> String {
format!("{}_{}", modified.unwrap_or(0), meta.len()) 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<String> {
// UTF-16 LE BOM: FF FE
if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE {
let body = &bytes[2..];
let units: Vec<u16> = 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<u16> = 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。 /// 阶段4(容错/恢复,开关 `df-ai-approval-retry`):跨盘/跨卷文件移动统一降级 helper。
/// ///
/// 背景:Windows 跨盘符(C→E)或跨卷时 `tokio::fs::rename` 报 `os error 17` /// 背景: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<RwLock<AllowedDirs>>` 形参, /// F-260619-03 Phase A: 新增 `allowed_dirs: &Arc<RwLock<AllowedDirs>>` 形参,
/// 文件工具闭包 clone 进闭包,在 resolve_workspace_path_with_allowed 校验动态白名单。 /// 文件工具闭包 clone 进闭包,在 resolve_workspace_path_with_allowed 校验动态白名单。
/// data/http 工具不涉及文件路径,不传白名单。 /// 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( pub fn build_ai_tool_registry(
db: &Arc<Database>, db: &Arc<Database>,
allowed_dirs: &Arc<RwLock<AllowedDirs>>, allowed_dirs: &Arc<RwLock<AllowedDirs>>,
data_dir: PathBuf, data_dir: PathBuf,
get_app_config_ctx: super::get_app_config::GetAppConfigCtx,
) -> AiToolRegistry { ) -> AiToolRegistry {
let mut registry = AiToolRegistry::new(); let mut registry = AiToolRegistry::new();
register_data_tools(&mut registry, db); register_data_tools(&mut registry, db);
register_file_tools(&mut registry, allowed_dirs, data_dir); 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_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 registry
} }
@@ -473,8 +529,11 @@ pub fn build_ai_tool_registry(
/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托 /// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托
/// super::tools::http::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count /// super::tools::http::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count
/// 仍断言 48 总量 + 工具名集合稳定。 /// 仍断言 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<RwLock<AllowedDirs>>) {
super::tools::http::register(registry, allowed_dirs);
} }
/// fetch_url AI 工具注册(1 个:URL → markdown 文档嗅探)— 只读 GET 网页文档。 /// 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); 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<Database>,
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
) {
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 抽出。 /// 数据层 AI 工具注册(25 个持 db 的 CRUD/状态机/工作流/知识图谱工具)——从 build_ai_tool_registry 抽出。
/// ///
/// 工具闭包捕获 `db: &Arc<Database>` Arc 重建 Repo(列表/创建/更新/删除/状态推进/工作流/任务关联)。 /// 工具闭包捕获 `db: &Arc<Database>` Arc 重建 Repo(列表/创建/更新/删除/状态推进/工作流/任务关联)。
@@ -639,6 +755,10 @@ fn register_file_tools(
data_dir: PathBuf, data_dir: PathBuf,
) { ) {
super::tools::file::register(registry, allowed_dirs, data_dir); 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 超时兜底。 /// 探测可执行文件路径(which/where 风格),8s 超时兜底。
@@ -1122,6 +1242,28 @@ pub(crate) fn search_files_recursive<'a>(
mod tests { mod tests {
use super::*; use super::*;
use std::fs; 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<Database>) -> 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 拆分防护) // 工具注册基线测试(SMELL-P0-2 拆分防护)
@@ -1131,7 +1273,7 @@ mod tests {
// 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。 // 任一层漏移 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, /// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db,
// 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。 // 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。
@@ -1142,7 +1284,7 @@ mod tests {
// F-260619-03 Phase A: build_ai_tool_registry 新增 allowed_dirs 形参, // F-260619-03 Phase A: build_ai_tool_registry 新增 allowed_dirs 形参,
// 测试用 default_with_root(仅 workspace_root),零回归(白名单含 workspace_root)。 // 测试用 default_with_root(仅 workspace_root),零回归(白名单含 workspace_root)。
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_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)。拆分前后必须一致。 // 总量基线:41(27 data + 13 file + 1 http)。拆分前后必须一致。
// F-260621: file 层 10→11(新增 grep 跨文件内容搜索工具)。 // F-260621: file 层 10→11(新增 grep 跨文件内容搜索工具)。
@@ -1168,15 +1310,31 @@ mod tests {
// fetch_url(2026-08-01): http 层 1→2(新增 URL→markdown 文档嗅探,只读 GET, // fetch_url(2026-08-01): http 层 1→2(新增 URL→markdown 文档嗅探,只读 GET,
// htmd HTML→markdown + 去噪音 + 截断,SSRF 复用 http.rs)。 // htmd HTML→markdown + 去噪音 + 截断,SSRF 复用 http.rs)。
// 51 = 36 data + 13 file + 1 http + 1 fetch_url。 // 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!( assert_eq!(
registry.len(), registry.len(),
51, 55,
"工具总数应为 51(36 data + 13 file + 1 http + 1 fetch_url),实际 {}", registry.len() "工具总数应为 55(36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config),实际 {}", registry.len()
); );
// 工具名集合基线:防 rename / 漏注册 / 误删除。 // 工具名集合基线:防 rename / 漏注册 / 误删除。
// data 层 36 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流 + 基础设施配置 + git 工具 // data 层 36 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流 + 基础设施配置 + git 工具
// file 层 13 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测/符号解析 // file 层 14 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测/符号解析/下载
// http 层 1 个(不持 db):http_request // http 层 1 个(不持 db):http_request
let mut expected: Vec<&str> = vec![ let mut expected: Vec<&str> = vec![
// ── data 层 (36) ── // ── data 层 (36) ──
@@ -1200,17 +1358,27 @@ mod tests {
"git_status", "git_diff", "git_log", "git_status", "git_diff", "git_log",
// Git 写工具 // Git 写工具
"git_commit", "git_branch", "git_merge", "git_commit", "git_branch", "git_merge",
// ── file 层 (13) ──(run_command 注册顺序已移至末位降低 LLM 偏好, // ── file 层 (14) ──(run_command 注册顺序已移至末位降低 LLM 偏好,
// 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621; // 集合断言经 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", "read_file", "read_symbol", "list_directory", "write_file",
"patch_file", "file_info", "append_file", "patch_file", "file_info", "append_file",
"delete_file", "rename_file", "search_files", "run_command", "delete_file", "rename_file", "search_files", "run_command",
"grep", "detect_environment", "grep", "detect_environment",
// download_file(URL→文件流式下载,写文件需 allowed_dirs,注册在 register_file_tools)
"download_file",
// ── http 层 (1) ── // ── http 层 (1) ──
"http_request", "http_request",
// ── fetch_url 层 (1) ──(URL → markdown 文档嗅探,只读 GET,与 http_request 分工) // ── fetch_url 层 (1) ──(URL → markdown 文档嗅探,只读 GET,与 http_request 分工)
"fetch_url", "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(); expected.sort_unstable();
@@ -1412,7 +1580,7 @@ mod tests {
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败"); let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let db = Arc::new(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 canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
let args = serde_json::json!({ "path": canon_file, "limit": 15 }); let args = serde_json::json!({ "path": canon_file, "limit": 15 });
let res = registry.execute("read_file", args).await.expect("read_file 执行失败"); 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 = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let db = Arc::new(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 canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
let args = serde_json::json!({ "path": canon_file }); let args = serde_json::json!({ "path": canon_file });
let res = registry.execute("read_file", args).await.expect("read_file 执行失败"); let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
@@ -1450,6 +1618,114 @@ mod tests {
fs::remove_dir_all(&tmp).ok(); 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<u8> = 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<u8> = 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<u8> = 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<u8> = 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<u8> = 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 // patch_file 三模式共用底层测试(F-260617-01
// 纯函数 apply_line_range / resolve_anchor_to_lines,不依赖 fs/async/锁。 // 纯函数 apply_line_range / resolve_anchor_to_lines,不依赖 fs/async/锁。
@@ -1921,7 +2197,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_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));
(db, registry) (db, registry)
} }
@@ -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<RwLock<AllowedDirs>>(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<RwLock<AllowedDirs>>,
) {
// 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<RwLock<AllowedDirs>>,
"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
}
);
}
@@ -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
}
);
}
@@ -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<Database>(选 provider + 解析凭证)+ allowed_dirs:Arc<RwLock<AllowedDirs>>
//! (output_path 白名单校验 + 默认路径锚定)。
//!
//! ## 捕获变量说明(declare_tool! 单捕获限制)
//!
//! declare_tool! 宏只支持单捕获变量(见 crates/df-ai/src/ai_tools_decl.rs),本工具需 db +
//! allowed_dirs 两个,故包进 tuple Arc `(Arc<Database>, Arc<RwLock<AllowedDirs>>)` 在闭包内解构。
//! 这是宏单捕获限制的标准绕法(零架构改动:不改宏、不改 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<Database>, Arc<RwLock<AllowedDirs>>);
/// 注册 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<Database>,
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
) {
// 捕获 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
}
);
}
@@ -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
}
);
}
+29 -16
View File
@@ -2,16 +2,22 @@
//! //!
//! 迁自 `tool_registry.rs::register_http_tools`(原 1 个 http_request),改用 `declare_tool!` 宏。 //! 迁自 `tool_registry.rs::register_http_tools`(原 1 个 http_request),改用 `declare_tool!` 宏。
//! //!
//! 迁移策略(handler 逻辑零变更): //! 迁移策略(handler 逻辑零变更,2026-08-01 增 output_file/max_response_chars + allowed_dirs 捕获):
//! - handler body 逐字照搬原 async move 块(转调 `crate::commands::ai::http::execute_http_request`), //! - handler body 转调 `crate::commands::ai::http::execute_http_request`,闭包包装由 `declare_tool!` 宏生成。
//! 仅闭包包装改由 `declare_tool!` 宏生成。 //! output_file 参数引入后,handler 内增 `allowed_dirs.read().await.clone()` 快照传入(路径校验用,
//! 无 output_file 时校验不触发,行为零变更)。
//! - name/desc/schema/risk 与原手写定义逐字一致(headers 是对象 map,object_schema 仅支持扁平 //! - name/desc/schema/risk 与原手写定义逐字一致(headers 是对象 map,object_schema 仅支持扁平
//! 标量三元组,故保留原手工拼 serde_json::Map schema 表达式)。 //! 标量三元组,故保留原手工拼 serde_json::Map schema 表达式);新增 output_file/max_response_chars 两 prop
//! - 捕获:handler 不 clone db(handler 仅转调 http.rs),用占位 `Arc<()>` 捕获(宏要求 capture 形参)。 //! - 捕获:handler 不持 db(纯转调 http.rs),但需 allowed_dirs 做 output_file 路径校验,
//! 故捕获 `Arc<RwLock<AllowedDirs>>`(宏要求 capture 形参)。
//! //!
//! 风险:GET=Medium(只读但触发外发)/ 写方法=High(须人工批准),一个工具名两种 risk 不支持(register //! 风险:GET=Medium(只读但触发外发)/ 写方法=High(须人工批准),一个工具名两种 risk 不支持(register
//! 单一 risk_level),故按最高风险 High 注册(写方法 High 兜底;GET 也走 High 审批更保守)。 //! 单一 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 总量 + //! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 +
//! 工具名集合稳定(防 rename / 漏注册)。 //! 工具名集合稳定(防 rename / 漏注册)。
@@ -19,18 +25,21 @@ use std::sync::Arc;
use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
use df_ai::declare_tool; 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: /// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1(2026-08-01 增 output_file /
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义 /// max_response_chars 两参数 + allowed_dirs 捕获):
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义(新增两 prop)
/// - risk=High(写方法兜底,GET 同走审批更保守) /// - 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 直接写业务逻辑。 /// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
pub fn register(registry: &mut AiToolRegistry) { pub fn register(registry: &mut AiToolRegistry, allowed_dirs: &Arc<RwLock<AllowedDirs>>) {
// 无捕获:占位 Arc<()>(原 handler 不持 db,仅转调 http.rs)。
let dummy: Arc<()> = Arc::new(());
// schema:headers 是对象 map,object_schema 仅支持扁平标量三元组,故手工拼 serde_json::Map。 // schema:headers 是对象 map,object_schema 仅支持扁平标量三元组,故手工拼 serde_json::Map。
// 提取为 let 绑定:declare_tool! 的 schema: $schema:expr 形参对花括号块表达式解析有歧义, // 提取为 let 绑定:declare_tool! 的 schema: $schema:expr 形参对花括号块表达式解析有歧义,
// 先求值到局部变量再传入,语义等价(原手写 register 亦以此 Map 作为 schema 实参)。 // 先求值到局部变量再传入,语义等价(原手写 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("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("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("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!({ serde_json::json!({
"type": "object", "type": "object",
"properties": props, "properties": props,
@@ -50,14 +61,16 @@ pub fn register(registry: &mut AiToolRegistry) {
}; };
declare_tool!( declare_tool!(
registry, registry,
dummy: Arc<()>, allowed_dirs: Arc<RwLock<AllowedDirs>>,
"http_request", "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, RiskLevel::High,
schema: schema, schema: schema,
args => { args => {
// 转调 http.rs handler(SSRF 防护 + 重定向 + 截断全在那) // allowed_dirs read lock clone(output_file 路径校验用;无 output_file 时校验不触发)
crate::commands::ai::http::execute_http_request(args).await let snap = allowed_dirs.read().await.clone();
// 转调 http.rs handler(SSRF 防护 + 重定向 + 截断 + output_file 落盘全在那)
crate::commands::ai::http::execute_http_request(args, &snap).await
} }
); );
} }
+4
View File
@@ -15,7 +15,11 @@ pub mod task_graph;
pub mod git; pub mod git;
pub mod http; pub mod http;
pub mod fetch_url; pub mod fetch_url;
pub mod fetch_search;
pub mod generate_image;
pub mod get_app_config;
pub mod workflow; pub mod workflow;
pub mod idea; pub mod idea;
pub mod trash; pub mod trash;
pub mod file; pub mod file;
pub mod download_file;
+26 -9
View File
@@ -233,8 +233,28 @@ impl AppState {
// 文件工具闭包捕获后 resolve_workspace_path_with_allowed 校验动态白名单。 // 文件工具闭包捕获后 resolve_workspace_path_with_allowed 校验动态白名单。
// 此处用 default_with_root 占位,下方 init 尾部 reload_allowed_dirs 从 Settings KV 覆盖。 // 此处用 default_with_root 占位,下方 init 尾部 reload_allowed_dirs 从 Settings KV 覆盖。
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default())); 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( 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。 // Input Augmentation 层(核心设计2):ResolverRegistry 启动期注册四 resolver。
// resolver 持 Arc<Database>(非 AppState,避免循环依赖:AppState 持 Arc<ResolverRegistry>), // resolver 持 Arc<Database>(非 AppState,避免循环依赖:AppState 持 Arc<ResolverRegistry>),
@@ -272,14 +292,11 @@ impl AppState {
knowledge_config: Arc::new(Mutex::new(KnowledgeConfig::default())), knowledge_config: Arc::new(Mutex::new(KnowledgeConfig::default())),
data_dir: data_dir.clone(), data_dir: data_dir.clone(),
settings: SettingsRepo::new(&db), settings: SettingsRepo::new(&db),
llm_concurrency: LlmConcurrency::new(3, 2), // 复用上方提前构造的句柄(与 get_app_config 工具共享同一底层原子量/Semaphore)。
agent_max_iterations: Arc::new(AtomicUsize::new( llm_concurrency,
crate::commands::ai::agentic::DEFAULT_MAX_AGENT_ITERATIONS, agent_max_iterations,
)), agent_max_retries,
agent_max_retries: Arc::new(AtomicUsize::new( approval_timeout_minutes,
crate::commands::ai::agentic::DEFAULT_MAX_AGENT_RETRIES,
)),
approval_timeout_minutes: Arc::new(AtomicU64::new(15)),
workflow_state_registry: Arc::new(Mutex::new(HashMap::new())), workflow_state_registry: Arc::new(Mutex::new(HashMap::new())),
// F-260619-03 Phase A: 与 ai_tools registry 共享同一 Arc(构建时注入同一句柄) // F-260619-03 Phase A: 与 ai_tools registry 共享同一 Arc(构建时注入同一句柄)
allowed_dirs: allowed_dirs.clone(), allowed_dirs: allowed_dirs.clone(),
+9
View File
@@ -174,6 +174,15 @@ impl LlmConcurrency {
self.per_conv.lock().await.clear(); self.per_conv.lock().await.clear();
} }
/// 读当前 per_conv permits 生效值(对齐 set_per_conv 的写,补足读侧 getter)。
///
/// 用途:get_app_config AI 工具查「当前 LLM 并发配置」时返给 LLM(global 无对称 getter:
/// Semaphore permits 封装在 Arc<Mutex<Arc<Semaphore>>> 内层无读取接口,故此处只返 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 层 — 占位未接入调用点 // Phase3 预留(批2-B): per_sub_flow 层 — 占位未接入调用点
// ============================================================ // ============================================================