diff --git a/crates/df-execute/src/env_snapshot.rs b/crates/df-execute/src/env_snapshot.rs new file mode 100644 index 0000000..9feca3d --- /dev/null +++ b/crates/df-execute/src/env_snapshot.rs @@ -0,0 +1,432 @@ +//! 环境感知系统 — 启动时一次性探测 OS/shell/工具版本/编码,全局缓存,注入 system_prompt。 +//! +//! 设计目标:让 LLM 准确生成跨平台命令。LLM 训练数据 Unix 多,易生成 macOS/Linux 语法 +//! 命令(PowerShell 5 不支持 `&&`、Windows 路径分隔符 `\`、GBK 终端中文乱码等),通过 +//! 把当前平台的真实环境(操作系统/Shell/工具版本)拼进 system_prompt,LLM 即可生成与 +//! 当前平台兼容的命令,从根上治「LLM 跨平台命令幻觉」。 +//! +//! 缓存策略:`OnceLock` 全局单例,首次 `detect()` 探测,后续调用零开销。探测本身在 +//! `spawn_blocking` 中执行(执行 `tool --version` 等阻塞 IO),避免阻塞 tokio runtime。 +//! 探测失败的字段设 None/默认值,不向上传播错误(环境感知是 best-effort 增强,不应 +//! 阻断主流程)。 + +use std::sync::OnceLock; + +/// 环境快照 — 系统环境的不可变视图。 +#[derive(Debug, Clone, serde::Serialize)] +pub struct EnvSnapshot { + /// 操作系统族:"windows" / "macos" / "linux" + pub os: String, + /// OS 版本:"11" / "Ubuntu 22.04"(探测失败为空串) + pub os_version: String, + /// Shell 名:"powershell" / "pwsh" / "bash" / "zsh" / "sh" + pub shell: String, + /// 路径分隔符:"\\"(Windows) / "/"(Unix) + pub path_sep: String, + /// 终端编码:"utf-8" / "gbk"(Windows 中文常见 GBK,致 LLM 输出乱码) + pub encoding: String, + /// 工具版本(逐个探测,缺失为 None) + pub tools: ToolVersions, +} + +/// 各开发工具的版本信息。 +#[derive(Debug, Clone, serde::Serialize, Default)] +pub struct ToolVersions { + pub python: Option, + pub node: Option, + pub rust: Option, + pub go: Option, + pub docker: Option, + pub git: Option, +} + +impl EnvSnapshot { + /// 探测环境(惰性,OnceLock 全局缓存,整个进程生命周期只探一次)。 + /// + /// 返回 `&'static EnvSnapshot` —— 引用静态存储,可安全地长存于 loop 不变量中。 + /// 探测在 `spawn_blocking` 中同步执行(`tool --version` 是阻塞 IO),不卡 runtime。 + pub async fn detect() -> &'static EnvSnapshot { + static SNAPSHOT: OnceLock = OnceLock::new(); + if let Some(snap) = SNAPSHOT.get() { + return snap; + } + // 首次探测:同步逻辑包到 spawn_blocking,避免阻塞 async runtime。 + let snap = tokio::task::spawn_blocking(|| EnvSnapshot::do_detect()) + .await + .unwrap_or_else(|_| EnvSnapshot::fallback()); + // 多任务竞态:均等价,以先到者为准。 + let _ = SNAPSHOT.set(snap); + SNAPSHOT.get().expect("EnvSnapshot 已初始化") + } + + /// 同步探测(可能短暂阻塞,仅在 spawn_blocking 中调用)。 + fn do_detect() -> EnvSnapshot { + EnvSnapshot { + os: std::env::consts::OS.to_string(), + os_version: detect_os_version(), + shell: detect_shell(), + path_sep: std::path::MAIN_SEPARATOR.to_string(), + encoding: detect_encoding(), + tools: ToolVersions { + python: probe_version("python", &["--version"]), + node: probe_version("node", &["--version"]), + rust: probe_version("rustc", &["--version"]), + go: probe_version("go", &["version"]), + docker: probe_version("docker", &["--version"]), + git: probe_version("git", &["--version"]), + }, + } + } + + /// 兜底:spawn_blocking panic/join 失败时返回最小可用快照(全 None,字段不空)。 + fn fallback() -> EnvSnapshot { + EnvSnapshot { + os: std::env::consts::OS.to_string(), + os_version: String::new(), + shell: if cfg!(windows) { "powershell".into() } else { "sh".into() }, + path_sep: std::path::MAIN_SEPARATOR.to_string(), + encoding: "utf-8".into(), + tools: ToolVersions::default(), + } + } + + /// 生成 system_prompt 注入文本(拼到 system_prompt 尾部)。 + /// + /// 末尾加「请生成本平台兼容的命令」软提示,锚定 LLM 输出平台一致性。 + pub fn to_prompt(&self) -> String { + let mut lines: Vec = vec![ + "## 系统环境".to_string(), + format!("- 操作系统: {} {}", self.os, self.os_version).trim_end().to_string(), + format!("- Shell: {}", self.shell), + format!("- 路径分隔符: {}", self.path_sep), + format!("- 终端编码: {}", self.encoding), + ]; + if let Some(v) = &self.tools.python { + lines.push(format!("- Python: {}", v)); + } + if let Some(v) = &self.tools.node { + lines.push(format!("- Node: {}", v)); + } + if let Some(v) = &self.tools.rust { + lines.push(format!("- Rust: {}", v)); + } + if let Some(v) = &self.tools.go { + lines.push(format!("- Go: {}", v)); + } + if let Some(v) = &self.tools.docker { + lines.push(format!("- Docker: {}", v)); + } + if let Some(v) = &self.tools.git { + lines.push(format!("- Git: {}", v)); + } + lines.push(String::new()); + lines.push("注意: 请生成本平台兼容的命令。".to_string()); + lines.join("\n") + } +} + +/// 探测 OS 版本(各平台路径不一,失败返回空串而非 None,简化 prompt 拼接)。 +fn detect_os_version() -> String { + // Windows:读注册表 HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion (ProductName/DisplayVersion)。 + // 不依赖 winver GUI / reg.exe 输出格式,直接读注册表最稳。 + #[cfg(windows)] + { + if let Some(v) = read_windows_version() { + return v; + } + return String::new(); + } + // macOS:sw_vers -productVersion 输出如 "12.5" + #[cfg(target_os = "macos")] + { + if let Ok(out) = std::process::Command::new("sw_vers").arg("-productVersion").output() { + if out.status.success() { + return String::from_utf8_lossy(&out.stdout).trim().to_string(); + } + } + return String::new(); + } + // Linux:读 /etc/os-release 的 PRETTY_NAME 字段(系统标准位置) + #[cfg(target_os = "linux")] + { + if let Ok(content) = std::fs::read_to_string("/etc/os-release") { + for line in content.lines() { + if let Some(rest) = line.strip_prefix("PRETTY_NAME=") { + return rest.trim_matches('"').to_string(); + } + } + } + return String::new(); + } + #[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] + { + String::new() + } +} + +#[cfg(windows)] +fn read_windows_version() -> Option { + // 用 reg.exe query 读注册表(DisplayVersion 优先,如 "22H2";回退 ProductName,如 "Windows 10 Pro")。 + // 避开 winreg crate 依赖(增加构建复杂度,且 reg.exe 在所有 Win 版本均自带)。 + let out = std::process::Command::new("reg") + .args([ + "query", + r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion", + "/v", + "DisplayVersion", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .creation_flags(0x0800_0000) // CREATE_NO_WINDOW + .output() + .ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8_lossy(&out.stdout); + // 输出形如: " DisplayVersion REG_SZ 22H2" + for line in text.lines() { + let trimmed = line.trim(); + if let Some(idx) = trimmed.find("REG_SZ") { + let val = trimmed[idx + "REG_SZ".len()..].trim(); + if !val.is_empty() { + return Some(format!("Windows {}", val)); + } + } + } + None +} + +/// 探测默认 shell(复用 shell.rs 的 pwsh 探测语义)。 +fn detect_shell() -> String { + #[cfg(windows)] + { + // 优先 pwsh(PS7,支持 &&),其次 powershell(PS5),兜底 cmd。 + if probe_command_success("pwsh", &["-NoProfile", "-Command", "exit 0"]) { + return "pwsh".to_string(); + } + if probe_command_success("powershell", &["-NoProfile", "-Command", "exit 0"]) { + return "powershell".to_string(); + } + return "cmd".to_string(); + } + #[cfg(not(windows))] + { + // Unix:SHELL 环境变量优先,常见值 /bin/bash /bin/zsh /bin/sh。 + if let Ok(sh) = std::env::var("SHELL") { + // 取 basename(/bin/zsh → zsh) + let name = sh.rsplit('/').next().unwrap_or(&sh); + if !name.is_empty() { + return name.to_string(); + } + } + "sh".to_string() + } +} + +/// 探测终端编码(Windows 中文常见 GBK,导致 LLM 输出 UTF-8 在终端乱码)。 +fn detect_encoding() -> String { + #[cfg(windows)] + { + // chcp 输出形如 "活动代码页: 936"(GBK)。936 → gbk,65001 → utf-8,其余按数字降级。 + if let Ok(out) = std::process::Command::new("chcp") + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .creation_flags(0x0800_0000) + .output() + { + let text = String::from_utf8_lossy(&out.stdout); + if let Some(code) = extract_codepage(&text) { + return match code.as_str() { + "65001" => "utf-8".to_string(), + "936" => "gbk".to_string(), + "950" => "big5".to_string(), + other => format!("cp{}", other), + }; + } + } + return "utf-8".to_string(); + } + #[cfg(not(windows))] + { + // Unix 默认 UTF-8(LANG/LC_ALL 通常含 UTF-8)。 + if let Ok(lang) = std::env::var("LANG") { + if lang.to_ascii_uppercase().contains("UTF-8") { + return "utf-8".to_string(); + } + } + "utf-8".to_string() + } +} + +#[cfg(windows)] +fn extract_codepage(text: &str) -> Option { + // 提取末尾数字("...936" / "...: 65001")。 + let mut num = String::new(); + for c in text.chars().rev() { + if c.is_ascii_digit() { + num.insert(0, c); + } else if !num.is_empty() { + break; + } + } + if num.is_empty() { + None + } else { + Some(num) + } +} + +/// 执行 `tool args`,成功(true)即工具可用。Windows 加 CREATE_NO_WINDOW 防黑窗。 +#[allow(dead_code)] // 仅 Windows 路径调用,非 Windows 静态裁掉 +fn probe_command_success(tool: &str, args: &[&str]) -> bool { + let mut cmd = std::process::Command::new(tool); + cmd.args(args); + cmd.stdout(Stdio::null()).stderr(Stdio::null()); + #[cfg(windows)] + cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + cmd.status().map(|s| s.success()).unwrap_or(false) +} + +/// 执行 `tool --version`,解析首行返回版本串。失败/超时返回 None,不阻塞调用方。 +/// +/// 例:python --version 输出 "Python 3.11.5" → 返回 "3.11.5";git --version 输出 +/// "git version 2.41.0" → 返回 "2.41.0"。统一抽掉工具名前缀,只保留版本号本身。 +fn probe_version(tool: &str, args: &[&str]) -> Option { + let mut cmd = std::process::Command::new(tool); + cmd.args(args); + cmd.stdout(Stdio::piped()).stderr(Stdio::null()); + #[cfg(windows)] + cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + let out = cmd.output().ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8_lossy(&out.stdout); + let first_line = text.lines().next()?; + Some(extract_version_token(first_line)) +} + +/// 从版本命令首行抽取版本号:取首个形如 N(.N)+ 的 token(至少一个点号)。 +/// 兼容 "Python 3.11.5" / "v18.17.0" / "git version 2.41.0.windows.1" / "go version go1.21.0 ..."。 +/// +/// 策略:把行切成空白 token,逐个匹配「数字开头 + 至少一个 `.`」的模式,取首个命中。 +/// 比 char 状态机更鲁棒(状态机遇 `2.41.0.windows.1` 这种多层嵌套点会误判)。 +fn extract_version_token(line: &str) -> String { + for token in line.split_whitespace() { + // 找 token 内首个数字位置,从这里开始扫 "数字段(.数字段)*" 序列。 + // 遇点要求下一字符为数字,否则在该点处截断(避免 "2.41.0.windows.1" 被吞成 + // "2.41.0.windows.1",实际应止于 "2.41.0")。 + let bytes = token.as_bytes(); + let mut i = match bytes.iter().position(|b| b.is_ascii_digit()) { + Some(i) => i, + None => continue, + }; + let mut head = String::new(); + loop { + // 收数字段 + let seg_start = i; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + head.push_str(&token[seg_start..i]); + // 点号:仅当下一字符为数字时才续,否则收尾 + if i < bytes.len() && bytes[i] == b'.' && i + 1 < bytes.len() + && bytes[i + 1].is_ascii_digit() + { + head.push('.'); + i += 1; + continue; + } + break; + } + if head.contains('.') { + return head; + } + } + // 无版本号 token 时退回整行(避免返回空串让 prompt 出现 "None")。 + line.trim().to_string() +} + +// Windows 下统一在文件顶部引入 CommandExt,使各 #[cfg(windows)] 块可直接调用 creation_flags。 +#[cfg(windows)] +use std::os::windows::process::CommandExt; + +use std::process::Stdio; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_version_python_style() { + assert_eq!(extract_version_token("Python 3.11.5"), "3.11.5"); + } + + #[test] + fn extract_version_node_style() { + assert_eq!(extract_version_token("v18.17.0"), "18.17.0"); + } + + #[test] + fn extract_version_git_style() { + assert_eq!(extract_version_token("git version 2.41.0.windows.1"), "2.41.0"); + } + + #[test] + fn extract_version_go_style() { + assert_eq!( + extract_version_token("go version go1.21.0 windows/amd64"), + "1.21.0" + ); + } + + #[test] + fn extract_version_no_match_returns_line() { + assert_eq!(extract_version_token("no version here"), "no version here"); + } + + #[cfg(windows)] + #[test] + fn extract_codepage_parsing() { + assert_eq!(extract_codepage("活动代码页: 936"), Some("936".into())); + assert_eq!( + extract_codepage("Active code page: 65001"), + Some("65001".into()) + ); + assert_eq!(extract_codepage("no digits here"), None); + } + + #[tokio::test] + async fn detect_returns_cached_static_ref() { + // 两次 detect 返回同一引用(OnceLock 全局缓存)。 + let a = EnvSnapshot::detect().await as *const _; + let b = EnvSnapshot::detect().await as *const _; + assert_eq!(a, b, "detect() 应返回同一静态引用"); + } + + #[test] + fn to_prompt_contains_os_and_shell() { + let snap = EnvSnapshot { + os: "test_os".into(), + os_version: "v1".into(), + shell: "test_shell".into(), + path_sep: "/".into(), + encoding: "utf-8".into(), + tools: ToolVersions { + python: Some("3.11".into()), + node: None, + rust: None, + go: None, + docker: None, + git: None, + }, + }; + let prompt = snap.to_prompt(); + assert!(prompt.contains("test_os")); + assert!(prompt.contains("test_shell")); + assert!(prompt.contains("Python: 3.11")); + assert!(!prompt.contains("Node")); + assert!(prompt.contains("请生成本平台兼容的命令")); + } +} diff --git a/crates/df-execute/src/lib.rs b/crates/df-execute/src/lib.rs index fb1cb5c..6b0ec9a 100644 --- a/crates/df-execute/src/lib.rs +++ b/crates/df-execute/src/lib.rs @@ -1,3 +1,6 @@ -//! df-execute: 执行运行时 — Shell +//! df-execute: 执行运行时 — Shell + 环境感知 +pub mod env_snapshot; pub mod shell; + +pub use env_snapshot::EnvSnapshot; diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 895e71a..9b4db0b 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -823,6 +823,15 @@ pub(crate) async fn run_agentic_loop( } } + // EnvSnapshot 环境感知:入口把当前平台的真实环境(OS/shell/工具版本)拼进 system_prompt 尾部。 + // + // 治「LLM 跨平台命令幻觉」:LLM 训练数据 Unix 多,易生成 macOS/Linux 语法命令(PowerShell 5 + // 不支持 `&&`、Windows 路径分隔符 `\`、GBK 终端中文乱码),把真实环境塞 prompt 即可锚定 + // 输出平台一致性。detect() 是 OnceLock 全局缓存(启动时探一次,后续零开销),与 G1 一样 + // 是 loop 不变量(整个会话不重探),与目标钉扎拼接次序无强约束(放其后,语义自然)。 + let env_prompt = df_execute::EnvSnapshot::detect().await.to_prompt(); + system_prompt = format!("{}\n\n{}", system_prompt, env_prompt); + // 对话透明化 L1:拍快照供 AiCompleted 事件携带,前端直接读取 pinned_goals 无需等 loadConversations let pinned_goals_snapshot: Vec = { let session = session_arc.lock().await;