#[cfg(not(target_os = "macos"))] use crate::utils::openclaw_command; /// 配置读写命令 use serde_json::{json, Value}; use std::fs; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; use std::path::PathBuf; use std::process::Command; use crate::models::types::VersionInfo; struct GuardianPause { reason: &'static str, } impl GuardianPause { fn new(reason: &'static str) -> Self { crate::commands::service::guardian_pause(reason); Self { reason } } } impl Drop for GuardianPause { fn drop(&mut self) { crate::commands::service::guardian_resume(self.reason); } } /// 预设 npm 源列表 const DEFAULT_REGISTRY: &str = "https://registry.npmmirror.com"; /// Linux: 检测是否以 root 身份运行(避免 unsafe libc 调用) #[cfg(target_os = "linux")] fn nix_is_root() -> bool { std::env::var("USER") .or_else(|_| std::env::var("EUID")) .map(|v| v == "root" || v == "0") .unwrap_or(false) } /// 读取用户配置的 npm registry,fallback 到淘宝镜像 fn get_configured_registry() -> String { let path = super::openclaw_dir().join("npm-registry.txt"); fs::read_to_string(&path) .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .unwrap_or_else(|| DEFAULT_REGISTRY.to_string()) } /// 创建使用配置源的 npm Command /// Windows 上 npm 是 npm.cmd,需要通过 cmd /c 调用,并隐藏窗口 /// Linux 非 root 用户全局安装需要 sudo fn npm_command() -> Command { let registry = get_configured_registry(); #[cfg(target_os = "windows")] { const CREATE_NO_WINDOW: u32 = 0x08000000; let mut cmd = Command::new("cmd"); cmd.args(["/c", "npm", "--registry", ®istry]); cmd.env("PATH", super::enhanced_path()); cmd.creation_flags(CREATE_NO_WINDOW); cmd } #[cfg(target_os = "macos")] { let mut cmd = Command::new("npm"); cmd.args(["--registry", ®istry]); cmd.env("PATH", super::enhanced_path()); cmd } #[cfg(target_os = "linux")] { // Linux 非 root 用户全局 npm install 需要 sudo let need_sudo = !nix_is_root(); let mut cmd = if need_sudo { let mut c = Command::new("sudo"); c.args(["npm", "--registry", ®istry]); c } else { let mut c = Command::new("npm"); c.args(["--registry", ®istry]); c }; cmd.env("PATH", super::enhanced_path()); cmd } } fn backups_dir() -> PathBuf { super::openclaw_dir().join("backups") } #[tauri::command] pub fn read_openclaw_config() -> Result { let path = super::openclaw_dir().join("openclaw.json"); let raw = fs::read(&path).map_err(|e| format!("读取配置失败: {e}"))?; // 自愈:自动剥离 UTF-8 BOM(EF BB BF),防止 JSON 解析失败 let content = if raw.starts_with(&[0xEF, 0xBB, 0xBF]) { String::from_utf8_lossy(&raw[3..]).into_owned() } else { String::from_utf8_lossy(&raw).into_owned() }; // 解析 JSON,失败时尝试从备份恢复 let mut config: Value = match serde_json::from_str(&content) { Ok(v) => { // BOM 被剥离过,静默写回干净文件 if raw.starts_with(&[0xEF, 0xBB, 0xBF]) { let _ = fs::write(&path, &content); } v } Err(e) => { // JSON 解析失败,尝试从备份恢复 let bak = super::openclaw_dir().join("openclaw.json.bak"); if bak.exists() { let bak_raw = fs::read(&bak).map_err(|e2| format!("备份也读取失败: {e2}"))?; let bak_content = if bak_raw.starts_with(&[0xEF, 0xBB, 0xBF]) { String::from_utf8_lossy(&bak_raw[3..]).into_owned() } else { String::from_utf8_lossy(&bak_raw).into_owned() }; let bak_config: Value = serde_json::from_str(&bak_content) .map_err(|e2| format!("配置损坏且备份也无效: 原始={e}, 备份={e2}"))?; // 备份有效,恢复主文件 let _ = fs::write(&path, &bak_content); bak_config } else { return Err(format!("配置 JSON 损坏且无备份: {e}")); } } }; // 自动清理 UI 专属字段,防止污染配置导致 CLI 启动失败 if has_ui_fields(&config) { config = strip_ui_fields(config); // 静默写回清理后的配置 let bak = super::openclaw_dir().join("openclaw.json.bak"); let _ = fs::copy(&path, &bak); let json = serde_json::to_string_pretty(&config).map_err(|e| format!("序列化失败: {e}"))?; let _ = fs::write(&path, json); } Ok(config) } /// 供其他模块复用:读取 openclaw.json 为 JSON Value pub fn load_openclaw_json() -> Result { read_openclaw_config() } /// 供其他模块复用:将 JSON Value 写回 openclaw.json(含备份和清理) pub fn save_openclaw_json(config: &Value) -> Result<(), String> { write_openclaw_config(config.clone()) } /// 供其他模块复用:触发 Gateway 重载 pub async fn do_reload_gateway(app: &tauri::AppHandle) -> Result { let _ = app; // 预留扩展用 reload_gateway().await } #[tauri::command] pub fn write_openclaw_config(config: Value) -> Result<(), String> { let path = super::openclaw_dir().join("openclaw.json"); // 备份 let bak = super::openclaw_dir().join("openclaw.json.bak"); let _ = fs::copy(&path, &bak); // 清理 UI 专属字段,避免 CLI schema 校验失败 let cleaned = strip_ui_fields(config.clone()); // 写入 let json = serde_json::to_string_pretty(&cleaned).map_err(|e| format!("序列化失败: {e}"))?; fs::write(&path, &json).map_err(|e| format!("写入失败: {e}"))?; // 同步 provider 配置到所有 agent 的 models.json(运行时注册表) sync_providers_to_agent_models(&config); Ok(()) } /// 将 openclaw.json 的 models.providers 完整同步到每个 agent 的 models.json /// 包括:同步 baseUrl/apiKey/api、删除已移除的 provider、删除已移除的 model、 /// 确保 Gateway 运行时不会引用 openclaw.json 中已不存在的模型 fn sync_providers_to_agent_models(config: &Value) { let src_providers = config .pointer("/models/providers") .and_then(|p| p.as_object()); // 收集 openclaw.json 中所有有效的 provider/model 组合 let mut valid_models: std::collections::HashSet = std::collections::HashSet::new(); if let Some(providers) = src_providers { for (pk, pv) in providers { if let Some(models) = pv.get("models").and_then(|m| m.as_array()) { for m in models { let id = m.get("id").and_then(|v| v.as_str()).or_else(|| m.as_str()); if let Some(id) = id { valid_models.insert(format!("{}/{}", pk, id)); } } } } } // 收集所有 agent ID let mut agent_ids = vec!["main".to_string()]; if let Some(Value::Array(list)) = config.pointer("/agents/list") { for agent in list { if let Some(id) = agent.get("id").and_then(|v| v.as_str()) { if id != "main" { agent_ids.push(id.to_string()); } } } } let agents_dir = super::openclaw_dir().join("agents"); for agent_id in &agent_ids { let models_path = agents_dir.join(agent_id).join("agent").join("models.json"); if !models_path.exists() { continue; } let Ok(content) = fs::read_to_string(&models_path) else { continue; }; let Ok(mut models_json) = serde_json::from_str::(&content) else { continue; }; let mut changed = false; if models_json .get("providers") .and_then(|p| p.as_object()) .is_none() { if let Some(root) = models_json.as_object_mut() { root.insert("providers".into(), json!({})); changed = true; } } // 同步 providers if let Some(dst_providers) = models_json .get_mut("providers") .and_then(|p| p.as_object_mut()) { // 1. 删除 openclaw.json 中已不存在的 provider if let Some(src) = src_providers { let to_remove: Vec = dst_providers .keys() .filter(|k| !src.contains_key(k.as_str())) .cloned() .collect(); for k in to_remove { dst_providers.remove(&k); changed = true; } for (provider_name, src_provider) in src.iter() { if !dst_providers.contains_key(provider_name) { dst_providers.insert(provider_name.clone(), src_provider.clone()); changed = true; } } // 2. 同步存在的 provider 的 baseUrl/apiKey/api + 清理已删除的 models for (provider_name, src_provider) in src.iter() { if let Some(dst_provider) = dst_providers.get_mut(provider_name) { if let Some(dst_obj) = dst_provider.as_object_mut() { // 同步连接信息 for field in ["baseUrl", "apiKey", "api"] { if let Some(src_val) = src_provider.get(field).and_then(|v| v.as_str()) { if dst_obj.get(field).and_then(|v| v.as_str()) != Some(src_val) { dst_obj.insert( field.to_string(), Value::String(src_val.to_string()), ); changed = true; } } } // 清理已删除的 models if let Some(dst_models) = dst_obj.get_mut("models").and_then(|m| m.as_array_mut()) { let src_model_ids: std::collections::HashSet = src_provider .get("models") .and_then(|m| m.as_array()) .map(|arr| { arr.iter() .filter_map(|m| { m.get("id") .and_then(|v| v.as_str()) .or_else(|| m.as_str()) .map(|s| s.to_string()) }) .collect() }) .unwrap_or_default(); let before = dst_models.len(); dst_models.retain(|m| { let id = m .get("id") .and_then(|v| v.as_str()) .or_else(|| m.as_str()) .unwrap_or(""); src_model_ids.contains(id) }); if dst_models.len() != before { changed = true; } } } } } } } if changed { if let Ok(new_json) = serde_json::to_string_pretty(&models_json) { let _ = fs::write(&models_path, new_json); } } } } /// 检测配置中是否包含 UI 专属字段 fn has_ui_fields(val: &Value) -> bool { if let Some(obj) = val.as_object() { if let Some(models_val) = obj.get("models") { if let Some(models_obj) = models_val.as_object() { if let Some(providers_val) = models_obj.get("providers") { if let Some(providers_obj) = providers_val.as_object() { for (_provider_name, provider_val) in providers_obj.iter() { if let Some(provider_obj) = provider_val.as_object() { if let Some(Value::Array(arr)) = provider_obj.get("models") { for model in arr.iter() { if let Some(mobj) = model.as_object() { if mobj.contains_key("lastTestAt") || mobj.contains_key("latency") || mobj.contains_key("testStatus") || mobj.contains_key("testError") { return true; } } } } } } } } } } } false } /// 递归清理 models 数组中的 UI 专属字段(lastTestAt, latency, testStatus, testError) /// 并为缺少 name 字段的模型自动补上 name = id fn strip_ui_fields(mut val: Value) -> Value { if let Some(obj) = val.as_object_mut() { // 处理 models.providers.xxx.models 结构 if let Some(models_val) = obj.get_mut("models") { if let Some(models_obj) = models_val.as_object_mut() { if let Some(providers_val) = models_obj.get_mut("providers") { if let Some(providers_obj) = providers_val.as_object_mut() { for (_provider_name, provider_val) in providers_obj.iter_mut() { if let Some(provider_obj) = provider_val.as_object_mut() { if let Some(Value::Array(arr)) = provider_obj.get_mut("models") { for model in arr.iter_mut() { if let Some(mobj) = model.as_object_mut() { mobj.remove("lastTestAt"); mobj.remove("latency"); mobj.remove("testStatus"); mobj.remove("testError"); if !mobj.contains_key("name") { if let Some(id) = mobj.get("id").and_then(|v| v.as_str()) { mobj.insert( "name".into(), Value::String(id.to_string()), ); } } } } } } } } } } } } val } #[tauri::command] pub fn read_mcp_config() -> Result { let path = super::openclaw_dir().join("mcp.json"); if !path.exists() { return Ok(Value::Object(Default::default())); } let content = fs::read_to_string(&path).map_err(|e| format!("读取 MCP 配置失败: {e}"))?; serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {e}")) } #[tauri::command] pub fn write_mcp_config(config: Value) -> Result<(), String> { let path = super::openclaw_dir().join("mcp.json"); let json = serde_json::to_string_pretty(&config).map_err(|e| format!("序列化失败: {e}"))?; fs::write(&path, json).map_err(|e| format!("写入失败: {e}")) } /// 获取本地安装的 openclaw 版本号(异步版本) /// macOS: 优先从 npm 包的 package.json 读取(含完整后缀),fallback 到 CLI /// Windows/Linux: 优先读文件系统,fallback 到 CLI async fn get_local_version() -> Option { // macOS: 通过 symlink 找到包目录,读 package.json 的 version #[cfg(target_os = "macos")] { if let Ok(target) = fs::read_link("/opt/homebrew/bin/openclaw") { let pkg_json = PathBuf::from("/opt/homebrew/bin") .join(&target) .parent()? .join("package.json"); if let Ok(content) = fs::read_to_string(&pkg_json) { if let Some(ver) = serde_json::from_str::(&content) .ok() .and_then(|v| v.get("version")?.as_str().map(String::from)) { return Some(ver); } } } } // Windows: 直接读 npm 全局目录下的 package.json,避免 spawn 进程 #[cfg(target_os = "windows")] { if let Ok(appdata) = std::env::var("APPDATA") { // 先查汉化版,再查官方版 for pkg in &["@qingchencloud/openclaw-zh", "openclaw"] { let pkg_json = PathBuf::from(&appdata) .join("npm") .join("node_modules") .join(pkg) .join("package.json"); if let Ok(content) = fs::read_to_string(&pkg_json) { if let Some(ver) = serde_json::from_str::(&content) .ok() .and_then(|v| v.get("version")?.as_str().map(String::from)) { return Some(ver); } } } } } // 所有平台通用 fallback: CLI 输出(异步) use crate::utils::openclaw_command_async; let output = openclaw_command_async() .arg("--version") .output() .await .ok()?; let raw = String::from_utf8_lossy(&output.stdout).trim().to_string(); raw.split_whitespace() .last() .filter(|s| !s.is_empty()) .map(String::from) } /// 从 npm registry 获取最新版本号,超时 5 秒 async fn get_latest_version_for(source: &str) -> Option { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(2)) .build() .ok()?; let pkg = npm_package_name(source) .replace('/', "%2F") .replace('@', "%40"); let registry = get_configured_registry(); let url = format!("{registry}/{pkg}/latest"); let resp = client.get(&url).send().await.ok()?; let json: Value = resp.json().await.ok()?; json.get("version") .and_then(|v| v.as_str()) .map(String::from) } /// 检测当前安装的是官方版还是汉化版 /// macOS: 优先检查 homebrew symlink,fallback 到 npm list /// Windows: 优先检查 npm 全局目录下的 package.json,避免调用 npm list 阻塞 /// Linux: 直接用 npm list fn detect_installed_source() -> String { // macOS: 检查 openclaw bin 的 symlink 指向 #[cfg(target_os = "macos")] { if let Ok(target) = std::fs::read_link("/opt/homebrew/bin/openclaw") { if target.to_string_lossy().contains("openclaw-zh") { return "chinese".into(); } return "official".into(); } "official".into() } // Windows: 优先通过文件系统检测,避免 npm list 阻塞 #[cfg(target_os = "windows")] { if let Some(appdata) = std::env::var_os("APPDATA") { let zh_dir = PathBuf::from(&appdata) .join("npm") .join("node_modules") .join("@qingchencloud") .join("openclaw-zh"); if zh_dir.exists() { return "chinese".into(); } } "official".into() } // 所有平台通用: npm list 检测 #[cfg(not(any(target_os = "macos", target_os = "windows")))] { if let Ok(o) = npm_command() .args(["list", "-g", "@qingchencloud/openclaw-zh", "--depth=0"]) .output() { if String::from_utf8_lossy(&o.stdout).contains("openclaw-zh@") { return "chinese".into(); } } "official".into() } } #[tauri::command] pub async fn get_version_info() -> Result { let current = get_local_version().await; let source = detect_installed_source(); let latest = get_latest_version_for(&source).await; let parse_ver = |v: &str| -> Vec { v.split(|c: char| !c.is_ascii_digit()) .filter_map(|s| s.parse().ok()) .collect() }; let update_available = match (¤t, &latest) { (Some(c), Some(l)) => parse_ver(l) > parse_ver(c), _ => false, }; Ok(VersionInfo { current, latest, update_available, source, }) } /// 获取 OpenClaw 运行时状态摘要(openclaw status --json) /// 包含 runtimeVersion、会话列表(含 token 用量、fastMode 等标签) #[tauri::command] pub async fn get_status_summary() -> Result { let output = crate::utils::openclaw_command_async() .args(["status", "--json"]) .output() .await; match output { Ok(o) if o.status.success() => { let stdout = String::from_utf8_lossy(&o.stdout); // CLI 输出可能含非 JSON 行,复用 skills 模块的 extract_json crate::commands::skills::extract_json_pub(&stdout) .ok_or_else(|| "解析失败: 输出中未找到有效 JSON".to_string()) } Ok(o) => { let stderr = String::from_utf8_lossy(&o.stderr); Err(format!("openclaw status 失败: {}", stderr.trim())) } Err(e) => Err(format!("执行 openclaw 失败: {e}")), } } /// npm 包名映射 fn npm_package_name(source: &str) -> &'static str { match source { "official" => "openclaw", _ => "@qingchencloud/openclaw-zh", } } /// 获取指定源的所有可用版本列表(从 npm registry 查询) #[tauri::command] pub async fn list_openclaw_versions(source: String) -> Result, String> { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() .map_err(|e| format!("HTTP 初始化失败: {e}"))?; let pkg = npm_package_name(&source).replace('/', "%2F"); let registry = get_configured_registry(); let url = format!("{registry}/{pkg}"); let resp = client .get(&url) .header("Accept", "application/json") .send() .await .map_err(|e| format!("查询版本失败: {e}"))?; let json: Value = resp .json() .await .map_err(|e| format!("解析响应失败: {e}"))?; let versions = json .get("versions") .and_then(|v| v.as_object()) .map(|obj| { let mut vers: Vec = obj.keys().cloned().collect(); // 按版本号排序(新版本在前) vers.sort_by(|a, b| { let pa: Vec = a .split(|c: char| !c.is_ascii_digit()) .filter_map(|s| s.parse().ok()) .collect(); let pb: Vec = b .split(|c: char| !c.is_ascii_digit()) .filter_map(|s| s.parse().ok()) .collect(); pb.cmp(&pa) }); vers }) .unwrap_or_default(); Ok(versions) } /// 执行 npm 全局安装/升级/降级 openclaw(流式推送日志) #[tauri::command] pub async fn upgrade_openclaw( app: tauri::AppHandle, source: String, version: Option, ) -> Result { use std::io::{BufRead, BufReader}; use std::process::Stdio; use tauri::Emitter; let _guardian_pause = GuardianPause::new("upgrade"); let current_source = detect_installed_source(); let pkg_name = npm_package_name(&source); let ver = version.as_deref().unwrap_or("latest"); let pkg = format!("{}@{}", pkg_name, ver); // 切换源时需要卸载旧包,但为避免安装失败导致 CLI 丢失, // 先安装新包,成功后再卸载旧包 let old_pkg = npm_package_name(¤t_source); let need_uninstall_old = current_source != source; // 自动配置 git 全面使用 HTTPS 替代 SSH/git 协议,避免用户没配 SSH Key 导致依赖安装失败 let _ = app.emit("upgrade-log", "配置 Git HTTPS 模式..."); // 先清除旧的 insteadOf 规则,再逐条添加(git config 不带 --add 会覆盖,只保留最后一条) let _ = Command::new("git") .args([ "config", "--global", "--unset-all", "url.https://github.com/.insteadOf", ]) .output(); for from in &[ "ssh://git@github.com/", "git@github.com:", "git://github.com/", "git+ssh://git@github.com/", ] { let _ = Command::new("git") .args([ "config", "--global", "--add", "url.https://github.com/.insteadOf", from, ]) .output(); } let _ = app.emit("upgrade-log", format!("$ npm install -g {pkg}")); let _ = app.emit("upgrade-progress", 10); // 汉化版只支持官方源和淘宝源 let configured_registry = get_configured_registry(); let registry = if pkg_name.contains("openclaw-zh") { // 汉化版:淘宝源或官方源 if configured_registry.contains("npmmirror.com") || configured_registry.contains("taobao.org") { configured_registry.as_str() } else { "https://registry.npmjs.org" } } else { // 官方版:使用用户配置的镜像源 configured_registry.as_str() }; let mut child = npm_command() .args(["install", "-g", &pkg, "--registry", registry, "--verbose"]) .env("GIT_TERMINAL_PROMPT", "0") .env( "GIT_SSH_COMMAND", "ssh -o BatchMode=yes -o StrictHostKeyChecking=no", ) // Force HTTPS insteadOf via env vars — ensures npm-spawned git subprocesses also use HTTPS // even if global git config didn't take effect (e.g. git not in PATH, or Windows permission issues) .env("GIT_CONFIG_COUNT", "4") .env("GIT_CONFIG_KEY_0", "url.https://github.com/.insteadOf") .env("GIT_CONFIG_VALUE_0", "ssh://git@github.com/") .env("GIT_CONFIG_KEY_1", "url.https://github.com/.insteadOf") .env("GIT_CONFIG_VALUE_1", "git@github.com:") .env("GIT_CONFIG_KEY_2", "url.https://github.com/.insteadOf") .env("GIT_CONFIG_VALUE_2", "git://github.com/") .env("GIT_CONFIG_KEY_3", "url.https://github.com/.insteadOf") .env("GIT_CONFIG_VALUE_3", "git+ssh://git@github.com/") .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| format!("执行升级命令失败: {e}"))?; let stderr = child.stderr.take(); let stdout = child.stdout.take(); // stderr 每行递增进度(10→80 区间),让用户看到进度在动 // 同时收集 stderr 用于失败时返回给前端诊断 let app2 = app.clone(); let stderr_lines = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); let stderr_lines2 = stderr_lines.clone(); let handle = std::thread::spawn(move || { let mut progress: u32 = 15; if let Some(pipe) = stderr { for line in BufReader::new(pipe).lines().map_while(Result::ok) { let _ = app2.emit("upgrade-log", &line); stderr_lines2.lock().unwrap().push(line); if progress < 75 { progress += 2; let _ = app2.emit("upgrade-progress", progress); } } } }); if let Some(pipe) = stdout { for line in BufReader::new(pipe).lines().map_while(Result::ok) { let _ = app.emit("upgrade-log", &line); } } let _ = handle.join(); let _ = app.emit("upgrade-progress", 80); let status = child.wait().map_err(|e| format!("等待进程失败: {e}"))?; let _ = app.emit("upgrade-progress", 100); if !status.success() { let code = status .code() .map(|c| c.to_string()) .unwrap_or("unknown".into()); // 如果使用了镜像源失败,自动降级到官方源重试 let used_mirror = registry.contains("npmmirror.com") || registry.contains("taobao.org"); if used_mirror { let _ = app.emit("upgrade-log", ""); let _ = app.emit("upgrade-log", "⚠️ 镜像源安装失败,自动切换到官方源重试..."); let _ = app.emit("upgrade-progress", 15); let fallback = "https://registry.npmjs.org"; let mut child2 = npm_command() .args(["install", "-g", &pkg, "--registry", fallback, "--verbose"]) .env("GIT_TERMINAL_PROMPT", "0") .env( "GIT_SSH_COMMAND", "ssh -o BatchMode=yes -o StrictHostKeyChecking=no", ) .env("GIT_CONFIG_COUNT", "4") .env("GIT_CONFIG_KEY_0", "url.https://github.com/.insteadOf") .env("GIT_CONFIG_VALUE_0", "ssh://git@github.com/") .env("GIT_CONFIG_KEY_1", "url.https://github.com/.insteadOf") .env("GIT_CONFIG_VALUE_1", "git@github.com:") .env("GIT_CONFIG_KEY_2", "url.https://github.com/.insteadOf") .env("GIT_CONFIG_VALUE_2", "git://github.com/") .env("GIT_CONFIG_KEY_3", "url.https://github.com/.insteadOf") .env("GIT_CONFIG_VALUE_3", "git+ssh://git@github.com/") .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| format!("执行重试命令失败: {e}"))?; let stderr2 = child2.stderr.take(); let stdout2 = child2.stdout.take(); let app3 = app.clone(); let stderr_lines3 = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); let stderr_lines4 = stderr_lines3.clone(); let handle2 = std::thread::spawn(move || { if let Some(pipe) = stderr2 { let mut p: u32 = 20; for line in BufReader::new(pipe).lines().map_while(Result::ok) { let _ = app3.emit("upgrade-log", &line); stderr_lines4.lock().unwrap().push(line); if p < 75 { p += 2; let _ = app3.emit("upgrade-progress", p); } } } }); if let Some(pipe) = stdout2 { for line in BufReader::new(pipe).lines().map_while(Result::ok) { let _ = app.emit("upgrade-log", &line); } } let _ = handle2.join(); let _ = app.emit("upgrade-progress", 80); let status2 = child2 .wait() .map_err(|e| format!("等待重试进程失败: {e}"))?; let _ = app.emit("upgrade-progress", 100); if !status2.success() { let code2 = status2 .code() .map(|c| c.to_string()) .unwrap_or("unknown".into()); let tail = stderr_lines3 .lock() .unwrap() .iter() .rev() .take(15) .rev() .cloned() .collect::>() .join("\n"); return Err(format!( "升级失败(镜像源和官方源均失败),exit code: {code2}\n{tail}" )); } let _ = app.emit("upgrade-log", "✅ 官方源安装成功"); } else { let _ = app.emit("upgrade-log", format!("❌ 升级失败 (exit code: {code})")); let tail = stderr_lines .lock() .unwrap() .iter() .rev() .take(15) .rev() .cloned() .collect::>() .join("\n"); return Err(format!("升级失败,exit code: {code}\n{tail}")); } } // 安装成功后再卸载旧包(确保 CLI 始终可用) if need_uninstall_old { let _ = app.emit("upgrade-log", format!("清理旧版本 ({old_pkg})...")); let _ = npm_command().args(["uninstall", "-g", old_pkg]).output(); } // 切换源后重装 Gateway 服务 if need_uninstall_old { let _ = app.emit("upgrade-log", "正在重装 Gateway 服务(更新启动路径)..."); // 先停掉旧的 #[cfg(target_os = "macos")] { let uid = get_uid().unwrap_or(501); let _ = Command::new("launchctl") .args(["bootout", &format!("gui/{uid}/ai.openclaw.gateway")]) .output(); } #[cfg(not(target_os = "macos"))] { let _ = openclaw_command().args(["gateway", "stop"]).output(); } // 重新安装 use crate::utils::openclaw_command_async; let gw_out = openclaw_command_async() .args(["gateway", "install"]) .output() .await; match gw_out { Ok(o) if o.status.success() => { let _ = app.emit("upgrade-log", "Gateway 服务已重装"); } _ => { let _ = app.emit( "upgrade-log", "⚠️ Gateway 重装失败,请手动执行 openclaw gateway install", ); } } } let new_ver = get_local_version().await.unwrap_or_else(|| "未知".into()); let action = if ver == "latest" { "升级" } else { "安装" }; let msg = format!("✅ {action}成功,当前版本: {new_ver}"); let _ = app.emit("upgrade-log", &msg); Ok(msg) } /// 卸载 OpenClaw(npm uninstall + 可选清理配置) #[tauri::command] pub async fn uninstall_openclaw( app: tauri::AppHandle, clean_config: bool, ) -> Result { use std::io::{BufRead, BufReader}; use std::process::Stdio; use tauri::Emitter; let _guardian_pause = GuardianPause::new("uninstall openclaw"); crate::commands::service::guardian_mark_manual_stop(); let source = detect_installed_source(); let pkg = npm_package_name(&source); // 1. 先停止 Gateway let _ = app.emit("upgrade-log", "正在停止 Gateway..."); #[cfg(target_os = "macos")] { let uid = get_uid().unwrap_or(501); let _ = Command::new("launchctl") .args(["bootout", &format!("gui/{uid}/ai.openclaw.gateway")]) .output(); } #[cfg(not(target_os = "macos"))] { let _ = openclaw_command().args(["gateway", "stop"]).output(); } // 2. 卸载 Gateway 服务 let _ = app.emit("upgrade-log", "正在卸载 Gateway 服务..."); #[cfg(not(target_os = "macos"))] { let _ = openclaw_command().args(["gateway", "uninstall"]).output(); } // 3. npm uninstall let _ = app.emit("upgrade-log", format!("$ npm uninstall -g {pkg}")); let _ = app.emit("upgrade-progress", 20); let mut child = npm_command() .args(["uninstall", "-g", pkg]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| format!("执行卸载命令失败: {e}"))?; let stderr = child.stderr.take(); let stdout = child.stdout.take(); let app2 = app.clone(); let handle = std::thread::spawn(move || { if let Some(pipe) = stderr { for line in BufReader::new(pipe).lines().map_while(Result::ok) { let _ = app2.emit("upgrade-log", &line); } } }); if let Some(pipe) = stdout { for line in BufReader::new(pipe).lines().map_while(Result::ok) { let _ = app.emit("upgrade-log", &line); } } let _ = handle.join(); let _ = app.emit("upgrade-progress", 60); let status = child.wait().map_err(|e| format!("等待进程失败: {e}"))?; if !status.success() { let code = status .code() .map(|c| c.to_string()) .unwrap_or("unknown".into()); return Err(format!("卸载失败,exit code: {code}")); } // 4. 两个包都尝试卸载(确保干净) let other_pkg = if source == "official" { "@qingchencloud/openclaw-zh" } else { "openclaw" }; let _ = app.emit("upgrade-log", format!("清理 {other_pkg}...")); let _ = npm_command().args(["uninstall", "-g", other_pkg]).output(); let _ = app.emit("upgrade-progress", 80); // 5. 可选:清理配置目录 if clean_config { let config_dir = super::openclaw_dir(); if config_dir.exists() { let _ = app.emit( "upgrade-log", format!("清理配置目录: {}", config_dir.display()), ); if let Err(e) = std::fs::remove_dir_all(&config_dir) { let _ = app.emit( "upgrade-log", format!("⚠️ 清理配置目录失败: {e}(可能有文件被占用)"), ); } } } let _ = app.emit("upgrade-progress", 100); let msg = if clean_config { "✅ OpenClaw 已完全卸载(包括配置文件)" } else { "✅ OpenClaw 已卸载(配置文件保留在 ~/.openclaw/)" }; let _ = app.emit("upgrade-log", msg); Ok(msg.into()) } /// 自动初始化配置文件(CLI 已装但 openclaw.json 不存在时) #[tauri::command] pub fn init_openclaw_config() -> Result { let dir = super::openclaw_dir(); let config_path = dir.join("openclaw.json"); let mut result = serde_json::Map::new(); if config_path.exists() { result.insert("created".into(), Value::Bool(false)); result.insert("message".into(), Value::String("配置文件已存在".into())); return Ok(Value::Object(result)); } // 确保目录存在 if !dir.exists() { std::fs::create_dir_all(&dir).map_err(|e| format!("创建目录失败: {e}"))?; } let default_config = serde_json::json!({ "$schema": "https://openclaw.ai/schema/config.json", "meta": { "lastTouchedVersion": "2026.1.1" }, "models": { "providers": {} }, "gateway": { "mode": "local", "port": 18789, "auth": { "mode": "none" }, "controlUi": { "allowedOrigins": ["*"], "allowInsecureAuth": true } }, "tools": { "profile": "full", "sessions": { "visibility": "all" } } }); let content = serde_json::to_string_pretty(&default_config).map_err(|e| format!("序列化失败: {e}"))?; std::fs::write(&config_path, content).map_err(|e| format!("写入失败: {e}"))?; result.insert("created".into(), Value::Bool(true)); result.insert("message".into(), Value::String("配置文件已创建".into())); Ok(Value::Object(result)) } #[tauri::command] pub fn check_installation() -> Result { let dir = super::openclaw_dir(); let installed = dir.join("openclaw.json").exists(); let mut result = serde_json::Map::new(); result.insert("installed".into(), Value::Bool(installed)); result.insert( "path".into(), Value::String(dir.to_string_lossy().to_string()), ); Ok(Value::Object(result)) } /// 检测 Node.js 是否已安装,返回版本号 #[tauri::command] pub fn check_node() -> Result { let mut result = serde_json::Map::new(); let mut cmd = Command::new("node"); cmd.arg("--version"); cmd.env("PATH", super::enhanced_path()); #[cfg(target_os = "windows")] cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW match cmd.output() { Ok(o) if o.status.success() => { let ver = String::from_utf8_lossy(&o.stdout).trim().to_string(); result.insert("installed".into(), Value::Bool(true)); result.insert("version".into(), Value::String(ver)); } _ => { result.insert("installed".into(), Value::Bool(false)); result.insert("version".into(), Value::Null); } } Ok(Value::Object(result)) } /// 在指定路径下检测 node 是否存在 #[tauri::command] pub fn check_node_at_path(node_dir: String) -> Result { let dir = std::path::PathBuf::from(&node_dir); #[cfg(target_os = "windows")] let node_bin = dir.join("node.exe"); #[cfg(not(target_os = "windows"))] let node_bin = dir.join("node"); let mut result = serde_json::Map::new(); if !node_bin.exists() { result.insert("installed".into(), Value::Bool(false)); result.insert("version".into(), Value::Null); return Ok(Value::Object(result)); } let mut cmd = Command::new(&node_bin); cmd.arg("--version"); #[cfg(target_os = "windows")] cmd.creation_flags(0x08000000); match cmd.output() { Ok(o) if o.status.success() => { let ver = String::from_utf8_lossy(&o.stdout).trim().to_string(); result.insert("installed".into(), Value::Bool(true)); result.insert("version".into(), Value::String(ver)); result.insert("path".into(), Value::String(node_dir)); } _ => { result.insert("installed".into(), Value::Bool(false)); result.insert("version".into(), Value::Null); } } Ok(Value::Object(result)) } /// 扫描常见路径,返回所有找到的 Node.js 安装 #[tauri::command] pub fn scan_node_paths() -> Result { let mut found: Vec = vec![]; let home = dirs::home_dir().unwrap_or_default(); let mut candidates: Vec = vec![]; #[cfg(target_os = "windows")] { let pf = std::env::var("ProgramFiles").unwrap_or_else(|_| r"C:\Program Files".into()); let pf86 = std::env::var("ProgramFiles(x86)").unwrap_or_else(|_| r"C:\Program Files (x86)".into()); let localappdata = std::env::var("LOCALAPPDATA").unwrap_or_default(); let appdata = std::env::var("APPDATA").unwrap_or_default(); candidates.push(format!(r"{}\nodejs", pf)); candidates.push(format!(r"{}\nodejs", pf86)); if !localappdata.is_empty() { candidates.push(format!(r"{}\Programs\nodejs", localappdata)); } if !appdata.is_empty() { candidates.push(format!(r"{}\npm", appdata)); } candidates.push(format!(r"{}\.volta\bin", home.display())); candidates.push(format!(r"{}\.nvm", home.display())); for drive in &["C", "D", "E", "F", "G"] { candidates.push(format!(r"{}:\nodejs", drive)); candidates.push(format!(r"{}:\Node", drive)); candidates.push(format!(r"{}:\Node.js", drive)); candidates.push(format!(r"{}:\Program Files\nodejs", drive)); // 扫描常见 AI 工具目录 candidates.push(format!(r"{}:\AI\Node", drive)); candidates.push(format!(r"{}:\AI\nodejs", drive)); candidates.push(format!(r"{}:\Dev\nodejs", drive)); candidates.push(format!(r"{}:\Tools\nodejs", drive)); } } #[cfg(not(target_os = "windows"))] { candidates.push("/usr/local/bin".into()); candidates.push("/opt/homebrew/bin".into()); candidates.push(format!("{}/.nvm/current/bin", home.display())); candidates.push(format!("{}/.volta/bin", home.display())); candidates.push(format!("{}/.nodenv/shims", home.display())); candidates.push(format!("{}/.fnm/current/bin", home.display())); candidates.push(format!("{}/n/bin", home.display())); } for dir in &candidates { let path = std::path::Path::new(dir); #[cfg(target_os = "windows")] let node_bin = path.join("node.exe"); #[cfg(not(target_os = "windows"))] let node_bin = path.join("node"); if node_bin.exists() { let mut cmd = Command::new(&node_bin); cmd.arg("--version"); #[cfg(target_os = "windows")] cmd.creation_flags(0x08000000); if let Ok(o) = cmd.output() { if o.status.success() { let ver = String::from_utf8_lossy(&o.stdout).trim().to_string(); let mut entry = serde_json::Map::new(); entry.insert("path".into(), Value::String(dir.clone())); entry.insert("version".into(), Value::String(ver)); found.push(Value::Object(entry)); } } } } Ok(Value::Array(found)) } /// 保存用户自定义的 Node.js 路径到 ~/.openclaw/clawpanel.json #[tauri::command] pub fn save_custom_node_path(node_dir: String) -> Result<(), String> { let config_path = super::openclaw_dir().join("clawpanel.json"); let mut config: serde_json::Map = if config_path.exists() { let content = std::fs::read_to_string(&config_path).map_err(|e| format!("读取配置失败: {e}"))?; serde_json::from_str(&content).unwrap_or_default() } else { serde_json::Map::new() }; config.insert("nodePath".into(), Value::String(node_dir)); let json = serde_json::to_string_pretty(&Value::Object(config)) .map_err(|e| format!("序列化失败: {e}"))?; std::fs::write(&config_path, json).map_err(|e| format!("写入配置失败: {e}"))?; // 立即刷新 PATH 缓存,使新路径生效(无需重启应用) super::refresh_enhanced_path(); Ok(()) } #[tauri::command] pub fn write_env_file(path: String, config: String) -> Result<(), String> { let expanded = if let Some(stripped) = path.strip_prefix("~/") { dirs::home_dir().unwrap_or_default().join(stripped) } else { PathBuf::from(&path) }; // 安全限制:只允许写入 ~/.openclaw/ 目录下的文件 let openclaw_base = super::openclaw_dir(); if !expanded.starts_with(&openclaw_base) { return Err("只允许写入 ~/.openclaw/ 目录下的文件".to_string()); } if let Some(parent) = expanded.parent() { let _ = fs::create_dir_all(parent); } fs::write(&expanded, &config).map_err(|e| format!("写入 .env 失败: {e}")) } // ===== 备份管理 ===== #[tauri::command] pub fn list_backups() -> Result { let dir = backups_dir(); if !dir.exists() { return Ok(Value::Array(vec![])); } let mut backups: Vec = vec![]; let entries = fs::read_dir(&dir).map_err(|e| format!("读取备份目录失败: {e}"))?; for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("json") { continue; } let name = path .file_name() .unwrap_or_default() .to_string_lossy() .to_string(); let meta = fs::metadata(&path).ok(); let size = meta.as_ref().map(|m| m.len()).unwrap_or(0); // macOS 支持 created(),fallback 到 modified() let created = meta .and_then(|m| m.created().ok().or_else(|| m.modified().ok())) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs()) .unwrap_or(0); let mut obj = serde_json::Map::new(); obj.insert("name".into(), Value::String(name)); obj.insert("size".into(), Value::Number(size.into())); obj.insert("created_at".into(), Value::Number(created.into())); backups.push(Value::Object(obj)); } // 按时间倒序 backups.sort_by(|a, b| { let ta = a.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0); let tb = b.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0); tb.cmp(&ta) }); Ok(Value::Array(backups)) } #[tauri::command] pub fn create_backup() -> Result { let dir = backups_dir(); fs::create_dir_all(&dir).map_err(|e| format!("创建备份目录失败: {e}"))?; let src = super::openclaw_dir().join("openclaw.json"); if !src.exists() { return Err("openclaw.json 不存在".into()); } let now = chrono::Local::now(); let name = format!("openclaw-{}.json", now.format("%Y%m%d-%H%M%S")); let dest = dir.join(&name); fs::copy(&src, &dest).map_err(|e| format!("备份失败: {e}"))?; let size = fs::metadata(&dest).map(|m| m.len()).unwrap_or(0); let mut obj = serde_json::Map::new(); obj.insert("name".into(), Value::String(name)); obj.insert("size".into(), Value::Number(size.into())); Ok(Value::Object(obj)) } /// 检查备份文件名是否安全 fn is_unsafe_backup_name(name: &str) -> bool { name.contains("..") || name.contains('/') || name.contains('\\') } #[tauri::command] pub fn restore_backup(name: String) -> Result<(), String> { if is_unsafe_backup_name(&name) { return Err("非法文件名".into()); } let backup_path = backups_dir().join(&name); if !backup_path.exists() { return Err(format!("备份文件不存在: {name}")); } let target = super::openclaw_dir().join("openclaw.json"); // 恢复前先自动备份当前配置 if target.exists() { let _ = create_backup(); } fs::copy(&backup_path, &target).map_err(|e| format!("恢复失败: {e}"))?; Ok(()) } #[tauri::command] pub fn delete_backup(name: String) -> Result<(), String> { if is_unsafe_backup_name(&name) { return Err("非法文件名".into()); } let path = backups_dir().join(&name); if !path.exists() { return Err(format!("备份文件不存在: {name}")); } fs::remove_file(&path).map_err(|e| format!("删除失败: {e}")) } /// 获取当前用户 UID(macOS/Linux 用 id -u,Windows 返回 0) #[allow(dead_code)] fn get_uid() -> Result { #[cfg(target_os = "windows")] { Ok(0) } #[cfg(not(target_os = "windows"))] { let output = Command::new("id") .arg("-u") .output() .map_err(|e| format!("获取 UID 失败: {e}"))?; String::from_utf8_lossy(&output.stdout) .trim() .parse::() .map_err(|e| format!("解析 UID 失败: {e}")) } } /// 重载 Gateway 服务 /// macOS: launchctl kickstart -k /// Windows/Linux: 直接通过进程管理重启(不走慢 CLI) #[tauri::command] pub async fn reload_gateway() -> Result { #[cfg(target_os = "macos")] { let uid = get_uid()?; let target = format!("gui/{uid}/ai.openclaw.gateway"); let output = tokio::process::Command::new("launchctl") .args(["kickstart", "-k", &target]) .output() .await .map_err(|e| format!("重载失败: {e}"))?; if output.status.success() { Ok("Gateway 已重载".to_string()) } else { let stderr = String::from_utf8_lossy(&output.stderr); Err(format!("重载失败: {stderr}")) } } #[cfg(not(target_os = "macos"))] { // 直接调用服务管理(进程级别),避免慢 CLI 调用 crate::commands::service::restart_service("ai.openclaw.gateway".into()) .await .map(|_| "Gateway 已重载".to_string()) } } /// 重启 Gateway 服务(与 reload_gateway 相同实现) #[tauri::command] pub async fn restart_gateway() -> Result { reload_gateway().await } /// 清理 base URL:去掉尾部斜杠和已知端点路径,防止用户粘贴完整端点 URL 导致路径重复 fn normalize_base_url(raw: &str) -> String { let mut base = raw.trim_end_matches('/').to_string(); for suffix in &[ "/api/chat", "/api/generate", "/api/tags", "/api", "/chat/completions", "/completions", "/responses", "/messages", "/models", ] { if base.ends_with(suffix) { base.truncate(base.len() - suffix.len()); break; } } base = base.trim_end_matches('/').to_string(); if base.ends_with(":11434") { return format!("{base}/v1"); } base } fn normalize_model_api_type(raw: &str) -> &'static str { match raw.trim() { "anthropic" | "anthropic-messages" => "anthropic-messages", "google-gemini" => "google-gemini", "openai" | "openai-completions" | "openai-responses" | "" => "openai-completions", _ => "openai-completions", } } fn normalize_base_url_for_api(raw: &str, api_type: &str) -> String { let mut base = normalize_base_url(raw); match normalize_model_api_type(api_type) { "anthropic-messages" => { if !base.ends_with("/v1") { base.push_str("/v1"); } base } "google-gemini" => base, _ => { // 不再强制追加 /v1,尊重用户填写的 URL(火山引擎等第三方用 /v3 等路径) // 仅 Ollama (端口 11434) 自动补 /v1 base } } } fn extract_error_message(text: &str, status: reqwest::StatusCode) -> String { serde_json::from_str::(text) .ok() .and_then(|v| { v.get("error") .and_then(|e| e.get("message")) .and_then(|m| m.as_str()) .map(String::from) .or_else(|| v.get("message").and_then(|m| m.as_str()).map(String::from)) }) .unwrap_or_else(|| format!("HTTP {status}")) } /// 测试模型连通性:向 provider 发送一个简单的 chat completion 请求 #[tauri::command] pub async fn test_model( base_url: String, api_key: String, model_id: String, api_type: Option, ) -> Result { let api_type = normalize_model_api_type(api_type.as_deref().unwrap_or("openai-completions")); let base = normalize_base_url_for_api(&base_url, api_type); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() .map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?; let resp = match api_type { "anthropic-messages" => { let url = format!("{}/messages", base); let body = json!({ "model": model_id, "messages": [{"role": "user", "content": "Hi"}], "max_tokens": 16, }); let mut req = client .post(&url) .header("anthropic-version", "2023-06-01") .json(&body); if !api_key.is_empty() { req = req.header("x-api-key", api_key.clone()); } req.send() } "google-gemini" => { let url = format!( "{}/models/{}:generateContent?key={}", base, model_id, api_key ); let body = json!({ "contents": [{"role": "user", "parts": [{"text": "Hi"}]}] }); client.post(&url).json(&body).send() } _ => { let url = format!("{}/chat/completions", base); let body = json!({ "model": model_id, "messages": [{"role": "user", "content": "Hi"}], "max_tokens": 16, "stream": false }); let mut req = client.post(&url).json(&body); if !api_key.is_empty() { req = req.header("Authorization", format!("Bearer {api_key}")); } req.send() } } .await .map_err(|e| { if e.is_timeout() { "请求超时 (30s)".to_string() } else if e.is_connect() { format!("连接失败: {e}") } else { format!("请求失败: {e}") } })?; let status = resp.status(); let text = resp.text().await.unwrap_or_default(); if !status.is_success() { let msg = extract_error_message(&text, status); // 401/403 是认证错误,一定要报错 if status.as_u16() == 401 || status.as_u16() == 403 { return Err(msg); } // 其他错误(400/422 等):服务器可达、认证通过,仅模型对简单测试不兼容 // 返回成功但带提示,避免误导用户认为模型不可用 return Ok(format!( "⚠ 连接正常(API 返回 {status},部分模型对简单测试不兼容,不影响实际使用)" )); } // 提取回复内容(兼容多种响应格式) let reply = serde_json::from_str::(&text) .ok() .and_then(|v| { if let Some(arr) = v.get("content").and_then(|c| c.as_array()) { let text = arr .iter() .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text")) .filter_map(|b| b.get("text").and_then(|t| t.as_str())) .collect::>() .join(""); if !text.is_empty() { return Some(text); } } if let Some(t) = v .get("candidates") .and_then(|c| c.get(0)) .and_then(|c| c.get("content")) .and_then(|c| c.get("parts")) .and_then(|p| p.get(0)) .and_then(|p| p.get("text")) .and_then(|t| t.as_str()) .filter(|s| !s.is_empty()) { return Some(t.to_string()); } // 标准 OpenAI 格式: choices[0].message.content if let Some(msg) = v .get("choices") .and_then(|c| c.get(0)) .and_then(|c| c.get("message")) { let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or(""); if !content.is_empty() { return Some(content.to_string()); } // reasoning 模型 if let Some(rc) = msg .get("reasoning_content") .and_then(|c| c.as_str()) .filter(|s| !s.is_empty()) { return Some(format!("[reasoning] {rc}")); } } // DashScope 格式: output.text if let Some(t) = v .get("output") .and_then(|o| o.get("text")) .and_then(|t| t.as_str()) .filter(|s| !s.is_empty()) { return Some(t.to_string()); } None }) .unwrap_or_else(|| "(模型已响应)".into()); Ok(reply) } /// 获取服务商的远程模型列表(调用 /models 接口) #[tauri::command] pub async fn list_remote_models( base_url: String, api_key: String, api_type: Option, ) -> Result, String> { let api_type = normalize_model_api_type(api_type.as_deref().unwrap_or("openai-completions")); let base = normalize_base_url_for_api(&base_url, api_type); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(15)) .build() .map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?; let resp = match api_type { "anthropic-messages" => { let url = format!("{}/models", base); let mut req = client.get(&url).header("anthropic-version", "2023-06-01"); if !api_key.is_empty() { req = req.header("x-api-key", api_key.clone()); } req.send() } "google-gemini" => { let url = format!("{}/models?key={}", base, api_key); client.get(&url).send() } _ => { let url = format!("{}/models", base); let mut req = client.get(&url); if !api_key.is_empty() { req = req.header("Authorization", format!("Bearer {api_key}")); } req.send() } } .await .map_err(|e| { if e.is_timeout() { "请求超时 (15s),该服务商可能不支持模型列表接口".to_string() } else if e.is_connect() { format!("连接失败,请检查接口地址是否正确: {e}") } else { format!("请求失败: {e}") } })?; let status = resp.status(); let text = resp.text().await.unwrap_or_default(); if !status.is_success() { let msg = extract_error_message(&text, status); return Err(format!("获取模型列表失败: {msg}")); } // 解析 OpenAI / Anthropic / Gemini 格式的 /models 响应 let ids = serde_json::from_str::(&text) .ok() .map(|v| { let mut ids: Vec = if let Some(data) = v.get("data").and_then(|d| d.as_array()) { data.iter() .filter_map(|m| m.get("id").and_then(|id| id.as_str()).map(String::from)) .collect() } else if let Some(data) = v.get("models").and_then(|d| d.as_array()) { data.iter() .filter_map(|m| { m.get("name") .and_then(|id| id.as_str()) .map(|s| s.trim_start_matches("models/").to_string()) }) .collect() } else { vec![] }; ids.sort(); ids }) .unwrap_or_default(); if ids.is_empty() { return Err("该服务商返回了空的模型列表,可能不支持 /models 接口".to_string()); } Ok(ids) } /// 安装 Gateway 服务(执行 openclaw gateway install) #[tauri::command] pub async fn install_gateway() -> Result { use crate::utils::openclaw_command_async; let _guardian_pause = GuardianPause::new("install gateway"); // 先检测 openclaw CLI 是否可用 let cli_check = openclaw_command_async().arg("--version").output().await; match cli_check { Ok(o) if o.status.success() => {} _ => { return Err("openclaw CLI 未安装。请先执行以下命令安装:\n\n\ npm install -g @qingchencloud/openclaw-zh\n\n\ 安装完成后再点击此按钮安装 Gateway 服务。" .into()); } } let output = openclaw_command_async() .args(["gateway", "install"]) .output() .await .map_err(|e| format!("安装失败: {e}"))?; if output.status.success() { Ok("Gateway 服务已安装".to_string()) } else { let stderr = String::from_utf8_lossy(&output.stderr); Err(format!("安装失败: {stderr}")) } } /// 卸载 Gateway 服务 /// macOS: launchctl bootout + 删除 plist /// Windows: 直接 taskkill /// Linux: pkill #[tauri::command] pub fn uninstall_gateway() -> Result { let _guardian_pause = GuardianPause::new("uninstall gateway"); crate::commands::service::guardian_mark_manual_stop(); #[cfg(target_os = "macos")] { let uid = get_uid()?; let target = format!("gui/{uid}/ai.openclaw.gateway"); // 先停止服务 let _ = Command::new("launchctl") .args(["bootout", &target]) .output(); // 删除 plist 文件 let home = dirs::home_dir().unwrap_or_default(); let plist = home.join("Library/LaunchAgents/ai.openclaw.gateway.plist"); if plist.exists() { fs::remove_file(&plist).map_err(|e| format!("删除 plist 失败: {e}"))?; } } #[cfg(target_os = "windows")] { // 直接杀死 gateway 相关的 node.exe 进程,不走慢 CLI let _ = Command::new("taskkill") .args(["/f", "/im", "node.exe", "/fi", "WINDOWTITLE eq openclaw*"]) .creation_flags(0x08000000) .output(); } #[cfg(target_os = "linux")] { let _ = Command::new("pkill") .args(["-f", "openclaw.*gateway"]) .output(); } Ok("Gateway 服务已卸载".to_string()) } /// 为 openclaw.json 中所有模型添加 input: ["text", "image"],使 Gateway 识别模型支持图片输入 #[tauri::command] pub fn patch_model_vision() -> Result { let path = super::openclaw_dir().join("openclaw.json"); let content = fs::read_to_string(&path).map_err(|e| format!("读取配置失败: {e}"))?; let mut config: Value = serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {e}"))?; let vision_input = Value::Array(vec![ Value::String("text".into()), Value::String("image".into()), ]); let mut changed = false; if let Some(obj) = config.as_object_mut() { if let Some(models_val) = obj.get_mut("models") { if let Some(models_obj) = models_val.as_object_mut() { if let Some(providers_val) = models_obj.get_mut("providers") { if let Some(providers_obj) = providers_val.as_object_mut() { for (_provider_name, provider_val) in providers_obj.iter_mut() { if let Some(provider_obj) = provider_val.as_object_mut() { if let Some(Value::Array(arr)) = provider_obj.get_mut("models") { for model in arr.iter_mut() { if let Some(mobj) = model.as_object_mut() { if !mobj.contains_key("input") { mobj.insert("input".into(), vision_input.clone()); changed = true; } } } } } } } } } } } if changed { let bak = super::openclaw_dir().join("openclaw.json.bak"); let _ = fs::copy(&path, &bak); let json = serde_json::to_string_pretty(&config).map_err(|e| format!("序列化失败: {e}"))?; fs::write(&path, json).map_err(|e| format!("写入失败: {e}"))?; } Ok(changed) } /// 检查 ClawPanel 自身是否有新版本(GitHub → Gitee 自动降级) #[tauri::command] pub async fn check_panel_update() -> Result { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(8)) .user_agent("ClawPanel") .build() .map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?; // 先尝试 GitHub,失败后降级 Gitee let sources = [ ( "https://api.github.com/repos/qingchencloud/clawpanel/releases/latest", "https://github.com/qingchencloud/clawpanel/releases", "github", ), ( "https://gitee.com/api/v5/repos/QtCodeCreators/clawpanel/releases/latest", "https://gitee.com/QtCodeCreators/clawpanel/releases", "gitee", ), ]; let mut last_err = String::new(); for (api_url, releases_url, source) in &sources { match client.get(*api_url).send().await { Ok(resp) if resp.status().is_success() => { let json: Value = resp .json() .await .map_err(|e| format!("解析响应失败: {e}"))?; let tag = json .get("tag_name") .and_then(|v| v.as_str()) .unwrap_or("") .trim_start_matches('v') .to_string(); if tag.is_empty() { last_err = format!("{source}: 未找到版本号"); continue; } let mut result = serde_json::Map::new(); result.insert("latest".into(), Value::String(tag)); result.insert( "url".into(), json.get("html_url") .cloned() .unwrap_or(Value::String(releases_url.to_string())), ); result.insert("source".into(), Value::String(source.to_string())); result.insert( "downloadUrl".into(), Value::String("https://claw.qt.cool".into()), ); return Ok(Value::Object(result)); } Ok(resp) => { last_err = format!("{source}: HTTP {}", resp.status()); } Err(e) => { last_err = format!("{source}: {e}"); } } } Err(last_err) } // === 面板配置 (clawpanel.json) === #[tauri::command] pub fn read_panel_config() -> Result { let path = super::openclaw_dir().join("clawpanel.json"); if !path.exists() { return Ok(serde_json::json!({})); } let content = fs::read_to_string(&path).map_err(|e| format!("读取失败: {e}"))?; serde_json::from_str(&content).map_err(|e| format!("解析失败: {e}")) } #[tauri::command] pub fn write_panel_config(config: Value) -> Result<(), String> { let dir = super::openclaw_dir(); if !dir.exists() { fs::create_dir_all(&dir).map_err(|e| format!("创建目录失败: {e}"))?; } let path = dir.join("clawpanel.json"); let json = serde_json::to_string_pretty(&config).map_err(|e| format!("序列化失败: {e}"))?; fs::write(&path, json).map_err(|e| format!("写入失败: {e}")) } #[tauri::command] pub fn get_npm_registry() -> Result { Ok(get_configured_registry()) } #[tauri::command] pub fn set_npm_registry(registry: String) -> Result<(), String> { let path = super::openclaw_dir().join("npm-registry.txt"); fs::write(&path, registry.trim()).map_err(|e| format!("保存失败: {e}")) } /// 检测 Git 是否已安装 #[tauri::command] pub fn check_git() -> Result { let mut result = serde_json::Map::new(); let mut cmd = Command::new("git"); cmd.arg("--version"); #[cfg(target_os = "windows")] cmd.creation_flags(0x08000000); match cmd.output() { Ok(o) if o.status.success() => { let ver = String::from_utf8_lossy(&o.stdout).trim().to_string(); result.insert("installed".into(), Value::Bool(true)); result.insert("version".into(), Value::String(ver)); } _ => { result.insert("installed".into(), Value::Bool(false)); result.insert("version".into(), Value::Null); } } Ok(Value::Object(result)) } /// 尝试自动安装 Git(Windows: winget; macOS: xcode-select; Linux: apt/yum) #[tauri::command] pub async fn auto_install_git(app: tauri::AppHandle) -> Result { use std::process::Stdio; use tauri::Emitter; let _ = app.emit("upgrade-log", "正在尝试自动安装 Git..."); #[cfg(target_os = "windows")] { use std::io::{BufRead, BufReader}; // 尝试 winget let _ = app.emit("upgrade-log", "尝试使用 winget 安装 Git..."); let mut child = Command::new("winget") .args([ "install", "--id", "Git.Git", "-e", "--source", "winget", "--accept-package-agreements", "--accept-source-agreements", ]) .creation_flags(0x08000000) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| format!("winget 不可用,请手动安装 Git: {e}"))?; let stderr = child.stderr.take(); let stdout = child.stdout.take(); let app2 = app.clone(); let handle = std::thread::spawn(move || { if let Some(pipe) = stderr { for line in BufReader::new(pipe).lines().map_while(Result::ok) { let _ = app2.emit("upgrade-log", &line); } } }); if let Some(pipe) = stdout { for line in BufReader::new(pipe).lines().map_while(Result::ok) { let _ = app.emit("upgrade-log", &line); } } let _ = handle.join(); let status = child .wait() .map_err(|e| format!("等待 winget 完成失败: {e}"))?; if status.success() { let _ = app.emit("upgrade-log", "Git 安装成功!"); return Ok("Git 已通过 winget 安装".to_string()); } Err("winget 安装 Git 失败,请手动下载安装: https://git-scm.com/downloads".to_string()) } #[cfg(target_os = "macos")] { let _ = app.emit("upgrade-log", "尝试通过 xcode-select 安装 Git..."); let mut child = Command::new("xcode-select") .arg("--install") .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| format!("xcode-select 不可用: {e}"))?; let status = child.wait().map_err(|e| format!("等待安装完成失败: {e}"))?; if status.success() { let _ = app.emit("upgrade-log", "Git 安装已触发,请在弹出的窗口中确认安装。"); return Ok("已触发 xcode-select 安装,请在弹窗中确认".to_string()); } Err( "xcode-select 安装失败,请手动安装 Xcode Command Line Tools 或 brew install git" .to_string(), ) } #[cfg(target_os = "linux")] { use std::io::{BufRead, BufReader}; // 检测包管理器 let pkg_mgr = if Command::new("apt-get") .arg("--version") .output() .map(|o| o.status.success()) .unwrap_or(false) { "apt" } else if Command::new("yum") .arg("--version") .output() .map(|o| o.status.success()) .unwrap_or(false) { "yum" } else if Command::new("dnf") .arg("--version") .output() .map(|o| o.status.success()) .unwrap_or(false) { "dnf" } else if Command::new("pacman") .arg("--version") .output() .map(|o| o.status.success()) .unwrap_or(false) { "pacman" } else { return Err( "未找到包管理器,请手动安装 Git: sudo apt install git 或 sudo yum install git" .to_string(), ); }; let (cmd_name, args): (&str, Vec<&str>) = match pkg_mgr { "apt" => ("sudo", vec!["apt-get", "install", "-y", "git"]), "yum" => ("sudo", vec!["yum", "install", "-y", "git"]), "dnf" => ("sudo", vec!["dnf", "install", "-y", "git"]), "pacman" => ("sudo", vec!["pacman", "-S", "--noconfirm", "git"]), _ => return Err("不支持的包管理器".to_string()), }; let _ = app.emit( "upgrade-log", format!("执行: {} {}", cmd_name, args.join(" ")), ); let mut child = Command::new(cmd_name) .args(&args) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| format!("安装命令执行失败: {e}"))?; let stderr = child.stderr.take(); let stdout = child.stdout.take(); let app2 = app.clone(); let handle = std::thread::spawn(move || { if let Some(pipe) = stderr { for line in BufReader::new(pipe).lines().map_while(Result::ok) { let _ = app2.emit("upgrade-log", &line); } } }); if let Some(pipe) = stdout { for line in BufReader::new(pipe).lines().map_while(Result::ok) { let _ = app.emit("upgrade-log", &line); } } let _ = handle.join(); let status = child.wait().map_err(|e| format!("等待安装完成失败: {e}"))?; if status.success() { let _ = app.emit("upgrade-log", "Git 安装成功!"); return Ok("Git 已安装".to_string()); } Err("Git 安装失败,请手动执行: sudo apt install git".to_string()) } } /// 配置 Git 使用 HTTPS 替代 SSH,解决国内用户 SSH 不通的问题 #[tauri::command] pub fn configure_git_https() -> Result { let mut success = 0; let configs = [ ("url.https://github.com/.insteadOf", "ssh://git@github.com/"), ("url.https://github.com/.insteadOf", "git@github.com:"), ("url.https://github.com/.insteadOf", "git://github.com/"), ]; for (key, value) in &configs { let mut cmd = Command::new("git"); cmd.args(["config", "--global", key, value]); #[cfg(target_os = "windows")] cmd.creation_flags(0x08000000); if cmd.output().map(|o| o.status.success()).unwrap_or(false) { success += 1; } } if success > 0 { Ok(format!("已配置 Git 使用 HTTPS({success} 条规则)")) } else { Err("Git 未安装或配置失败".to_string()) } } /// 刷新 enhanced_path 缓存,使新设置的 Node.js 路径立即生效 #[tauri::command] pub fn invalidate_path_cache() -> Result<(), String> { super::refresh_enhanced_path(); Ok(()) }