优化: run_command 实时流式 + 审批浮窗修复 + 文档探索闭环(fetch_url+obscura引导+model_fetch兼容+prompt策略+厂商预设)

This commit is contained in:
lxy
2026-08-01 17:58:47 +08:00
parent 0e0c6862ba
commit d664bdc309
25 changed files with 1693 additions and 44 deletions
Generated
+35
View File
@@ -861,6 +861,7 @@ dependencies = [
"df-types", "df-types",
"df-workflow", "df-workflow",
"futures", "futures",
"htmd",
"keyring", "keyring",
"percent-encoding", "percent-encoding",
"regex", "regex",
@@ -1912,6 +1913,17 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "htmd"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5a1c7113c831fec68cbd79cd8bf281a84e5b6943f51473dc266b0b88a6a017e"
dependencies = [
"html5ever",
"markup5ever_rcdom",
"phf",
]
[[package]] [[package]]
name = "html5ever" name = "html5ever"
version = "0.38.0" version = "0.38.0"
@@ -2521,6 +2533,18 @@ dependencies = [
"web_atoms", "web_atoms",
] ]
[[package]]
name = "markup5ever_rcdom"
version = "0.38.0+unofficial"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "333171ccdf66e915257740d44e38ea5b1b19ce7b45d33cc35cb6f118fbd981ff"
dependencies = [
"html5ever",
"markup5ever",
"tendril",
"xml5ever",
]
[[package]] [[package]]
name = "matchers" name = "matchers"
version = "0.2.0" version = "0.2.0"
@@ -4273,6 +4297,7 @@ dependencies = [
"parking_lot", "parking_lot",
"phf_shared", "phf_shared",
"precomputed-hash", "precomputed-hash",
"serde",
] ]
[[package]] [[package]]
@@ -6482,6 +6507,16 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "xml5ever"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3dc9559429edf0cd3f327cc0afd9d6b36fa8cec6d93107b7fbe64f806b5f2d9"
dependencies = [
"log",
"markup5ever",
]
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.3" version = "0.8.3"
+220 -8
View File
@@ -18,6 +18,7 @@ use std::time::Duration;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use df_ai_core::model::ModelConfig; use df_ai_core::model::ModelConfig;
use serde_json::Value;
use crate::model_fetch_helpers::{build_models_url, filter_chat_models, ModelsList}; use crate::model_fetch_helpers::{build_models_url, filter_chat_models, ModelsList};
use crate::model_probe::probe; use crate::model_probe::probe;
@@ -83,12 +84,15 @@ async fn fetch_openai_compat(base_url: &str, api_key: &str) -> Result<Vec<String
} }
// OpenAI 响应:`{data:[{id, owned_by, ...}]}`。中转站通常同构。 // OpenAI 响应:`{data:[{id, owned_by, ...}]}`。中转站通常同构。
let body: ModelsList = resp // 不用 resp.json():reqwest::Error::Decode 的 Display 吞 serde 详情(只给 "error decoding
.json() // response body"),SenseNova 等厂商解析失败时无法定位根因。改 text() + serde_json::from_str,
// 解析失败时 serde_json::Error 含具体 field/type/position;再叠加宽松 Value fallback 兜底。
let body = resp
.text()
.await .await
.map_err(|e| anyhow!("openai_compat 响应解析失败({url}):{e}"))?; .map_err(|e| anyhow!("openai_compat 读取响应体失败({url}):{e}"))?;
Ok(filter_chat_models(body.into_ids())) Ok(filter_chat_models(parse_models_compat("openai_compat", &url, &body)?))
} }
/// Anthropic 兼容(Claude 官方 / GLM 订阅端点):`GET /v1/models`,x-api-key + anthropic-version 鉴权。 /// Anthropic 兼容(Claude 官方 / GLM 订阅端点):`GET /v1/models`,x-api-key + anthropic-version 鉴权。
@@ -112,12 +116,93 @@ async fn fetch_anthropic_compat(base_url: &str, api_key: &str) -> Result<Vec<Str
// Anthropic 响应:`{data:[{id, display_name, type, ...}]}`(has_more 分页字段忽略)。 // Anthropic 响应:`{data:[{id, display_name, type, ...}]}`(has_more 分页字段忽略)。
// 兼容兜底:`{models:[{name, ...}]}`(Ollama 风格,理论 anthropic_compat 不会命中, // 兼容兜底:`{models:[{name, ...}]}`(Ollama 风格,理论 anthropic_compat 不会命中,
// 但中转站行为不可控,用 `#[serde(alias)]` 零成本兜底 — 见 issues)。 // 但中转站行为不可控,用 `#[serde(alias)]` 零成本兜底 — 见 issues)。
let body: ModelsList = resp // 与 openai_compat 同:text() + 严格 serde + Value 宽松 fallback,见 parse_models_compat。
.json() let body = resp
.text()
.await .await
.map_err(|e| anyhow!("anthropic_compat 响应解析失败({url}):{e}"))?; .map_err(|e| anyhow!("anthropic_compat 读取响应体失败({url}):{e}"))?;
Ok(filter_chat_models(body.into_ids())) Ok(filter_chat_models(parse_models_compat("anthropic_compat", &url, &body)?))
}
// ────────────────────────────────────────────────────────────
// 响应解析(text → 严格 serde → Value 宽松 fallback)
// ────────────────────────────────────────────────────────────
/// 响应体诊断片段最大字符数。完整 body 可能巨大,日志只取前缀定位结构。
const BODY_DIAGNOSTIC_CHARS: usize = 200;
/// 解析厂商 `/v1/models` 响应体,返回模型 id 列表(过滤前)。
///
/// 三层解析(诊断优先,兜底保成功):
/// 1. **严格**:`serde_json::from_str::<ModelsList>` — 标准结构命中,错误信息含具体
/// field/type/position(serde_json::Error Display 自带 line/column,不丢 detail)。
/// 2. **宽松 fallback**:`serde_json::Value` 解析 → 取 `data` / `models` 任一数组 →
/// 遍历项取 `id` / `name` 字符串。容错厂商额外字段、类型变体(如 id 漏成 number)。
/// 3. **诊断错误**:严格 + 宽松都失败时,返回含 HTTP 标识 + serde detail + body 前缀
/// 的友好错误,而非 reqwest 默认 "error decoding response body"。
///
/// 注:fallback 只取 id/name(模型名),丢弃 ModelEntry 上的其他字段 — 厂商变体下
/// 我们关心的就是模型名,ModelsList 本身也只消费 id/name,语义对齐。
fn parse_models_compat(provider_type: &str, url: &str, body: &str) -> Result<Vec<String>> {
// 1) 严格解析(标准结构,serde 错误 detail 完整)。
match serde_json::from_str::<ModelsList>(body) {
Ok(list) => return Ok(list.into_ids()),
Err(strict_err) => {
// 2) 宽松 Value fallback — 不依赖 ModelsList 结构,容错厂商变体。
if let Some(ids) = parse_ids_loose(body) {
return Ok(ids);
}
// 3) 双双失败:叠 HTTP 标识 + serde detail + body 前缀诊断。
return Err(anyhow!(
"{provider_type} 响应解析失败({url}):{strict_err} | body 前缀:{}",
body_preview(body)
));
}
}
}
/// 用 `serde_json::Value` 宽松提取模型 id/name。失败(非 JSON / 无 data / 无 id)返回 None。
///
/// 取数组字段优先级:`data`(OpenAI/Anthropic)→ `models`(Ollama 风格 alias)。
/// 项里取 `id` → 兜底 `name`,只接受字符串值(number/bool 等跳过)。
fn parse_ids_loose(body: &str) -> Option<Vec<String>> {
let val: Value = serde_json::from_str(body).ok()?;
let obj = val.as_object()?;
// 任一存在即取;data 优先(标准结构)。
let arr = obj.get("data").or_else(|| obj.get("models"))?;
let arr = arr.as_array()?;
let mut ids = Vec::with_capacity(arr.len());
for item in arr {
let id = item
.get("id")
.or_else(|| item.get("name"))
.and_then(|v| v.as_str());
if let Some(id) = id {
ids.push(id.to_string());
}
}
Some(ids)
}
/// body 前缀诊断(截断 + 控制字符占位,避免换行/制表符污染日志单行)。
fn body_preview(body: &str) -> String {
let prefix: String = body.chars().take(BODY_DIAGNOSTIC_CHARS).collect();
if prefix.chars().all(|c| c.is_control()) && !prefix.is_empty() {
// 整段控制字符(二进制?)→ 给长度提示而非乱码。
return format!("<非文本 body,长度 {}>", body.len());
}
let truncated = body.chars().count() > BODY_DIAGNOSTIC_CHARS;
// 把控制字符(换行/制表等)压成空格,保持日志单行可读。
let cleaned: String = prefix
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
if truncated {
format!("{cleaned}")
} else {
cleaned
}
} }
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
@@ -173,4 +258,131 @@ mod tests {
assert!(msg.contains("ollama"), "err={msg}"); assert!(msg.contains("ollama"), "err={msg}");
assert!(msg.contains("provider_type"), "err={msg}"); assert!(msg.contains("provider_type"), "err={msg}");
} }
// ── parse_models_compat:严格 / fallback / 诊断三层 ──
#[test]
fn parse_strict_openai_format() {
// 标准 OpenAI 结构 → 严格解析命中,不进 fallback
let body = r#"{"data":[{"id":"gpt-4o","owned_by":"openai"},{"id":"gpt-4o-mini"}]}"#;
let ids = parse_models_compat("openai_compat", "http://x/v1/models", body).unwrap();
assert_eq!(ids, vec!["gpt-4o", "gpt-4o-mini"]);
}
#[test]
fn parse_loose_fallback_on_unknown_field_type_variant() {
// 厂商变体:data 项里多了非标准字段、且某项漏 id → 严格可能仍过(serde default),
// 此用例构造严格失败 + 宽松应成功:id 字段为 number(非字符串)致 ModelEntry serde 失败。
// 宽松 fallback 应:跳过 number id,保留 string id。
let body = r#"{"data":[{"id":12345},{"id":"glm-4-flash"}]}"#;
// 严格 ModelsList 的 id: Option<String>,number 12345 无法反序列化为 String → 失败
let ids = parse_models_compat("openai_compat", "http://x/v1/models", body).unwrap();
assert_eq!(ids, vec!["glm-4-flash"]);
}
#[test]
fn parse_loose_fallback_via_models_alias() {
// 严格解析缺 data 字段时进 fallback,走 models alias 取 name
let body = r#"{"models":[{"name":"llama3:8b"},{"name":"qwen2:7b"}]}"#;
let ids = parse_models_compat("openai_compat", "http://x/v1/models", body).unwrap();
assert_eq!(ids, vec!["llama3:8b", "qwen2:7b"]);
}
#[test]
fn parse_loose_fallback_tolerates_extra_top_level_fields() {
// 宽松 fallback 应容错顶层额外字段、非 id 项(只关心 data[].id/name)
let body = r#"{"object":"list","data":[{"id":"deepseek-chat","object":"model"},{"id":"deepseek-coder"}],"supported_ids":["x"]}"#;
let ids = parse_models_compat("openai_compat", "http://x/v1/models", body).unwrap();
assert_eq!(ids, vec!["deepseek-chat", "deepseek-coder"]);
}
#[test]
fn parse_diagnostic_error_has_serde_detail_and_body_prefix() {
// 完全无法解析(非 JSON)→ 严格 + 宽松双失败 → 错误含 serde detail + body 前缀 + HTTP 标识
let body = "this is not json at all {{{";
let err = parse_models_compat("openai_compat", "http://x/v1/models", body).unwrap_err();
let msg = format!("{err}");
// provider_type 标识
assert!(msg.contains("openai_compat"), "err={msg}");
// url 便于定位
assert!(msg.contains("http://x/v1/models"), "err={msg}");
// serde detail(serde_json 错误含 line/column 或 expected 字样)
assert!(
msg.contains("line") || msg.contains("column") || msg.contains("expected"),
"err={msg}"
);
// body 前缀诊断片段
assert!(msg.contains("this is not json"), "err={msg}");
}
#[test]
fn parse_diagnostic_truncates_long_body() {
// 超长 body → 前缀截断(… 标记),不整段灌进错误信息
let long_id = "a".repeat(500);
let body = format!(r#"{{"garbage":"{long_id}""#); // 缺尾 → 非 JSON
let err = parse_models_compat("openai_compat", "http://x/v1/models", &body).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains(""), "长 body 应截断(err={})\n{}", msg.len(), msg);
// 诊断片段不应超过 BODY_DIAGNOSTIC_CHARS + 容差
assert!(
msg.len() < long_id.len(),
"错误信息不应含完整 500 字符 body"
);
}
#[test]
fn parse_diagnostic_empty_body() {
// 空 body → 双失败,错误信息不 panic、含 provider 标识
let err = parse_models_compat("openai_compat", "http://x/v1/models", "").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("openai_compat"), "err={msg}");
assert!(msg.contains("解析失败"), "err={msg}");
}
// ── parse_ids_loose:边界 ──
#[test]
fn parse_ids_loose_returns_none_on_non_json() {
assert!(parse_ids_loose("not json").is_none());
}
#[test]
fn parse_ids_loose_returns_none_on_missing_data_field() {
// 合法 JSON 但无 data/models → None(parse_models_compat 会进而报诊断错误)
assert!(parse_ids_loose(r#"{"foo":"bar"}"#).is_none());
}
#[test]
fn parse_ids_loose_data_not_array_returns_none() {
// data 存在但非数组 → None
assert!(parse_ids_loose(r#"{"data":"not-an-array"}"#).is_none());
}
#[test]
fn parse_ids_loose_skips_non_string_id() {
// id 为 number/null/object → 跳过,只留字符串 id
let body = r#"{"data":[{"id":1},{"id":null},{"id":"keep-me"},{"name":"named"}]}"#;
let ids = parse_ids_loose(body).unwrap();
assert_eq!(ids, vec!["keep-me", "named"]);
}
// ── body_preview:控制字符 + 截断 ──
#[test]
fn body_preview_replaces_control_chars_with_space() {
// 换行/制表压成空格,保持日志单行
let preview = body_preview("line1\nline2\tcol");
assert!(!preview.contains('\n'), "preview={preview}");
assert!(!preview.contains('\t'), "preview={preview}");
assert!(preview.contains("line1"), "preview={preview}");
}
#[test]
fn body_preview_truncates_with_ellipsis() {
let body = "abcdefghij".repeat(100); // 1000 chars
let preview = body_preview(&body);
assert!(preview.ends_with('…'), "preview should end with ellipsis");
// 不应含完整 body
assert!(preview.len() < body.len());
}
} }
+168 -12
View File
@@ -3,6 +3,8 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::process::Stdio; use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
/// Shell 命令执行结果 /// Shell 命令执行结果
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShellResult { pub struct ShellResult {
@@ -100,17 +102,28 @@ pub struct ShellRequest {
pub shell_type: Option<ShellType>, pub shell_type: Option<ShellType>,
} }
/// 执行 Shell 命令 /// 输出流类型(回调 on_output 用)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamKind {
Stdout,
Stderr,
}
impl StreamKind {
/// 序列化为稳定字符串标识(emit 事件 stream 字段用)
pub fn as_str(&self) -> &'static str {
match self {
StreamKind::Stdout => "stdout",
StreamKind::Stderr => "stderr",
}
}
}
/// 构造已配置好(stdio piped + kill_on_drop + CREATE_NO_WINDOW + cwd + env)的子进程 Command。
/// ///
/// 支持超时(timeout_secs)、环境变量(env)、工作目录(working_dir), /// execute() 与 execute_streaming() 共用同一构造逻辑(单真相源,DRY):
/// kill_on_drop(true) 保证超时后子进程不残留,shell_type 可选 Cmd/PowerShell/Sh /// shell 类型选择 / kill_on_drop / Windows 无窗 / cwd / env 全在此。差异仅在后续如何消费 stdout/stderr
pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> { fn build_command(request: ShellRequest) -> tokio::process::Command {
let start = std::time::Instant::now();
// 探测 pwsh(惰性 + OnceLock 全局缓存,只探一次),使后续 ShellType::default() 可读取缓存
#[cfg(windows)]
let _ = probe_pwsh().await;
let shell_type = request.shell_type.unwrap_or_default(); let shell_type = request.shell_type.unwrap_or_default();
let mut cmd = match shell_type { let mut cmd = match shell_type {
ShellType::PowerShell => { ShellType::PowerShell => {
@@ -161,14 +174,34 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
for (key, value) in &request.env { for (key, value) in &request.env {
cmd.env(key, value); cmd.env(key, value);
} }
cmd
}
let output = match request.timeout_secs { /// 执行 Shell 命令(等 exit 一次性返回,非流式)
///
/// 支持超时(timeout_secs)、环境变量(env)、工作目录(working_dir),
/// kill_on_drop(true) 保证超时后子进程不残留,shell_type 可选 Cmd/PowerShell/Sh。
///
/// 需要执行中实时获取 stdout/stderr 行(如 run_command 进度展示)用 [`execute_streaming`]。
pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
let start = std::time::Instant::now();
// 探测 pwsh(惰性 + OnceLock 全局缓存,只探一次),使后续 ShellType::default() 可读取缓存
#[cfg(windows)]
let _ = probe_pwsh().await;
// 先取走 build_command 之外的引用字段(超时错误信息 + timeout 判定),再 move request
let command_for_err = request.command.clone();
let timeout_secs = request.timeout_secs;
let mut cmd = build_command(request);
let output = match timeout_secs {
Some(secs) => tokio::time::timeout( Some(secs) => tokio::time::timeout(
std::time::Duration::from_secs(secs), std::time::Duration::from_secs(secs),
cmd.output(), cmd.output(),
) )
.await .await
.map_err(|_| anyhow::anyhow!("命令执行超时({}s): {}", secs, request.command))??, .map_err(|_| anyhow::anyhow!("命令执行超时({}s): {}", secs, command_for_err))??,
None => cmd.output().await?, None => cmd.output().await?,
}; };
@@ -181,3 +214,126 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
duration_ms: duration, duration_ms: duration,
}) })
} }
/// 流式执行 Shell 命令 —— spawn 后逐行读 stdout/stderr,每行回调 on_output。
///
/// 治 run_command 执行中黑盒:execute() 等 exit 才返回整块 stdout/stderr,长命令(cargo/npm 构建)
/// 期间前端只看 Started→Completed,中间进度不可见。本函数 spawn 子进程后并发逐行读两条流,
/// 每读到一行回调 `on_output(kind, line)`(调用方可 emit 事件给前端实时展示),仍等进程 exit
/// 后返回完整 ShellResult(与 execute() 同形,调用方无需感知差异)。
///
/// 4性:
/// - 合理机制:spawn + BufReader::lines() 逐行,不丢未换行结尾的末段(read_to_end 兜底)
/// - 简洁:与 execute() 共用 build_command(单真相源,shell/kill_on_drop/cwd/env 不重复)
/// - 可靠兜底:timeout_secs 仍生效(超时 drop future → kill_on_drop 杀进程,返回 Err);
/// on_output 回调 Err 不影响主流程(调用方 emit 失败静默吞)
/// - 健壮边界:stdout/stderr 各独立任务并发读,互不阻塞;无管道死锁(piped + 同时消费)
pub async fn execute_streaming<F>(request: ShellRequest, mut on_output: F) -> anyhow::Result<ShellResult>
where
F: FnMut(StreamKind, &str) + Send,
{
let start = std::time::Instant::now();
#[cfg(windows)]
let _ = probe_pwsh().await;
// 先取走引用字段,再 move request 进 build_command
let command_for_err = request.command.clone();
let timeout_secs = request.timeout_secs;
let mut cmd = build_command(request);
let inner = async {
let mut child = cmd.spawn()?;
// 取出 piped 的 stdout/stderr handle(None → 视为已关,读为空,不影响主流程)
let stdout = child.stdout.take();
let stderr = child.stderr.take();
// mpsc 通道:读 task 把 (kind, line) 推过来,主 task 在 wait 期间 drain 并调 on_output。
// 用通道而非直接共享 on_output:FnMut 不可 clone,两读 task 无法各持一份;通道解耦读写,
// 回调集中在主 task 单点调用(顺序确定、无锁、回调内阻塞不影响读循环)。
let (tx, mut rx) = tokio::sync::mpsc::channel::<(StreamKind, String)>(64);
let mut tasks: Vec<tokio::task::JoinHandle<()>> = Vec::with_capacity(2);
if let Some(out) = stdout {
let tx = tx.clone();
tasks.push(tokio::spawn(async move {
let mut reader = BufReader::new(out).lines();
while let Ok(Some(line)) = reader.next_line().await {
if tx.send((StreamKind::Stdout, line)).await.is_err() {
break; // 接收端 drop(主 task 结束)→ 停止读
}
}
}));
}
if let Some(err) = stderr {
let tx = tx.clone();
tasks.push(tokio::spawn(async move {
let mut reader = BufReader::new(err).lines();
while let Ok(Some(line)) = reader.next_line().await {
if tx.send((StreamKind::Stderr, line)).await.is_err() {
break;
}
}
}));
}
// 主 task 不再 send → drop tx(读 task send 失败即退出)
drop(tx);
// 完整输出累积(主 task 单点写,无锁)。
let mut stdout_buf = String::new();
let mut stderr_buf = String::new();
// wait + drain 并行:边等进程退出边消费输出行(防管道写满阻塞致子进程 hang)。
let wait_fut = child.wait();
tokio::pin!(wait_fut);
let status: std::process::ExitStatus = loop {
tokio::select! {
// 进程退出 → 跳出循环,继续 drain 通道内残余行
status = &mut wait_fut => {
let status = status?;
// drain 剩余行(读 task 在管道 EOF 后 send 完最后批次即退出,rx 返 None 闭合)
while let Some((kind, line)) = rx.recv().await {
match kind {
StreamKind::Stdout => { stdout_buf.push_str(&line); stdout_buf.push('\n'); }
StreamKind::Stderr => { stderr_buf.push_str(&line); stderr_buf.push('\n'); }
}
on_output(kind, &line);
}
break status;
}
// 收到一行 → 累积 + 回调
Some((kind, line)) = rx.recv() => {
match kind {
StreamKind::Stdout => { stdout_buf.push_str(&line); stdout_buf.push('\n'); }
StreamKind::Stderr => { stderr_buf.push_str(&line); stderr_buf.push('\n'); }
}
on_output(kind, &line);
}
}
};
// 防御性 join 读 task(此时必已 EOF 退出,仅保险;失败静默不阻断)
for t in tasks {
let _ = t.await;
}
Ok::<ShellResult, anyhow::Error>(ShellResult {
stdout: stdout_buf,
stderr: stderr_buf,
exit_code: status.code(),
duration_ms: 0, // 外层统一填
})
};
let result = match timeout_secs {
Some(secs) => tokio::time::timeout(std::time::Duration::from_secs(secs), inner)
.await
.map_err(|_| anyhow::anyhow!("命令执行超时({}s): {}", secs, command_for_err))??,
None => inner.await?,
};
let duration = start.elapsed().as_millis() as u64;
Ok(ShellResult {
stdout: result.stdout,
stderr: result.stderr,
exit_code: result.exit_code,
duration_ms: duration,
})
}
+79 -1
View File
@@ -9,7 +9,7 @@
//! //!
//! 注:execute 逻辑本身未改动,此文件为零行为变更的纯新增测试。 //! 注:execute 逻辑本身未改动,此文件为零行为变更的纯新增测试。
use df_execute::shell::{execute, ShellRequest, ShellType}; use df_execute::shell::{execute, execute_streaming, ShellRequest, ShellType, StreamKind};
use std::collections::HashMap; use std::collections::HashMap;
/// 平台默认 ShellType(对齐 shell.rs:31 Default impl:Windows→Cmd, 非 Windows→Sh) /// 平台默认 ShellType(对齐 shell.rs:31 Default impl:Windows→Cmd, 非 Windows→Sh)
@@ -159,3 +159,81 @@ async fn execute_working_dir() {
// 清理 // 清理
let _ = std::fs::remove_dir_all(&tmp_for_cleanup); let _ = std::fs::remove_dir_all(&tmp_for_cleanup);
} }
// ============================================================
// execute_streaming 流式测试
// ============================================================
/// 流式:stdout 多行逐行回调,且 ShellResult 完整(行数对齐 + exit_code=0)。
///
/// 治 run_command 黑盒:验证 spawn 后逐行回调 vs 一次性返回的等价性(行内容 + 完整结果)。
#[tokio::test]
async fn streaming_stdout_lines_callback() {
// 多行输出:Cmd 用多个 echo(用 & 串联无依赖),Sh 用 printf 多行
let cmd = if cfg!(windows) {
"@echo line1 & @echo line2 & @echo line3"
} else {
"printf 'line1\\nline2\\nline3\\n'"
};
let mut lines: Vec<(StreamKind, String)> = Vec::new();
let res = execute_streaming(req(cmd), |kind, line| {
lines.push((kind, line.to_string()));
})
.await
.expect("execute_streaming 应返回 Ok");
assert_eq!(res.exit_code, Some(0), "成功命令 exit_code 应为 0");
// stdout 应含三行(line1/line2/line3)
assert!(res.stdout.contains("line1"), "stdout 应含 line1,实际: {:?}", res.stdout);
assert!(res.stdout.contains("line3"), "stdout 应含 line3,实际: {:?}", res.stdout);
// 回调收到的 stdout 行应含三行(过滤 stderr 干扰:Cmd 无 stderr,Sh 无 stderr)
let stdout_lines: Vec<&String> = lines.iter()
.filter(|(k, _)| *k == StreamKind::Stdout)
.map(|(_, l)| l)
.collect();
assert!(
stdout_lines.iter().any(|l| l.contains("line1")),
"回调应收到含 line1 的 stdout 行,实际: {:?}", stdout_lines
);
assert!(
stdout_lines.iter().any(|l| l.contains("line3")),
"回调应收到含 line3 的 stdout 行,实际: {:?}", stdout_lines
);
}
/// 流式:超时仍生效(timeout_secs=1 + 长睡命令,返回 Err)。
#[tokio::test]
async fn streaming_timeout_returns_err() {
let sleep_cmd = if cfg!(windows) {
"ping -n 5 127.0.0.1 > nul".to_string()
} else {
"sleep 5".to_string()
};
let request = ShellRequest {
command: sleep_cmd,
working_dir: None,
env: HashMap::new(),
timeout_secs: Some(1),
shell_type: Some(default_shell()),
};
let result = execute_streaming(request, |_, _| {}).await;
assert!(result.is_err(), "超时应返回 Err,实际: {:?}", result.as_ref().err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("超时") || msg.to_lowercase().contains("timeout"),
"错误信息应含超时提示,实际: {}",
msg
);
}
/// 流式:非零退出仍返回 Ok + exit_code 非 0(对齐 execute 语义)。
#[tokio::test]
async fn streaming_nonzero_exit() {
let mut callbacks = 0u32;
let res = execute_streaming(req("exit 1"), |_, _| { callbacks += 1; })
.await
.expect("非零退出应仍返回 Ok");
assert_ne!(res.exit_code, Some(0), "exit 1 的 exit_code 应非 0");
// exit 1 无输出,回调可为 0 次(无行)——不强制断言次数,只确认无 panic
let _ = callbacks;
}
+61
View File
@@ -0,0 +1,61 @@
"""SenseNova API 连通性测试 — 无交互版,直接用 http_request 工具替代"""
import json, sys, urllib.request, urllib.error
BASE_URL = "https://api.sensenova.cn/compatible-mode/v1"
# 从命令行参数读 key 和 model
API_KEY = sys.argv[1] if len(sys.argv) > 1 else ""
MODEL = sys.argv[2] if len(sys.argv) > 2 else "SenseNova-Turbo"
if not API_KEY:
print("ERROR: 用法: python test_sensenova.py <api_key> [model_name]")
sys.exit(1)
print(f"Base URL : {BASE_URL}")
print(f"Model : {MODEL}")
print(f"API Key : {API_KEY[:6]}...{API_KEY[-3:]}")
print("-" * 50)
# 测试 1: chat/completions
print("[1/2] chat/completions ...")
url = f"{BASE_URL}/chat/completions"
payload = json.dumps({
"model": MODEL,
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 16,
"stream": False,
}).encode("utf-8")
req = urllib.request.Request(url, data=payload, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", f"Bearer {API_KEY}")
try:
with urllib.request.urlopen(req, timeout=20) as resp:
body = json.loads(resp.read().decode("utf-8"))
content = body.get("choices", [{}])[0].get("message", {}).get("content", "")
print(f" OK! reply: {content}")
except urllib.error.HTTPError as e:
print(f" FAIL HTTP {e.code}: {e.read().decode('utf-8')}")
except Exception as e:
print(f" FAIL: {e}")
# 测试 2: models
print("[2/2] models list ...")
url2 = f"{BASE_URL}/models"
req2 = urllib.request.Request(url2, method="GET")
req2.add_header("Authorization", f"Bearer {API_KEY}")
try:
with urllib.request.urlopen(req2, timeout=15) as resp:
body = json.loads(resp.read().decode("utf-8"))
models = body.get("data", [])
print(f" OK! {len(models)} models:")
for m in models[:10]:
print(f" - {m.get('id', '?')}")
except urllib.error.HTTPError as e:
print(f" FAIL HTTP {e.code}: {e.read().decode('utf-8')}")
except Exception as e:
print(f" FAIL: {e}")
print("-" * 50)
print("Done.")
+3
View File
@@ -61,6 +61,9 @@ keyring = { workspace = true }
# 使用 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"] }
# fetch_url AI 工具:URL → markdown 文档嗅探(GET HTML → htmd 转 markdown → 去噪音 + 截断)。
# 替代 http_request 拿原始 HTML(噪声大、爆 token)。turndown.js 移植,只读 GET,与 http_request 共享 SSRF 防护。
htmd = "0.5"
# AST 代码智能(read_symbol 三态,信息密度驱动,见 docs/02-架构设计/专项设计/AST符号解析-设计-2026-06-24.md): # AST 代码智能(read_symbol 三态,信息密度驱动,见 docs/02-架构设计/专项设计/AST符号解析-设计-2026-06-24.md):
# 治 aichat read_file 全文回灌 prompt 爆(e46f5605 360K/8dfe0b94 5M)。tree-sitter 语法层精准提取 # 治 aichat read_file 全文回灌 prompt 爆(e46f5605 360K/8dfe0b94 5M)。tree-sitter 语法层精准提取
# 符号骨架/下钻/全文,替代物理读全文件。静态编译 + 集中 grammar_for(ext) lookup(不动态加载/不抽 trait,YAGNI)。 # 符号骨架/下钻/全文,替代物理读全文件。静态编译 + 集中 grammar_for(ext) lookup(不动态加载/不抽 trait,YAGNI)。
+2
View File
@@ -9,6 +9,8 @@
"core:event:allow-emit", "core:event:allow-emit",
"core:window:allow-create", "core:window:allow-create",
"core:window:allow-close", "core:window:allow-close",
"core:window:allow-destroy",
"core:window:allow-hide",
"core:window:allow-show", "core:window:allow-show",
"core:window:allow-set-always-on-top", "core:window:allow-set-always-on-top",
"core:window:allow-set-focus", "core:window:allow-set-focus",
+17 -3
View File
@@ -100,6 +100,7 @@ async fn execute_with_heartbeat(
args: serde_json::Value, args: serde_json::Value,
app: &AppHandle, app: &AppHandle,
conv_id: &str, conv_id: &str,
tool_call_id: &str,
) -> anyhow::Result<serde_json::Value> { ) -> anyhow::Result<serde_json::Value> {
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use tokio::time::Duration; use tokio::time::Duration;
@@ -155,7 +156,20 @@ async fn execute_with_heartbeat(
} else { } else {
60 60
}; };
match tokio::time::timeout(Duration::from_secs(outer_secs), tools.execute(name, args)).await { // run_command 实时流式:经 task-local sink 注入(AppHandle + tool_call_id + conv_id),
// handler 内 execute_streaming 每行 emit AiCommandOutput(治执行中黑盒)。
// 非 run_command 不注入 sink(handler 内 emit_output 静默 noop),零行为变更。
let sink = if name == "run_command" {
Some(crate::commands::ai::command_stream::CommandSink::new(
app.clone(),
tool_call_id.to_string(),
Some(conv_id.to_string()),
))
} else {
None
};
let exec_fut = crate::commands::ai::command_stream::scope(sink, tools.execute(name, args));
match tokio::time::timeout(Duration::from_secs(outer_secs), exec_fut).await {
Ok(result) => result, Ok(result) => result,
Err(_elapsed) => { Err(_elapsed) => {
tracing::error!( tracing::error!(
@@ -460,7 +474,7 @@ pub(crate) async fn process_tool_calls(
let app_clone = app_handle.clone(); let app_clone = app_handle.clone();
let conv_clone = conv_id.to_string(); let conv_clone = conv_id.to_string();
async move { async move {
let exec_result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone).await; let exec_result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone, &draft.id).await;
match exec_result { match exec_result {
Ok(val) => { Ok(val) => {
let content = val.to_string(); let content = val.to_string();
@@ -573,7 +587,7 @@ pub(crate) async fn process_tool_calls(
let app_clone = app_handle.clone(); let app_clone = app_handle.clone();
let conv_clone = conv_id.to_string(); let conv_clone = conv_id.to_string();
async move { async move {
let result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone).await; let result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone, &draft.id).await;
match result { match result {
Ok(val) => { Ok(val) => {
// L3 emit 双写:Low 风险工具执行成功 emit Completed 双路发布。 // L3 emit 双写:Low 风险工具执行成功 emit Completed 双路发布。
@@ -0,0 +1,79 @@
//! run_command 实时流式输出 — task-local sink 机制。
//!
//! 治「run_command 执行中黑盒」:execute() 等 exit 才返回整块 stdout/stderr,
//! 长命令(cargo/npm 构建)期间前端只看 Started→Completed,中间进度不可见。
//!
//! 架构约束:工具 handler 注册为 `Box<dyn Fn(Value) -> Future>`(ai_tools.rs:48),
//! 签名只收 args 不收 AppHandle/tool_call_id,无法直接 emit 事件。改 schema 把 id
//! 塞进 args 会泄漏给 LLM,不可取。
//!
//! 本模块用 [`tokio::task_local!`] 解耦:调用方(execute_with_heartbeat / ai_approve)
//! 持有 AppHandle + tool_call_id + conv_id,调 `tools.execute` 前用 [`scope`] 把一个
//! [`CommandSink`] 注入当前 task 上下文;run_command handler 在 tools/file.rs 内
//! 读 task-local(同一 task,因 tools.execute 不 spawn 直接 await handler),
//! 命中则改走 shell `execute_streaming`,每行回调 [`emit_output`] → AiCommandOutput。
//!
//! 未注入 sink(非 run_command / 调用方未配 scope)时 [`emit_output`] 静默 noop,
//! 兜底不报错不阻断。
use tauri::{AppHandle, Emitter, Manager};
use super::AiChatEvent;
use df_execute::shell::StreamKind;
/// 一次 run_command 调用的输出下沉目标(emit 事件所需上下文)。
#[derive(Clone)]
pub struct CommandSink {
app: AppHandle,
tool_call_id: String,
conversation_id: Option<String>,
}
impl CommandSink {
pub fn new(app: AppHandle, tool_call_id: String, conversation_id: Option<String>) -> Self {
Self { app, tool_call_id, conversation_id }
}
/// emit 一行 stdout/stderr(AiCommandOutput,双写 app.emit + ai_event_bus)。
/// emit 失败静默吞(前端未 listen / 总线无订阅不阻断命令执行)。
fn emit(&self, kind: StreamKind, line: &str) {
let ev = AiChatEvent::AiCommandOutput {
id: self.tool_call_id.clone(),
stream: kind.as_str().to_string(),
line: line.to_string(),
conversation_id: self.conversation_id.clone(),
};
let _ = self.app.emit("ai-chat-event", ev.clone());
let _ = self.app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
}
}
// task-local 槽:tools.execute 调用期间(同 task)handler 可读。
// 嵌套 Option:外层是 task-local 是否 set,内层是是否注入 sink(None = 已 scope 但无 sink)。
tokio::task_local! {
static SINK: Option<CommandSink>;
}
/// 在 sink 作用域内执行 future。future 完成后自动清理(无残留)。
///
/// 调用方:execute_with_heartbeat / ai_approve 在调 `tools.execute("run_command", args)`
/// 前包一层 `command_stream::scope(Some(sink), async { tools.execute(...).await })`。
pub async fn scope<F, R>(sink: Option<CommandSink>, fut: F) -> R
where
F: std::future::Future<Output = R>,
{
SINK.scope(sink, fut).await
}
/// run_command handler 读取:回调每行输出 → emit AiCommandOutput。
///
/// task-local 未 set(非经 scope 调用,如直接单测调 handler)或内层 None → 静默 noop。
/// 返回值忽略(emit 失败不阻断命令)。
pub fn emit_output(kind: StreamKind, line: &str) {
// LocalKey::with 在 task-local 未 scope 时返 AccessError,静默吞(noop 兜底)。
let _ = SINK.with(|maybe_sink: &Option<CommandSink>| {
if let Some(sink) = maybe_sink {
sink.emit(kind, line);
}
});
}
+9 -1
View File
@@ -653,9 +653,17 @@ pub async fn ai_approve(
// 防 list_directory 大目录/run_command 慢命令卡死 → 到不了 audit_finalize/emit/try_continue // 防 list_directory 大目录/run_command 慢命令卡死 → 到不了 audit_finalize/emit/try_continue
// → IPC 永挂 → per_conv.generating 永真 → 前端 130s 看门狗静默吞消息(F-260620 同型故障, // → IPC 永挂 → per_conv.generating 永真 → 前端 130s 看门狗静默吞消息(F-260620 同型故障,
// 之前只给 ai_authorize_dir 加超时,ai_approve 漏)。 // 之前只给 ai_authorize_dir 加超时,ai_approve 漏)。
// run_command 实时流式:经 task-local sink 注入(AppHandle + tool_call_id + conv_id),
// handler 内 execute_streaming 每行 emit AiCommandOutput(治执行中黑盒)。非 run_command 不注入。
let sink = if approval.tool_name == "run_command" {
Some(super::super::command_stream::CommandSink::new(
app.clone(), id.clone(), conv_id.clone(),
))
} else { None };
let exec_fut = super::super::command_stream::scope(sink, state.ai_tools.execute(&approval.tool_name, args.clone()));
match tokio::time::timeout( match tokio::time::timeout(
std::time::Duration::from_secs(60), std::time::Duration::from_secs(60),
state.ai_tools.execute(&approval.tool_name, args.clone()), exec_fut,
).await { ).await {
Ok(r) => r, Ok(r) => r,
// 任务2: ai_approve 路径同步 run_command 失败提示(超时为最常见失败场景)。 // 任务2: ai_approve 路径同步 run_command 失败提示(超时为最常见失败场景)。
+658
View File
@@ -0,0 +1,658 @@
//! fetch_url AI 工具 — URL → markdown 文档嗅探(GET HTML → markdown,去噪音 + 截断)
//!
//! 设计目标:让 LLM 高效理解网页文档(API 文档/博客/技术资料),替代 `http_request` 拿原始 HTML。
//! 原始 HTML 充满 nav/footer/script/style/广告等噪声,直接灌进 prompt 既爆 token 又稀释信号;
//! 本工具 GET HTML 后用 htmd(turndown.js 移植)转 markdown,剥离非内容节点 + 按 char 截断,
//! 让 LLM 拿到的是干净文本主体。
//!
//! ## 与 http_request 的分工
//!
//! - `http_request`:结构化 API 调用(GET/POST/鉴权/JSON),拿原始响应 body。写方法有副作用=High。
//! - `fetch_url`:**只读 GET** 网页文档嗅探,输出已清洗的 markdown。Low risk(只读,无副作用)。
//!
//! ## 安全(SSRF 防护 — 与 http_request 共享同一套)
//!
//! 复用 `http.rs` 的 `validate_url` / `resolve_and_check_host` / `build_client` / `execute_with_redirects`:
//! ① 协议白名单(仅 http/https)② 私网 IP 黑名单(RFC1918 + 链路本地 + 环回 + 元数据)③ DNS resolve 后
//! 校验 IP(防 rebinding)④ 重定向 ≤3 跳每跳重校验。fetch_url 只发 GET,无 body/写副作用。
//!
//! ## 去噪音策略
//!
//! htmd 本身会把 script/style/noscript 等非可见节点忽略(转空),但转换后可能残留少量空白行、
//! 重复 nav 链接文本。本工具追加一道轻量清理:折叠 ≥3 连续空行为 2 行、trim 行尾空白。
//! 不做激进的 DOM 删节点(易误伤正文,且 htmd 已处理主要噪声)——稳健优先。
//!
//! ## 截断
//!
//! 按 char 截断(非字节,避免切多字节 UTF-8 中间):默认 max_length=8000 chars,clamp [500, 50000]。
//! 截断尾部加 `... [markdown 已截断]` 标记,LLM 可知内容不完整。
//!
//! ## render 模式(obscura 引导,JS 渲染 SPA/反爬文档)
//!
//! `render=false`(默认)走 reqwest + htmd 静态管线,所有用户可用。
//! `render=true` 时检测本地 `obscura`(用户自带 Rust 无头浏览器,h4ckf0r0day/obscura,自带 V8):
//! ① 已装 → spawn `obscura fetch --dump markdown <URL>` → 拿 stdout → 截断 → 返回(同静态格式)。
//! ② 未装 / 启动失败 / 超时 → 回退静态管线 + `hint` 字段提示装 obscura 可增强 JS 渲染。
//! DevFlow 不打包 obscura(按需引导),保证 render=true 在任何环境都不致工具失败。
use std::collections::HashMap;
use std::time::{Duration, Instant};
use serde_json::{json, Value};
/// 默认 markdown 截断长度(chars)。LLM 单次 fetch 应足以覆盖一篇中等文档的主体。
const DEFAULT_MAX_LENGTH: usize = 8000;
/// markdown 截断下限(chars)。防 LLM 传极小值拿到无意义片段。
const MIN_MAX_LENGTH: usize = 500;
/// markdown 截断上限(chars)。防 LLM 传极大值仍撑爆 context(50K chars ≈ 12-15K tokens)。
const MAX_MAX_LENGTH: usize = 50_000;
/// 默认请求超时(秒)。与 http_request 一致。
const DEFAULT_TIMEOUT_SECS: u64 = 30;
/// 超时硬上限(秒)。与 http_request 一致。
const MAX_TIMEOUT_SECS: u64 = 60;
/// 响应 body 字节上限。HTML 转 markdown 前先挡超大原始响应,防 OOM(50KB markdown 足够,
/// 但 HTML 可能远大于其 markdown,故放宽到 2MB 原始 HTML 上限)。
const MAX_HTML_BYTES: usize = 2 * 1024 * 1024;
/// obscura 子进程超时(秒)。obscura 默认 page timeout=30s,外加 V8 启动/JIT 余量给到 45s。
/// 超时即杀子进程并回退静态管线(render=true 兜底,不让 SPA 拖垮工具)。
const OBSCURA_TIMEOUT_SECS: u64 = 45;
/// obscura 未安装时的引导提示。回退静态管线后通过返回 JSON 的 `hint` 字段透传给 LLM/用户。
const OBSCURA_HINT: &str = "obscura 未安装,已用静态模式。装 obscura(Rust 无头浏览器,自带 V8)可增强 JS 渲染文档(SPA/反爬):见 github.com/h4ckf0r0day/obscura";
/// fetch_url 工具 handler 入口(供 tools/fetch_url.rs register 调用)。
///
/// 参数:
/// - url: 必填,http/https
/// - max_length: 可选,markdown 截断长度 chars(默认 8000,clamp [500, 50000])
/// - render: 可选,bool,默认 false。true 时用 obscura(JS 渲染 SPA/反爬),未装/失败回退静态。
///
/// 返回 {url, title, markdown, length, truncated, render_mode, ?hint}
pub(crate) async fn execute_fetch_url(args: Value) -> 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 不能为空");
}
let max_length = (args.get("max_length").and_then(|v| v.as_u64())
.unwrap_or(DEFAULT_MAX_LENGTH as u64) as usize)
.clamp(MIN_MAX_LENGTH, MAX_MAX_LENGTH);
let timeout_secs = args.get("timeout_secs").and_then(|v| v.as_u64())
.unwrap_or(DEFAULT_TIMEOUT_SECS)
.min(MAX_TIMEOUT_SECS)
.max(1);
let render = args.get("render").and_then(|v| v.as_bool()).unwrap_or(false);
// SSRF 校验对所有模式都必做。校验放入口而非 fetch_static 内,确保 render 分支与静态分支
// 都先过 SSRF(原 fetch_static 内的校验保留作第二道,双重校验对安全是好事,无害)。
let (_, host, port) = crate::commands::ai::http::validate_url(&url_raw)?;
crate::commands::ai::http::resolve_and_check_host(&host, port).await?;
// ── render 分支 ──
if render {
match fetch_with_obscura(&url_raw, max_length).await {
Ok(value) => {
if value.get("render_mode").and_then(|v| v.as_str()) == Some("static_fallback") {
tracing::info!("obscura 回退静态模式:hint={:?}",
value.get("hint").and_then(|v| v.as_str()).unwrap_or(""));
}
return Ok(value);
}
Err(e) => {
// 不该发生(fetch_with_obscura 内部已把所有错误转成回退+hint),但兜底再保险一道。
tracing::warn!("obscura 分支异常({}),回退静态", e);
}
}
}
// ── 静态管线(reqwest + htmd,默认/回退共用) ──
let mut result = fetch_static(&url_raw, max_length, timeout_secs, &args).await?;
if render {
// render=true 但走到了静态管线(未装/失败/异常)→ 标记并塞 hint。
result["render_mode"] = json!("static_fallback");
result["hint"] = json!(OBSCURA_HINT);
} else {
result["render_mode"] = json!("static");
}
Ok(result)
}
/// 静态管线:reqwest GET + htmd 转 markdown + 去噪音 + 截断。
///
/// 从原 execute_fetch_url 抽出,供默认/回退共用。SSRF 校验在 execute_fetch_url 入口已做,
/// 这里保留第二道校验(execute_fetch_url 改动前即有,移除会破坏单元测试触发的早期拒绝路径)。
async fn fetch_static(
url_raw: &str,
max_length: usize,
timeout_secs: u64,
args: &Value,
) -> anyhow::Result<Value> {
// headers: 容许 LLM 传自定义头(如 User-Agent,部分站点拒默认 UA),复用 http_request 同款解析。
let headers: HashMap<String, String> = match args.get("headers") {
Some(Value::Object(m)) => m.iter()
.filter_map(|(k, v)| {
let s = match v {
Value::String(s) => s.clone(),
other => other.to_string(),
};
Some((k.clone(), s))
})
.collect(),
_ => {
// 默认带一个浏览器 UA:不少站点(如 MDN/GitHub)对非浏览器 UA 返回简化/拒绝页面,
// 给一个主流 UA 拿到完整渲染 HTML,提升 markdown 质量。LLM 显式传 headers 则不覆盖。
let mut h = HashMap::new();
h.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(),
);
h
}
};
let body: Option<String> = None; // fetch_url 只 GET,无 body
// ── SSRF 校验(第二道,见函数注释) ──
let (scheme, host, port) = crate::commands::ai::http::validate_url(url_raw)?;
crate::commands::ai::http::resolve_and_check_host(&host, port).await?;
// ── 构建限制 client + 执行(GET,手动重定向循环复用 http.rs) ──
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,
url_raw.to_string(),
&headers,
&body,
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!(
"fetch 失败:HTTP {} {}({})",
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_url 不适合超大原始 HTML(改用 http_request 分段)",
total_bytes, MAX_HTML_BYTES
);
}
// 含 \0 → 二进制(非 HTML),直接拒(PDF/图片等走专用工具,fetch_url 只处理文本网页)
if bytes.contains(&0u8) {
anyhow::bail!("响应为二进制(非 HTML 文本),fetch_url 仅处理网页文档");
}
let html = String::from_utf8_lossy(&bytes).into_owned();
// ── 提取 title(<title>...</title>,正则避免引 HTML 解析重依赖) ──
let title = extract_title(&html);
// ── HTML → markdown(htmd:turndown.js 移植,自动忽略 script/style) ──
// htmd::convert 返回 Result(解析可能失败,如畸形 HTML)。失败时不阻塞 —— 降级用原文 lossy 文本,
// LLM 仍可读到内容(质量差于 markdown 但不致工具整体失败)。
let mut markdown = match htmd::convert(&html) {
Ok(md) => md,
Err(e) => {
tracing::warn!("htmd 转 markdown 失败({}),降级用原始 HTML 文本", e);
html.clone()
}
};
// ── 去噪音:折叠 ≥3 连续空行 → 2 行、trim 行尾空白 ──
markdown = cleanup_markdown(&markdown);
// ── 截断(按 char 边界) ──
let (markdown, truncated) = truncate_chars(&markdown, max_length);
Ok(json!({
"url": final_url,
"scheme": scheme,
"status": status,
"title": title,
"markdown": markdown,
"length": markdown.chars().count(),
"html_bytes": total_bytes,
"truncated": truncated,
"elapsed_ms": elapsed_ms,
}))
}
/// 用 obscura(JS 渲染)抓取 URL 并转 markdown。
///
/// 流程:检测 obscura 在 PATH/已知路径 → spawn `obscura fetch --dump markdown <URL>`(带超时)
/// → 取 stdout → 截断 → 返回与静态管线同构的 JSON(render_mode=obscura)。
///
/// 任何环节失败(未装 / spawn 失败 / 非零退出 / 超时)都不向上抛,而是回退静态管线:
/// 内部调 fetch_static 拿结果,塞 hint 字段提示装 obscura。这样 render=true 在任何环境
/// 都不会因 obscura 缺失/故障而让工具整体失败(引导式安装的核心保证)。
async fn fetch_with_obscura(url_raw: &str, max_length: usize) -> anyhow::Result<Value> {
let started = Instant::now();
// ── 检测 obscura ──
let obscura_path = match find_obscura() {
Some(p) => p,
None => {
// 未装:回退静态 + hint。
let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?;
fallback["render_mode"] = json!("static_fallback");
fallback["hint"] = json!(OBSCURA_HINT);
fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64);
return Ok(fallback);
}
};
// ── spawn obscura(带进程级超时) ──
// obscura fetch --dump markdown <URL>:obscura 内部管 page load timeout(默认 30s),
// 我们外加 tokio timeout 兜底 V8 启动/JIT 卡死等 obscura 自身超时管不到的场景。
let mut cmd = tokio::process::Command::new(&obscura_path);
cmd.args(["fetch", "--dump", "markdown", url_raw])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let output = match tokio::time::timeout(
Duration::from_secs(OBSCURA_TIMEOUT_SECS),
cmd.output(),
).await {
Ok(Ok(o)) => o,
Ok(Err(e)) => {
tracing::warn!("obscura spawn 失败({}),回退静态", e);
let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?;
fallback["render_mode"] = json!("static_fallback");
fallback["hint"] = json!(format!("obscura 启动失败({}),已用静态模式。{}", e, OBSCURA_HINT));
fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64);
return Ok(fallback);
}
Err(_) => {
tracing::warn!("obscura 超时({}s),回退静态", OBSCURA_TIMEOUT_SECS);
let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?;
fallback["render_mode"] = json!("static_fallback");
fallback["hint"] = json!(format!("obscura 渲染超时({}s),已用静态模式。{}", OBSCURA_TIMEOUT_SECS, OBSCURA_HINT));
fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64);
return Ok(fallback);
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!("obscura 非零退出({}),stderr: {}", output.status, stderr.trim());
let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?;
fallback["render_mode"] = json!("static_fallback");
fallback["hint"] = json!(format!("obscura 渲染失败(exit {}),已用静态模式。{}", output.status, OBSCURA_HINT));
fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64);
return Ok(fallback);
}
// ── 取 stdout(已是 markdown) ──
let mut markdown = String::from_utf8_lossy(&output.stdout).into_owned();
// obscura 偶发在 markdown 前后带空白/日志行,trim 首尾;内部仍走 cleanup 折叠空行。
markdown = cleanup_markdown(markdown.trim());
let html_bytes_est = markdown.len(); // obscura 不给原始 HTML 字节数,用 markdown 长度近似(仅信息字段)
// 截断(按 char 边界,与静态一致)
let (markdown, truncated) = truncate_chars(&markdown, max_length);
let elapsed_ms = started.elapsed().as_millis() as u64;
// title:obscura --dump markdown 不单独给 title,从 markdown 首个 H1/首行启发式取;取不到则 null。
let title = extract_title_from_markdown(&markdown);
// scheme:obscura 路径下重新 validate_url 拿 scheme(已在入口校验过,这里仅取字段值,失败兜底 https)。
let scheme = crate::commands::ai::http::validate_url(url_raw)
.map(|(s, _, _)| s)
.unwrap_or_else(|_| "https".to_string());
Ok(json!({
"url": url_raw,
"scheme": scheme,
"status": 200u16, // obscura 成功路径不暴露 HTTP status(已渲染),用 200 占位
"title": title,
"markdown": markdown,
"length": markdown.chars().count(),
"html_bytes": html_bytes_est,
"truncated": truncated,
"elapsed_ms": elapsed_ms,
"render_mode": "obscura",
}))
}
/// 检测 obscura 是否已安装且可执行。优先级:PATH > 已知 npm-global 路径。
///
/// 用 `obscura --version` 探活(比 `which` 跨平台:Windows 无 which,且 --version 能确认
/// 二进制可跑而非仅存在)。同步探活带 5s 超时防卡(对齐 module.rs::run_command 的 thread+channel 风格)。
fn find_obscura() -> Option<String> {
let candidates: &[&str] = &[
"obscura", // PATH 优先(用户 npm i -g 后即在 PATH)
"/d/NodeJS/npm-global/obscura", // 已知安装路径(用户环境,POSIX 形式 git-bash 友好)
"/d/NodeJS/npm-global/obscura.exe",
"D:\\NodeJS\\npm-global\\obscura.exe", // Windows 原生路径(tauri 在 Windows 跑)
];
for cand in candidates {
let (tx, rx) = std::sync::mpsc::channel();
let cand_owned = cand.to_string();
std::thread::spawn(move || {
let out = std::process::Command::new(&cand_owned)
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output();
let _ = tx.send(out);
});
match rx.recv_timeout(Duration::from_secs(5)) {
Ok(Ok(o)) if o.status.success() => return Some(cand.to_string()),
_ => continue,
}
}
None
}
/// 从 markdown 启发式提取 title:首个 `# 一级标题` 或首个非空文本行(≤120 chars)。
/// obscura dump markdown 无独立 title 字段,此为最佳近似(静态管线有 <title>,此函数仅 render 分支用)。
fn extract_title_from_markdown(md: &str) -> Option<String> {
for line in md.lines() {
let t = line.trim();
if t.is_empty() {
continue;
}
// 首个 H1 标题
if let Some(rest) = t.strip_prefix("# ") {
let title = rest.trim();
if !title.is_empty() {
return Some(title.to_string());
}
}
// 否则取首个非空、非表格/代码块的行(去掉 markdown 强调前缀)
if !t.starts_with('|') && !t.starts_with("```") {
let cleaned: String = t.trim_start_matches(|c: char| c == '*' || c == '-').trim().to_string();
if !cleaned.is_empty() && cleaned.chars().count() <= 120 {
return Some(cleaned);
}
}
}
None
}
/// 提取 HTML <title> 内容。case-insensitive,取首个,trim 空白。
/// 用简单字符串扫描而非 regex/HTML 解析:title 标签结构固定且少,正则/解析引依赖不值。
fn extract_title(html: &str) -> Option<String> {
let lower = html.to_lowercase();
let open = lower.find("<title")?;
// 跳过 `<title ...>` 到 '>'(可能含属性,但 <title> 几乎不带属性)
let after_open_tag = &html[open..];
let gt = after_open_tag.find('>')?;
let content_start = open + gt + 1;
let rest = &html[content_start..];
// 找 </title>(case-insensitive)
let rest_lower = &lower[content_start..];
let close = rest_lower.find("</title>")?;
let title = &rest[..close];
let trimmed = title.trim();
if trimmed.is_empty() {
None
} else {
// 去换行/多空格压缩,防 title 跨行带大量空白
let squashed: String = trimmed.split_whitespace().collect::<Vec<_>>().join(" ");
Some(squashed)
}
}
/// 折叠 ≥3 连续空行 → 恰好 2 行,trim 每行尾空白。htmd 输出常有连续空行(nav 块转空)。
fn cleanup_markdown(md: &str) -> String {
let mut out = String::with_capacity(md.len());
let mut blank_run = 0usize;
for line in md.split_inclusive('\n') {
// split_inclusive 保留 '\n';末行可能无 '\n'
let content = line.trim_end_matches('\n');
let is_blank = content.trim().is_empty();
if is_blank {
blank_run += 1;
if blank_run <= 2 {
out.push('\n');
}
// blank_run > 2:丢弃(折叠)
} else {
blank_run = 0;
// trim 行尾空白,保留行首缩进(markdown 列表/代码块缩进有意义)
out.push_str(content.trim_end());
out.push('\n');
}
}
out
}
/// 按 char 边界截断 markdown。返回 (截断后 String, 是否截断)。
/// 不切多字节 UTF-8 中间(char_indices 保证落在 char 边界)。
fn truncate_chars(md: &str, max: usize) -> (String, bool) {
let char_count = md.chars().count();
if char_count <= max {
return (md.to_string(), false);
}
// 找到第 max 个 char 的字节位置
let byte_cutoff = md.char_indices()
.nth(max)
.map(|(idx, _)| idx)
.unwrap_or(md.len());
let head = &md[..byte_cutoff];
let total = char_count;
(
format!(
"{}\n\n... [markdown 已截断,原文 {} chars,仅保留前 {} chars]",
head.trim_end(),
total,
max
),
true,
)
}
// ============================================================
// 单元测试
//
// 覆盖纯函数(extract_title / cleanup_markdown / truncate_chars),确定性零网络。
// handler 集成层走真实网络 #[ignore](对齐 http.rs 测试策略)。
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
// ── extract_title ──
#[test]
fn test_extract_title_basic() {
let html = "<html><head><title>Hello World</title></head><body>x</body></html>";
assert_eq!(extract_title(html).as_deref(), Some("Hello World"));
}
#[test]
fn test_extract_title_case_insensitive() {
let html = "<TITLE>Case Test</TITLE>";
assert_eq!(extract_title(html).as_deref(), Some("Case Test"));
}
#[test]
fn test_extract_title_multiline_squashed() {
let html = "<title>\n Multi\n Line Title \n</title>";
assert_eq!(extract_title(html).as_deref(), Some("Multi Line Title"));
}
#[test]
fn test_extract_title_missing_returns_none() {
let html = "<html><body>no title here</body></html>";
assert_eq!(extract_title(html), None);
}
#[test]
fn test_extract_title_empty_returns_none() {
let html = "<title> </title>";
assert_eq!(extract_title(html), None);
}
// ── cleanup_markdown ──
#[test]
fn test_cleanup_collapses_many_blank_lines() {
let md = "para1\n\n\n\n\npara2";
let out = cleanup_markdown(md);
// 4 连续空行折叠为 2
assert_eq!(out, "para1\n\n\npara2\n");
}
#[test]
fn test_cleanup_trims_trailing_whitespace() {
let md = "line with trailing spaces \nnext";
let out = cleanup_markdown(md);
assert_eq!(out, "line with trailing spaces\nnext\n");
}
#[test]
fn test_cleanup_preserves_leading_indent() {
// markdown 代码块/列表缩进有意义,不能 trim 行首
let md = " code line\n- list item";
let out = cleanup_markdown(md);
assert_eq!(out, " code line\n- list item\n");
}
#[test]
fn test_cleanup_keeps_two_blank_lines() {
let md = "a\n\n\nb";
let out = cleanup_markdown(md);
assert_eq!(out, "a\n\n\nb\n");
}
// ── extract_title_from_markdown(仅 render=obscura 分支用) ──
#[test]
fn test_md_title_from_h1() {
let md = "# Rust 异步编程指南\n\n正文内容";
assert_eq!(extract_title_from_markdown(md).as_deref(), Some("Rust 异步编程指南"));
}
#[test]
fn test_md_title_fallback_first_line() {
// 无 H1,取首个非空行(去掉强调前缀)
let md = "**Welcome** to my site\n\n更多内容";
assert_eq!(extract_title_from_markdown(md).as_deref(), Some("Welcome** to my site"));
}
#[test]
fn test_md_title_skips_blank_and_code() {
let md = "\n\n```\ncode block\n```\n# Real Title\nbody";
assert_eq!(extract_title_from_markdown(md).as_deref(), Some("Real Title"));
}
#[test]
fn test_md_title_empty_returns_none() {
assert_eq!(extract_title_from_markdown(""), None);
assert_eq!(extract_title_from_markdown(" \n\n "), None);
}
// ── truncate_chars ──
#[test]
fn test_truncate_under_limit_unchanged() {
let md = "short content";
let (out, trunc) = truncate_chars(md, 1000);
assert!(!trunc);
assert_eq!(out, "short content");
}
#[test]
fn test_truncate_over_limit_marks_truncated() {
let md = "abcdefghij".repeat(1000); // 10000 chars
let (out, trunc) = truncate_chars(&md, 100);
assert!(trunc);
assert!(out.contains("[markdown 已截断"));
assert!(out.contains("原文 10000 chars"));
assert!(out.contains("仅保留前 100 chars"));
}
#[test]
fn test_truncate_respects_char_boundary_unicode() {
// 中文每字 3 字节,截 5 char 应得 15 字节(不在中间切)
let md = "你好世界测试内容"; // 8 chars,每字 3 字节
let (out, trunc) = truncate_chars(md, 5);
assert!(trunc);
// 截断点应在 "你好世界测" 后(5 chars = 15 bytes),无半个字
assert!(out.starts_with("你好世界测\n\n"));
// 不应出现 U+FFFD(乱码替换符)
assert!(!out.contains('\u{FFFD}'));
}
// ── handler 参数边界 ──
#[tokio::test]
async fn test_handler_missing_url_errors() {
let args = json!({ "max_length": 1000 });
let err = execute_fetch_url(args).await.unwrap_err();
assert!(format!("{}", err).contains("缺少 url"));
}
#[tokio::test]
async fn test_handler_empty_url_errors() {
let args = json!({ "url": " " });
let err = execute_fetch_url(args).await.unwrap_err();
assert!(format!("{}", err).contains("url 不能为空"));
}
#[tokio::test]
async fn test_handler_rejects_localhost() {
let args = json!({ "url": "http://localhost:8080/" });
let err = execute_fetch_url(args).await.unwrap_err();
assert!(format!("{}", err).contains("localhost") || format!("{}", err).contains("SSRF"));
}
#[tokio::test]
async fn test_handler_rejects_private_ip() {
let args = json!({ "url": "http://169.254.169.254/latest/" });
let err = execute_fetch_url(args).await.unwrap_err();
assert!(format!("{}", err).contains("私网") || format!("{}", err).contains("SSRF"));
}
#[tokio::test]
async fn test_handler_rejects_non_http_scheme() {
let args = json!({ "url": "file:///etc/passwd" });
let err = execute_fetch_url(args).await.unwrap_err();
assert!(format!("{}", err).contains("协议"));
}
#[tokio::test]
async fn test_handler_max_length_clamped_low() {
// max_length=1 应 clamp 到 500,不发请求只验证参数解析不 panic(用 localhost 触发 SSRF 早期拒绝)
let args = json!({ "url": "http://localhost/", "max_length": 1 });
let _ = execute_fetch_url(args).await; // 不 panic 即通过
}
#[tokio::test]
async fn test_handler_max_length_clamped_high() {
let args = json!({ "url": "http://localhost/", "max_length": 999999 });
let _ = execute_fetch_url(args).await; // 不 panic 即通过
}
// ── 真实网络集成(#[ignore]:CI 无网时跳过) ──
#[tokio::test]
#[ignore = "需真实网络(example.com),CI 无网时跳过:cargo test -- --ignored"]
async fn integration_fetch_example_com_markdown() {
let args = json!({ "url": "https://example.com/" });
let result = execute_fetch_url(args).await.expect("fetch example.com 应成功");
assert_eq!(result["status"].as_u64().unwrap(), 200);
assert_eq!(result["title"].as_str().unwrap_or(""), "Example Domain");
let md = result["markdown"].as_str().unwrap_or("");
assert!(md.contains("Example Domain"));
assert!(!md.contains("<html")); // 应是 markdown 非 HTML
assert_eq!(result["truncated"], false);
}
}
+5 -5
View File
@@ -38,7 +38,7 @@ const DEFAULT_TIMEOUT_SECS: u64 = 30;
/// 超时硬上限(秒)。防 LLM 传超大值冻结会话(对齐 run_command MAX 思路,封顶更紧)。 /// 超时硬上限(秒)。防 LLM 传超大值冻结会话(对齐 run_command MAX 思路,封顶更紧)。
const MAX_TIMEOUT_SECS: u64 = 60; const MAX_TIMEOUT_SECS: u64 = 60;
/// 重定向最大跳转数。防开放重定向被利用做 SSRF 中转(302 链绕过到内网)。 /// 重定向最大跳转数。防开放重定向被利用做 SSRF 中转(302 链绕过到内网)。
const MAX_REDIRECTS: usize = 3; pub(crate) const MAX_REDIRECTS: usize = 3;
/// body/headers 各自大小上限(防 LLM 传超大请求体)。 /// body/headers 各自大小上限(防 LLM 传超大请求体)。
const MAX_REQUEST_BODY_BYTES: usize = 1 * 1024 * 1024; // 1MB const MAX_REQUEST_BODY_BYTES: usize = 1 * 1024 * 1024; // 1MB
@@ -85,7 +85,7 @@ pub(crate) fn is_private_ip(ip: &IpAddr) -> bool {
/// (词法层只挡字面量 IP/localhost,DNS rebinding 靠 resolve 后校验补防)。 /// (词法层只挡字面量 IP/localhost,DNS rebinding 靠 resolve 后校验补防)。
/// ///
/// 安全顺序:① scheme 白名单 ② host 非空 ③ 字面量 IP 私网拦截 ④ localhost/.*local 等域名拦截。 /// 安全顺序:① scheme 白名单 ② host 非空 ③ 字面量 IP 私网拦截 ④ localhost/.*local 等域名拦截。
fn validate_url(raw: &str) -> anyhow::Result<(String, String, u16)> { pub(crate) fn validate_url(raw: &str) -> anyhow::Result<(String, String, u16)> {
let parsed = reqwest::Url::parse(raw) let parsed = reqwest::Url::parse(raw)
.map_err(|e| anyhow::anyhow!("URL 解析失败: {} ({})", raw, e))?; .map_err(|e| anyhow::anyhow!("URL 解析失败: {} ({})", raw, e))?;
let scheme = parsed.scheme().to_lowercase(); let scheme = parsed.scheme().to_lowercase();
@@ -123,7 +123,7 @@ fn validate_url(raw: &str) -> anyhow::Result<(String, String, u16)> {
/// ///
/// 注:用 blocking resolve(tokio::net 已在 runtime,但单次 resolve 短且同步 DNS 系统调用, /// 注:用 blocking resolve(tokio::net 已在 runtime,但单次 resolve 短且同步 DNS 系统调用,
/// spawn_blocking 会增加调度开销)。直接用 tokio::net::lookup_host 异步解析。 /// spawn_blocking 会增加调度开销)。直接用 tokio::net::lookup_host 异步解析。
async fn resolve_and_check_host(host: &str, port: u16) -> anyhow::Result<Vec<std::net::SocketAddr>> { pub(crate) async fn resolve_and_check_host(host: &str, port: u16) -> anyhow::Result<Vec<std::net::SocketAddr>> {
// lookup_host 需 host:port 形式;host 可能含 IPv6 字面量,SocketAddr::to_string 会自动加 [] // lookup_host 需 host:port 形式;host 可能含 IPv6 字面量,SocketAddr::to_string 会自动加 []
let target = format!("{}:{}", host, port); let target = format!("{}:{}", host, port);
let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(target) let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(target)
@@ -149,7 +149,7 @@ async fn resolve_and_check_host(host: &str, port: u16) -> anyhow::Result<Vec<std
/// ///
/// reqwest 默认 follow ≤10 重定向且不暴露每跳 URL 校验钩子,故 Policy::none 关闭自动跟随, /// reqwest 默认 follow ≤10 重定向且不暴露每跳 URL 校验钩子,故 Policy::none 关闭自动跟随,
/// 在 execute_with_redirects 循环里逐跳校验 Location。 /// 在 execute_with_redirects 循环里逐跳校验 Location。
fn build_client(timeout: Duration) -> anyhow::Result<reqwest::Client> { pub(crate) fn build_client(timeout: Duration) -> anyhow::Result<reqwest::Client> {
reqwest::Client::builder() reqwest::Client::builder()
.timeout(timeout) .timeout(timeout)
.connect_timeout(Duration::from_secs(15)) .connect_timeout(Duration::from_secs(15))
@@ -162,7 +162,7 @@ fn build_client(timeout: Duration) -> anyhow::Result<reqwest::Client> {
/// ///
/// 返回最终响应(reqwest::Response)。重定向链每跳重新 validate_url + resolve_and_check_host, /// 返回最终响应(reqwest::Response)。重定向链每跳重新 validate_url + resolve_and_check_host,
/// 防 302 Location: http://127.0.0.1/ 绕过初始 URL 校验。 /// 防 302 Location: http://127.0.0.1/ 绕过初始 URL 校验。
async fn execute_with_redirects( pub(crate) async fn execute_with_redirects(
client: &reqwest::Client, client: &reqwest::Client,
method: reqwest::Method, method: reqwest::Method,
initial_url: String, initial_url: String,
+15
View File
@@ -30,11 +30,13 @@ pub mod agentic;
pub mod augmentation; pub mod augmentation;
pub mod audit; pub mod audit;
pub mod code_intel; pub mod code_intel;
pub mod command_stream;
pub mod commands; pub mod commands;
pub mod compress; pub mod compress;
pub mod conversation; pub mod conversation;
pub mod event_bus; pub mod event_bus;
pub mod http; pub mod http;
pub mod fetch_url;
pub mod knowledge_inject; pub mod knowledge_inject;
pub mod prompt; pub mod prompt;
pub mod provider_pool; pub mod provider_pool;
@@ -119,6 +121,19 @@ pub enum AiChatEvent {
AiToolCallStarted { id: String, name: String, args: serde_json::Value, conversation_id: Option<String> }, AiToolCallStarted { id: String, name: String, args: serde_json::Value, conversation_id: Option<String> },
/// 工具调用完成 /// 工具调用完成
AiToolCallCompleted { id: String, result: serde_json::Value, conversation_id: Option<String> }, AiToolCallCompleted { id: String, result: serde_json::Value, conversation_id: Option<String> },
/// run_command 实时流式输出(治执行中黑盒)。
///
/// execute_with_heartbeat / ai_approve 调用 run_command 前,经 task-local sink 注入
/// (AppHandle + tool_call_id + conv_id)。run_command handler 读 task-local,改走 shell
/// `execute_streaming`,每读一行 stdout/stderr 即 emit 本事件(双写 app.emit + ai_event_bus)。
/// 前端 listen 此事件可在工具卡 Started→Completed 间实时展示进度(编译/构建输出)。
///
/// 字段:
/// - `id`:tool_call_id(对齐 Started/Completed,前端按 id 路由到对应工具卡)
/// - `stream`:"stdout" | "stderr"(来源流,前端可差异化着色)
/// - `line`:单行内容(已去换行,行级粒度 emit)
/// - `conversation_id`:多对话路由
AiCommandOutput { id: String, stream: String, line: String, conversation_id: Option<String> },
/// 会话级信任自动放行(AE-2025-04 Session Trust): /// 会话级信任自动放行(AE-2025-04 Session Trust):
/// 同会话已批准过同类操作(同工具+同目录),下次命中自动放行,前端显示轻量 toast。 /// 同会话已批准过同类操作(同工具+同目录),下次命中自动放行,前端显示轻量 toast。
/// 与 AiToolCallStarted/Completed 正交——这三个事件描述一次信任放行调用生命周期: /// 与 AiToolCallStarted/Completed 正交——这三个事件描述一次信任放行调用生命周期:
+58
View File
@@ -116,6 +116,40 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
} }
} }
/// 文档探索策略段(中/英):引导 AI 收到 URL 时走「嗅探→识别→提取→验证→应用」,
/// 而非盲目对 URL 发 http_request 瞎试。
///
/// 机制治症状:实测 AI 收到产品/文档 URL 时,会直接 http_request 打原始 URL(HTML 噪声爆 token、
/// 也调不出真正 API 端点),应先用 fetch_url 转成可读 markdown 理解文档,提取 base_url/鉴权/
/// 端点路径后,再用 http_request 打正确端点验证。
///
/// - fetch_url = 读文档(URL→markdown,去 HTML 噪声);http_request = 调 API(POST/鉴权/原始响应)
/// - 不瞎试 URL:先读文档提取端点,再验证;杜绝未读文档即对原始 URL 盲发请求
///
/// 仅段加在 prefix 末尾(聚焦段后),不破坏现有结构。
fn doc_exploration_strategy_section(lang: &str) -> &'static str {
match lang {
"en" => "\n\
## Documentation Exploration Strategy\n\
When the user gives you a URL (product page / API docs / SDK guide), follow this flow instead of blindly firing http_request at the raw URL:\n\
1. **Sniff**: call `fetch_url` to turn the URL into clean markdown (strips HTML noise) and read what the page actually says.\n\
2. **Identify intent**: is it an API doc (endpoints/auth), a product capability page, a config spec, or a code sample?\n\
3. **Extract keys**: pull out base_url, provider_type, auth scheme, model list, endpoint paths write them down explicitly before calling anything.\n\
4. **Verify**: only now use `http_request` against the correct endpoint you extracted (e.g. `GET {base_url}/v1/models` with the auth header) to confirm it works.\n\
5. **Apply**: configure the Provider / write code / transcribe into a file via `write_file`.\n\
Rule: `fetch_url` is for *reading* a URL (docs/web/API description, markdown output); `http_request` is for *calling* an API (POST/auth/raw response). Never guess an endpoint read the doc first, extract, then verify.\n",
_ => "\n\
## \n\
URL(/API /SDK ),, URL http_request :\n\
1. ****: `fetch_url` URL markdown( HTML ),\n\
2. ****: API (/),?\n\
3. ****: base_urlprovider_type,\n\
4. ****: `http_request` ( `GET {base_url}/v1/models`)\n\
5. ****: Provider / / (write_file)\n\
:`fetch_url` **** URL(//API , markdown);`http_request` **** API(POST//),\n",
}
}
/// 构建系统提示词(环境信息 + 固定前缀 + 当前项目/任务**全局清单**) /// 构建系统提示词(环境信息 + 固定前缀 + 当前项目/任务**全局清单**)
/// ///
/// 本函数注入"全貌"清单:最近 20 项目 + 20 任务的 name/status/description(无 path), /// 本函数注入"全貌"清单:最近 20 项目 + 20 任务的 name/status/description(无 path),
@@ -155,6 +189,8 @@ pub(crate) async fn build_system_prompt_with_excluded(
let (prefix, proj_label, task_label) = system_prompt_parts(lang); let (prefix, proj_label, task_label) = system_prompt_parts(lang);
let mut prompt = env_profile_line(); let mut prompt = env_profile_line();
prompt.push_str(prefix); prompt.push_str(prefix);
// 文档探索策略段(URL→fetch_url 嗅探→识别→提取→验证→应用,治盲目 http_request 瞎试)
prompt.push_str(doc_exploration_strategy_section(lang));
// 附加当前数据上下文 // 附加当前数据上下文
if let Ok(projects) = state.projects.list_active().await { if let Ok(projects) = state.projects.list_active().await {
@@ -343,6 +379,28 @@ mod tests {
assert!(prefix.contains("聚焦准则")); assert!(prefix.contains("聚焦准则"));
} }
// 文档探索策略段(中/英)存在且含关键引导
#[test]
fn doc_exploration_strategy_section_present_zh_en() {
let zh = doc_exploration_strategy_section("zh-CN");
assert!(zh.contains("## 文档探索策略"));
assert!(zh.contains("fetch_url"));
assert!(zh.contains("http_request"));
assert!(zh.contains("嗅探"));
assert!(zh.contains("识别意图"));
assert!(zh.contains("提取关键"));
assert!(zh.contains("验证"));
assert!(zh.contains("应用"));
let en = doc_exploration_strategy_section("en");
assert!(en.contains("## Documentation Exploration Strategy"));
assert!(en.contains("Sniff"));
assert!(en.contains("Identify intent"));
assert!(en.contains("Extract keys"));
assert!(en.contains("Verify"));
assert!(en.contains("Apply"));
}
#[test] #[test]
fn compress_prompt_unaffected_by_focus_addition() { fn compress_prompt_unaffected_by_focus_addition() {
// 压缩 prompt 是独立函数,聚焦段改动不应波及 // 压缩 prompt 是独立函数,聚焦段改动不应波及
+22 -3
View File
@@ -457,6 +457,7 @@ pub fn build_ai_tool_registry(
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);
register_fetch_url_tool(&mut registry);
registry registry
} }
@@ -476,6 +477,19 @@ fn register_http_tools(registry: &mut AiToolRegistry) {
super::tools::http::register(registry); super::tools::http::register(registry);
} }
/// fetch_url AI 工具注册(1 个:URL → markdown 文档嗅探)— 只读 GET 网页文档。
/// 不持 db,纯 reqwest GET + htmd HTML→markdown 转换。SSRF 防护复用 commands/ai/http.rs
/// (validate_url / resolve_and_check_host / execute_with_redirects,经 pub(crate) 暴露)。
///
/// 风险:Low(只读 GET,无副作用)。与 http_request 分工:fetch_url 输出已清洗 markdown 供 LLM 理解文档,
/// http_request 输出原始响应 body 供 API 调用(POST/鉴权)。
///
/// tool_registry 拆分:声明式注册(tools/fetch_url.rs),handler 在 commands/ai/fetch_url.rs。
/// 基线测试 test_build_ai_tool_registry_baseline_tool_count 守护总量 + 工具名集合稳定。
fn register_fetch_url_tool(registry: &mut AiToolRegistry) {
super::tools::fetch_url::register(registry);
}
/// 数据层 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(列表/创建/更新/删除/状态推进/工作流/任务关联)。
@@ -1117,7 +1131,7 @@ mod tests {
// 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。 // 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。
// ============================================================ // ============================================================
/// build_ai_tool_registry 应注册恰好 41 个工具(27 data + 13 file + 1 http),且工具名集合稳定。 /// build_ai_tool_registry 应注册恰好 51 个工具(36 data + 13 file + 1 http + 1 fetch_url),且工具名集合稳定。
/// ///
/// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db, /// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db,
// 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。 // 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。
@@ -1151,10 +1165,13 @@ mod tests {
// update_idea/delete_idea(2026-08-01): data 层 34→36(补 AI 改/删灵感专用工具, // update_idea/delete_idea(2026-08-01): data 层 34→36(补 AI 改/删灵感专用工具,
// idea_repo.update_field / soft_delete 早已具备,工具层此前缺失致 AI 误用 update_task)。 // idea_repo.update_field / soft_delete 早已具备,工具层此前缺失致 AI 误用 update_task)。
// 50 = 36 data + 13 file + 1 http。 // 50 = 36 data + 13 file + 1 http。
// fetch_url(2026-08-01): http 层 1→2(新增 URL→markdown 文档嗅探,只读 GET,
// htmd HTML→markdown + 去噪音 + 截断,SSRF 复用 http.rs)。
// 51 = 36 data + 13 file + 1 http + 1 fetch_url。
assert_eq!( assert_eq!(
registry.len(), registry.len(),
50, 51,
"工具总数应为 50(36 data + 13 file + 1 http),实际 {}", registry.len() "工具总数应为 51(36 data + 13 file + 1 http + 1 fetch_url),实际 {}", registry.len()
); );
// 工具名集合基线:防 rename / 漏注册 / 误删除。 // 工具名集合基线:防 rename / 漏注册 / 误删除。
@@ -1192,6 +1209,8 @@ mod tests {
"grep", "detect_environment", "grep", "detect_environment",
// ── http 层 (1) ── // ── http 层 (1) ──
"http_request", "http_request",
// ── fetch_url 层 (1) ──(URL → markdown 文档嗅探,只读 GET,与 http_request 分工)
"fetch_url",
]; ];
expected.sort_unstable(); expected.sort_unstable();
@@ -0,0 +1,51 @@
//! fetch_url AI 工具声明式注册(URL → markdown 文档嗅探,只读 GET)。
//!
//! 工具职责:GET 网页 URL → HTML 转 markdown(htmd)→ 去噪音 + 截断 → 返回 {url,title,markdown,...}。
//! 让 LLM 高效理解网页文档,替代 http_request 拿原始 HTML(噪声大、爆 token)。
//!
//! 风险:Low(只读 GET,无副作用)。SSRF 防护与 http_request 共享同一套(validate_url +
//! resolve_and_check_host + 重定向每跳校验,见 commands/ai/http.rs)。
//!
//! handler body 在 commands/ai/fetch_url.rs::execute_fetch_url,声明式注册收敛样板。
use std::sync::Arc;
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
use df_ai::declare_tool;
/// 注册 fetch_url 工具到 `$registry`(无 db 捕获,纯网络 GET + htmd 转换)。
///
/// - name/desc/schema 面向 LLM 的工具说明
/// - risk=Low(只读 GET)
/// - handler 转调 fetch_url.rs::execute_fetch_url(SSRF 防护 + htmd + 截断全在那)
pub fn register(registry: &mut AiToolRegistry) {
// 无捕获:占位 Arc<()>(handler 不持 db,仅转调 fetch_url.rs)。
let dummy: Arc<()> = Arc::new(());
// schema:url(必填)+ max_length(可选,默认 8000)+ render(可选,默认 false)。
// headers 用手工 serde_json::Map 表达(additionalProperties 需对象 schema,object_schema 不支持)。
let schema = {
let mut props = serde_json::Map::new();
props.insert("url".into(), serde_json::json!({ "type": "string", "description": "要获取的网页 URL,仅 http/https(拒私网/localhost,SSRF 防护)" }));
props.insert("max_length".into(), serde_json::json!({ "type": "integer", "description": "返回 markdown 的截断长度(chars,默认 8000,clamp [500,50000])", "minimum": 500, "maximum": 50000, "default": 8000 }));
props.insert("render".into(), serde_json::json!({ "type": "boolean", "description": "是否用 obscura(JS 渲染)抓取,默认 false(静态 reqwest+htmd)。render=true 适合 SPA/JS 动态渲染/反爬文档:检测本地 obscura(Rust 无头浏览器,自带 V8),已装则 spawn 渲染,未装/失败/超时自动回退静态并附 hint。需装 obscura:github.com/h4ckf0r0day/obscura", "default": false }));
props.insert("headers".into(), serde_json::json!({ "type": "object", "description": "可选请求头 map<string,string>(如自定义 User-Agent)。默认带主流浏览器 UA 以拿完整渲染 HTML(仅静态模式生效,render=true 时由 obscura 自管 UA)", "additionalProperties": { "type": "string" } }));
props.insert("timeout_secs".into(), serde_json::json!({ "type": "integer", "description": "超时秒数(默认 30,上限 60,仅静态模式生效;render=true 用固定 45s obscura 超时)", "minimum": 1, "maximum": 60 }));
serde_json::json!({
"type": "object",
"properties": props,
"required": ["url"],
})
};
declare_tool!(
registry,
dummy: Arc<()>,
"fetch_url",
"获取网页 URL 内容并转为 markdown(HTML 清洗 + 截断),用于高效理解网页文档(API 文档/博客/技术资料)。只读 GET,自动剥离 script/style/nav 等噪声,提取 title,按 max_length 截断。返回 {url, title, markdown, length, truncated, render_mode, ?hint}。安全:仅 http/https,拒绝私网/保留 IP(SSRF 防护含 DNS resolve 后校验),重定向≤3 跳。render=true 用 obscura 渲染 JS(SPA/反爬,需装 obscura,未装自动回退静态)。如需 POST/鉴权/原始响应用 http_request",
RiskLevel::Low,
schema: schema,
args => {
// 转调 fetch_url.rs handler(SSRF 防护 + htmd 转换 + 去噪音 + 截断全在那)
crate::commands::ai::fetch_url::execute_fetch_url(args).await
}
);
}
+10 -4
View File
@@ -45,9 +45,10 @@ use crate::commands::ai::tool_registry::{
FILE_LOCKS, FileGrepHit, FILE_LOCKS, FileGrepHit,
}; };
// run_command 依赖 df_execute::shell::{execute, ShellRequest} + std HashMap // run_command 依赖 df_execute::shell::{execute_streaming, ShellRequest, StreamKind} + std HashMap
// new_id: delete_file 软删除备份命名(原 register_file_tools 闭包引用,经 df_types::types::new_id) // execute_streaming:run_command 专用流式(spawn 逐行读 stdout/stderr,回调 emit AiCommandOutput 治执行黑盒)。
use df_execute::shell::{execute, ShellRequest}; // new_id: delete_file 软删除备份命名(原 register_file_tools 闭包引用,经 df_types::types::new_id)。
use df_execute::shell::{execute_streaming, ShellRequest, StreamKind};
use df_types::types::new_id; use df_types::types::new_id;
use std::collections::HashMap; use std::collections::HashMap;
@@ -1032,7 +1033,12 @@ pub fn register(
} else { } else {
"" ""
}; };
let result = execute(request).await.map_err(|e| { let result = execute_streaming(request, |kind: StreamKind, line: &str| {
// 实时流式 emit:每读一行 stdout/stderr 即 emit AiCommandOutput。
// task-local 未注入(command_stream::scope 未调用)→ emit_output 静默 noop,
// 不阻断命令(等价原 execute 一次性返回,无副作用)。
crate::commands::ai::command_stream::emit_output(kind, line);
}).await.map_err(|e| {
let msg = e.to_string(); let msg = e.to_string();
if msg.contains("命令执行超时") { if msg.contains("命令执行超时") {
anyhow::anyhow!( anyhow::anyhow!(
+1
View File
@@ -14,6 +14,7 @@ pub mod task;
pub mod task_graph; pub mod task_graph;
pub mod git; pub mod git;
pub mod http; pub mod http;
pub mod fetch_url;
pub mod workflow; pub mod workflow;
pub mod idea; pub mod idea;
pub mod trash; pub mod trash;
+37
View File
@@ -68,6 +68,16 @@
<label class="form-label">{{ $t('settings.labelName') }}</label> <label class="form-label">{{ $t('settings.labelName') }}</label>
<input v-model="providerForm.name" class="setting-input" :placeholder="$t('settings.phProviderName')" /> <input v-model="providerForm.name" class="setting-input" :placeholder="$t('settings.phProviderName')" />
</div> </div>
<!-- D:厂商预设下拉 选中自动填充 providerType/baseUrl/defaultModel(用户可改覆盖)
新建态独立"快捷选择";编辑态回填后切预设仍可覆盖(等价快捷填充),不阻塞手改
"自定义" 不预填任何字段 -->
<div class="form-field">
<label class="form-label">{{ $t('settings.labelPreset') }}</label>
<select v-model="providerForm.presetId" class="setting-select" @change="onPresetChange">
<option value="__custom">{{ $t('settings.presetCustom') }}</option>
<option v-for="p in PROVIDER_PRESETS" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div class="form-field"> <div class="form-field">
<label class="form-label">{{ $t('settings.labelProviderType') }}</label> <label class="form-label">{{ $t('settings.labelProviderType') }}</label>
<select v-model="providerForm.providerType" class="setting-select"> <select v-model="providerForm.providerType" class="setting-select">
@@ -136,6 +146,7 @@ import { reactive, ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { aiApi } from '@/api' import { aiApi } from '@/api'
import type { AiProviderConfig, ModelConfig } from '@/api/types' import type { AiProviderConfig, ModelConfig } from '@/api/types'
import { PROVIDER_PRESETS, CUSTOM_PRESET_ID, findPreset } from './providerPresets'
// ============================================================ // ============================================================
// AI CRUD + F-04c (enabled / weight) // AI CRUD + F-04c (enabled / weight)
@@ -174,6 +185,10 @@ const providerForm = reactive({
baseUrl: '', baseUrl: '',
apiKey: '', apiKey: '',
defaultModel: '', defaultModel: '',
// D:(__custom=; id ,
// onPresetChange ; presetId ,
// , presetId )
presetId: CUSTOM_PRESET_ID,
saving: false, saving: false,
// F-01 6: + // F-01 6: +
fetching: false, fetching: false,
@@ -205,6 +220,9 @@ function openProviderForm(p?: AiProviderConfig) {
providerForm.apiKey = '' // api_key(list mask,=,FR-S1) providerForm.apiKey = '' // api_key(list mask,=,FR-S1)
providerForm.defaultModel = p.default_model providerForm.defaultModel = p.default_model
providerForm.providerType = p.provider_type || 'openai_compat' providerForm.providerType = p.provider_type || 'openai_compat'
// D:( baseUrl/model );
// "",
providerForm.presetId = CUSTOM_PRESET_ID
// F-01 6: model_configs() // F-01 6: model_configs()
providerForm.models = (p.model_configs ?? []).map(m => ({ ...m, modalities: [...m.modalities], capabilities: [...m.capabilities] })) providerForm.models = (p.model_configs ?? []).map(m => ({ ...m, modalities: [...m.modalities], capabilities: [...m.capabilities] }))
} else { } else {
@@ -214,6 +232,7 @@ function openProviderForm(p?: AiProviderConfig) {
providerForm.baseUrl = '' providerForm.baseUrl = ''
providerForm.apiKey = '' providerForm.apiKey = ''
providerForm.defaultModel = '' providerForm.defaultModel = ''
providerForm.presetId = CUSTOM_PRESET_ID
providerForm.models = [] providerForm.models = []
} }
providerForm.fetching = false providerForm.fetching = false
@@ -221,6 +240,24 @@ function openProviderForm(p?: AiProviderConfig) {
providerForm.visible = true providerForm.visible = true
} }
/**
* D:厂商预设下拉变更 命中预设则覆盖填充 providerType/baseUrl/defaultModel
*
* 设计取舍:
* - 覆盖而非合并:选预设=用户明确意图用某厂商端点,全量填入最省心;用户随后仍可手改任一字段
* - 不动 name/apiKey:name 用户应自取别名(避免重名"DeepSeek"×N);apiKey 是密钥预设无可填
* - "自定义"不动作(仅清字段是粗暴的;用户可能误点,留原值更安全)
* - 不复位 presetId:用户手改 baseUrl 等后下拉仍标原预设这无副作用(提交不读 presetId),
* 强行复位反而要监听每个字段 input,过度工程
*/
function onPresetChange() {
const preset = findPreset(providerForm.presetId)
if (!preset) return // __custom id:
providerForm.providerType = preset.providerType
providerForm.baseUrl = preset.baseUrl
providerForm.defaultModel = preset.defaultModel
}
/** /**
* 测试连接按钮可用条件(P0-1): * 测试连接按钮可用条件(P0-1):
* - baseUrl 必填(无论新建/编辑,表单值都要有) * - baseUrl 必填(无论新建/编辑,表单值都要有)
@@ -0,0 +1,86 @@
/**
* ( D:文档探索失败时兜底 + )
*
* providerType / baseUrl / defaultModel;
* "自定义" ()endpoints ,:
*
* - OpenAI openai_compat https://api.openai.com (openai_compat.rs:45 / presets/models.json)
* - GLM/ openai_compat https://open.bigmodel.cn/api/paas/v4 (model_fetch_helpers.rs:178,实测端点)
* - DeepSeek openai_compat https://api.deepseek.com (openai_compat.rs:45 / model_fetch_helpers.rs:187)
* - SenseNova openai_compat https://api.sensenova.cn/compatible-mode/v1 (scripts/test_sensenova.py 实测,非 token 端点)
* - Anthropic anthropic https://api.anthropic.com (anthropic_compat.rs:51)
*
* chat_url/messages_url ( openai_compat.rs chat_url / anthropic_compat.rs messages_url):
* - (OpenAI/DeepSeek) /v1/chat/completions
* - GLM /v4SenseNova /compatible-mode/v1 /chat/completions
* baseUrl /v1 ,;()
*/
/** providerType 字面量(对齐后端 ProviderType serde,与 ProviderPanel.vue 下拉 option value 一致) */
export type PresetProviderType = 'openai_compat' | 'anthropic'
export interface ProviderPreset {
/** 预设唯一标识(下拉 option value;__custom 为内置"自定义"项) */
id: string
/** 展示名(中英 i18n key 后缀,见 labelPreset.{id}) */
name: string
providerType: PresetProviderType
baseUrl: string
defaultModel: string
}
/**
* (;)
*
* baseUrl ( chat_url trim_end_matches('/') ,)
* defaultModel chat ()
*/
export const PROVIDER_PRESETS: readonly ProviderPreset[] = [
{
id: 'glm',
name: 'GLM / 智谱',
providerType: 'openai_compat',
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
defaultModel: 'glm-4.6',
},
{
id: 'deepseek',
name: 'DeepSeek',
providerType: 'openai_compat',
baseUrl: 'https://api.deepseek.com',
defaultModel: 'deepseek-chat',
},
{
id: 'sensenova',
name: 'SenseNova / 商汤',
providerType: 'openai_compat',
baseUrl: 'https://api.sensenova.cn/compatible-mode/v1',
defaultModel: 'SenseNova-Turbo',
},
{
id: 'openai',
name: 'OpenAI',
providerType: 'openai_compat',
baseUrl: 'https://api.openai.com',
defaultModel: 'gpt-4o',
},
{
id: 'anthropic',
name: 'Anthropic / Claude',
providerType: 'anthropic',
baseUrl: 'https://api.anthropic.com',
defaultModel: 'claude-sonnet-4',
},
] as const
/** "自定义" 项标识(下拉内置项,选中不预填任何字段) */
export const CUSTOM_PRESET_ID = '__custom' as const
/**
* id ;( CUSTOM_PRESET_ID) null
* 调用方:null (/)
*/
export function findPreset(id: string): ProviderPreset | null {
if (id === CUSTOM_PRESET_ID) return null
return PROVIDER_PRESETS.find(p => p.id === id) ?? null
}
+7
View File
@@ -43,6 +43,8 @@ export default {
// Provider form // Provider form
labelName: 'Name', labelName: 'Name',
labelPreset: 'Vendor preset',
presetCustom: 'Custom (manual)',
labelProviderType: 'Protocol', labelProviderType: 'Protocol',
labelBaseUrl: 'Base URL', labelBaseUrl: 'Base URL',
labelApiKey: 'API Key', labelApiKey: 'API Key',
@@ -209,6 +211,11 @@ export default {
toastSaveIncomplete: 'Please fill in all fields (Name / Base URL / API Key / Model)', toastSaveIncomplete: 'Please fill in all fields (Name / Base URL / API Key / Model)',
toastSaved: 'Saved', toastSaved: 'Saved',
toastSaveFail: 'Save failed: {msg}', toastSaveFail: 'Save failed: {msg}',
// P0-2: save-failure classified guidance (persistent banner, not raw Err text)
saveErrKeyringTitle: 'Failed to write key to the OS keychain',
saveErrKeyringDetail: 'Original config left unchanged. Backend reason: {msg}\nSuggestion: check the OS keychain/credential store permissions (Windows Credential Manager / macOS Keychain / Linux secret-service daemon), then click "Save" again to retry.',
saveErrGenericTitle: 'Failed to save provider config',
saveErrGenericDetail: 'Backend reason: {msg}\nSuggestion: double-check the form fields (Name / Base URL / Model), then click "Save" again to retry; check the logs if it keeps failing.',
toastDeleted: 'Deleted', toastDeleted: 'Deleted',
toastDeleteFail: 'Delete failed: {msg}', toastDeleteFail: 'Delete failed: {msg}',
toastSetDefaultOk: 'Set as default', toastSetDefaultOk: 'Set as default',
+7
View File
@@ -43,6 +43,8 @@ export default {
// Provider 表单 // Provider 表单
labelName: '名称', labelName: '名称',
labelPreset: '厂商预设',
presetCustom: '自定义(手填)',
labelProviderType: '协议类型', labelProviderType: '协议类型',
labelBaseUrl: 'Base URL', labelBaseUrl: 'Base URL',
labelApiKey: 'API Key', labelApiKey: 'API Key',
@@ -209,6 +211,11 @@ export default {
toastSaveIncomplete: '请填写完整(名称 / Base URL / API Key / 模型)', toastSaveIncomplete: '请填写完整(名称 / Base URL / API Key / 模型)',
toastSaved: '已保存', toastSaved: '已保存',
toastSaveFail: '保存失败:{msg}', toastSaveFail: '保存失败:{msg}',
// P0-2: 保存失败分类建议(常驻错误横幅,非裸 Err 文本)
saveErrKeyringTitle: '密钥写入系统钥匙串失败',
saveErrKeyringDetail: '已保留原配置未改动。后端原因:{msg}\n建议:检查操作系统钥匙串/凭据管理器权限(Windows 凭据管理器 / macOS 钥匙串 / Linux secret-service 守护进程),确认后再次点击「保存」重试。',
saveErrGenericTitle: '保存提供商配置失败',
saveErrGenericDetail: '后端原因:{msg}\n建议:核对表单字段(名称 / Base URL / 模型)后再次点击「保存」重试;若持续失败请查看日志。',
toastDeleted: '已删除', toastDeleted: '已删除',
toastDeleteFail: '删除失败:{msg}', toastDeleteFail: '删除失败:{msg}',
toastSetDefaultOk: '已设为默认', toastSetDefaultOk: '已设为默认',
+59 -7
View File
@@ -117,6 +117,11 @@ function displayName(tc: AiToolCallInfo): string {
let _unlistenUpdate: UnlistenFn | null = null let _unlistenUpdate: UnlistenFn | null = null
let _positioned = false let _positioned = false
/** : close() promise
* close() Tauri 2.x 触发 closeRequested(非强制),某些情况下原生窗口未及时销毁
* Vue 组件保持挂载 + 空态 hint 永久可见此处超时强制 destroy() 兜底 */
let _closeFallbackTimer: ReturnType<typeof setTimeout> | null = null
const CLOSE_TIMEOUT_MS = 1500
onMounted(async () => { onMounted(async () => {
// (,) // (,)
@@ -164,8 +169,51 @@ onMounted(async () => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
_unlistenUpdate?.() _unlistenUpdate?.()
_unlistenUpdate = null _unlistenUpdate = null
if (_closeFallbackTimer) {
clearTimeout(_closeFallbackTimer)
_closeFallbackTimer = null
}
}) })
/** :close (),/ destroy()()
* 根因修复(BUG3+):原实现仅 await getCurrentWebviewWindow().close(), Tauri 2.x
* close() 触发 closeRequested(非强制),若原生窗口因任意原因未及时销毁(已知偶发),
* Vue 组件保持挂载 浮窗内空态 hint正在关闭永久可见,UI 卡死
* - 兜底1:close() 失败 立即 destroy() 强制销毁
* - 兜底2:close() 1.5s 未完成 destroy() 强制销毁(超时定时器)
* - 兜底3:destroy() 也失败 控制台报错(不再静默吞掉,便于排查) */
async function closeWithFallback(): Promise<void> {
const win = getCurrentWebviewWindow()
// :close destroy
_closeFallbackTimer = setTimeout(() => {
_closeFallbackTimer = null
console.warn('[ApprovalPopup] close() 超时,降级 destroy()')
win.destroy().catch((e: unknown) => {
console.error('[ApprovalPopup] destroy() 兜底也失败:', e)
})
}, CLOSE_TIMEOUT_MS)
try {
await win.close()
// close :( destroy )
if (_closeFallbackTimer) {
clearTimeout(_closeFallbackTimer)
_closeFallbackTimer = null
}
} catch (e) {
// close :, destroy()
if (_closeFallbackTimer) {
clearTimeout(_closeFallbackTimer)
_closeFallbackTimer = null
}
console.warn('[ApprovalPopup] close() 失败,降级 destroy():', e)
try {
await win.destroy()
} catch (e2) {
console.error('[ApprovalPopup] destroy() 兜底也失败:', e2)
}
}
}
/** 批准:risk 类 approve(true),path 类 authorizeDir('once') */ /** 批准:risk 类 approve(true),path 类 authorizeDir('once') */
async function onApprove(tc: AiToolCallInfo) { async function onApprove(tc: AiToolCallInfo) {
if (processingId.value === tc.id) return if (processingId.value === tc.id) return
@@ -212,12 +260,15 @@ async function onReject(tc: AiToolCallInfo) {
* 误以为卡死 = 完成,应即时关浮窗(不等待主窗口 watch closePopup 的跨窗口往返) */ * 误以为卡死 = 完成,应即时关浮窗(不等待主窗口 watch closePopup 的跨窗口往返) */
async function maybeAutoClose() { async function maybeAutoClose() {
if (approvals.value.length > 0) return if (approvals.value.length > 0) return
// (), emit ( onClose )
// closeWithFallback (close / destroy ),
await closeWithFallback()
try { try {
const { emit } = await import('@tauri-apps/api/event') const { emit } = await import('@tauri-apps/api/event')
// (), emit ( onClose )
await getCurrentWebviewWindow().close()
await emit(ApprovalPopupEvents.EVT_CLOSED, {}) await emit(ApprovalPopupEvents.EVT_CLOSED, {})
} catch { /* ignore */ } } catch (e) {
console.warn('[ApprovalPopup] emit EVT_CLOSED 失败:', e)
}
} }
/** 点击「打开审批面板」:emit 主窗口聚焦 + togglePanel */ /** 点击「打开审批面板」:emit 主窗口聚焦 + togglePanel */
@@ -233,13 +284,14 @@ async function onActivate() {
* 改为先 close(即时反馈,core:window:allow-close 已授权), emit 清主窗口引用; * 改为先 close(即时反馈,core:window:allow-close 已授权), emit 清主窗口引用;
* 即便 emit 失败,主窗口 win.once('tauri://destroyed') 也会清 _popupWin(双保险) */ * 即便 emit 失败,主窗口 win.once('tauri://destroyed') 也会清 _popupWin(双保险) */
async function onClose() { async function onClose() {
try { // closeWithFallback (close / destroy )
await getCurrentWebviewWindow().close() await closeWithFallback()
} catch { /* ignore */ }
try { try {
const { emit } = await import('@tauri-apps/api/event') const { emit } = await import('@tauri-apps/api/event')
await emit(ApprovalPopupEvents.EVT_CLOSED, {}) await emit(ApprovalPopupEvents.EVT_CLOSED, {})
} catch { /* ignore */ } } catch (e) {
console.warn('[ApprovalPopup] emit EVT_CLOSED 失败:', e)
}
} }
// ( data-tauri-drag-region + Tauri dataDrag ) // ( data-tauri-drag-region + Tauri dataDrag )
+2
View File
@@ -533,4 +533,6 @@ async function runImport() {
} }
.toast-info { background: var(--df-accent); color: #fff; } .toast-info { background: var(--df-accent); color: #fff; }
.toast-error { background: var(--df-danger); color: #fff; } .toast-error { background: var(--df-danger); color: #fff; }
/* P0-2: ToastType 新增 'success' 兜底样式(本视图当前未用,补全避免裸类) */
.toast-success { background: var(--df-success); color: #fff; }
</style> </style>
+2
View File
@@ -221,6 +221,8 @@ onUnmounted(() => {
.toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); } .toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
.toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); } .toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
.toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); } .toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); }
/* P0-2: 保存成功明确反馈(绿色),与 info(中性强调)区分 */
.toast-success { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid var(--df-success); }
.toast-enter-active, .toast-leave-active { transition: opacity 0.2s, transform 0.2s; } .toast-enter-active, .toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translate(-50%, -8px); } .toast-enter-from, .toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }