diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6303c86..438da83 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -294,6 +294,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.44" @@ -314,6 +320,7 @@ version = "0.1.0" dependencies = [ "chrono", "dirs", + "reqwest 0.12.28", "serde", "serde_json", "tauri", @@ -1010,8 +1017,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1021,9 +1030,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1298,6 +1309,23 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1725,6 +1753,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac" version = "0.1.1" @@ -2505,6 +2539,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.44" @@ -2545,6 +2634,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -2565,6 +2664,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -2583,6 +2692,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -2676,6 +2794,44 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.2" @@ -2710,6 +2866,26 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2719,12 +2895,53 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -2914,6 +3131,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.17.0" @@ -3161,6 +3390,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3314,7 +3549,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.2", "serde", "serde_json", "serde_repr", @@ -3629,6 +3864,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.49.0" @@ -3643,6 +3893,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -3915,6 +4175,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4161,6 +4427,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webkit2gtk" version = "2.0.2" @@ -4205,6 +4481,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4435,6 +4720,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4918,6 +5212,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0af225d..372e833 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,3 +18,4 @@ serde_json = "1" dirs = "6" chrono = "0.4" zip = { version = "2", default-features = false, features = ["deflate"] } +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 7791bfe..0f59604 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -200,3 +200,107 @@ pub fn delete_backup(name: String) -> Result<(), String> { fs::remove_file(&path) .map_err(|e| format!("删除失败: {e}")) } + +/// 重载 Gateway 服务(unload + load plist) +#[tauri::command] +pub fn reload_gateway() -> Result { + let home = dirs::home_dir().unwrap_or_default(); + let plist = format!( + "{}/Library/LaunchAgents/ai.openclaw.gateway.plist", + home.display() + ); + + if !std::path::Path::new(&plist).exists() { + return Err("Gateway plist 不存在".into()); + } + + // 先 unload,忽略错误 + let _ = std::process::Command::new("launchctl") + .args(["unload", &plist]) + .output(); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + let output = std::process::Command::new("launchctl") + .args(["load", &plist]) + .output() + .map_err(|e| format!("重载 Gateway 失败: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.trim().is_empty() { + return Err(format!("重载 Gateway 失败: {stderr}")); + } + } + + Ok("Gateway 已重载".into()) +} + +/// 测试模型连通性:向 provider 发送一个简单的 chat completion 请求 +#[tauri::command] +pub async fn test_model( + base_url: String, + api_key: String, + model_id: String, +) -> Result { + let url = format!("{}/chat/completions", base_url.trim_end_matches('/')); + + let body = serde_json::json!({ + "model": model_id, + "messages": [{"role": "user", "content": "Hi"}], + "max_tokens": 16, + "stream": false + }); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?; + + let mut req = client.post(&url).json(&body); + if !api_key.is_empty() { + req = req.header("Authorization", format!("Bearer {api_key}")); + } + + let resp = 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 = 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) + }) + .unwrap_or_else(|| format!("HTTP {status}")); + return Err(msg); + } + + // 提取回复内容 + let reply = serde_json::from_str::(&text) + .ok() + .and_then(|v| { + v.get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("content")) + .and_then(|c| c.as_str()) + .map(String::from) + }) + .unwrap_or_else(|| "(无回复内容)".into()); + + Ok(reply) +} diff --git a/src-tauri/src/commands/service.rs b/src-tauri/src/commands/service.rs index b66c8d8..5759ced 100644 --- a/src-tauri/src/commands/service.rs +++ b/src-tauri/src/commands/service.rs @@ -1,50 +1,46 @@ /// 服务管理命令 (macOS launchd) +/// 动态扫描 ~/Library/LaunchAgents/ 下的 openclaw/cftunnel 相关 plist +use std::collections::HashMap; +use std::fs; use std::process::Command; use crate::models::types::ServiceStatus; -const SERVICES: &[(&str, &str)] = &[ - ("ai.openclaw.gateway", "OpenClaw Gateway"), - ("com.openclaw.guardian.watch", "健康监控 (60s)"), - ("com.openclaw.guardian.backup", "配置备份 (3600s)"), - ("com.openclaw.watchdog", "看门狗 (120s)"), -]; +/// 友好名称映射 +fn description_map() -> HashMap<&'static str, &'static str> { + HashMap::from([ + ("ai.openclaw.gateway", "OpenClaw Gateway"), + ("com.openclaw.guardian.watch", "健康监控 (60s)"), + ("com.openclaw.guardian.backup", "配置备份 (3600s)"), + ("com.openclaw.watchdog", "看门狗 (120s)"), + ("com.openclaw.webhook-router", "Webhook 路由"), + ("com.openclaw.webhook-tunnel", "Webhook SSH 隧道"), + ("com.openclaw.cf-tunnel", "Cloudflare Tunnel (旧)"), + ("com.cftunnel.cloudflared", "cftunnel 隧道服务"), + ("actions.runner.2221186349-qingchen.openclaw-mac", "GitHub Actions Runner"), + ]) +} -#[tauri::command] -pub fn get_services_status() -> Result, String> { - let output = Command::new("launchctl") - .arg("list") - .output() - .map_err(|e| format!("执行 launchctl 失败: {e}"))?; +/// 动态扫描 LaunchAgents 目录,找出所有 openclaw/cftunnel 相关 plist +fn scan_plist_labels() -> Vec { + let home = dirs::home_dir().unwrap_or_default(); + let agents_dir = home.join("Library/LaunchAgents"); + let mut labels = Vec::new(); - let stdout = String::from_utf8_lossy(&output.stdout); - let mut results = Vec::new(); - - for (label, desc) in SERVICES { - let mut status = ServiceStatus { - label: label.to_string(), - pid: None, - running: false, - description: desc.to_string(), - }; - - // 解析 launchctl list 输出: PID\tStatus\tLabel - for line in stdout.lines() { - if line.contains(label) { - let parts: Vec<&str> = line.split('\t').collect(); - if parts.len() >= 3 { - if let Ok(pid) = parts[0].trim().parse::() { - status.pid = Some(pid); - status.running = true; - } - } - break; + if let Ok(entries) = fs::read_dir(&agents_dir) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if (name.contains("openclaw") || name.contains("cftunnel")) + && name.ends_with(".plist") + { + // 文件名去掉 .plist 就是 label + let label = name.trim_end_matches(".plist").to_string(); + labels.push(label); } } - results.push(status); } - - Ok(results) + labels.sort(); + labels } fn plist_path(label: &str) -> String { @@ -56,36 +52,100 @@ fn plist_path(label: &str) -> String { ) } +#[tauri::command] +pub fn get_services_status() -> Result, String> { + let output = Command::new("launchctl") + .arg("list") + .output() + .map_err(|e| format!("执行 launchctl 失败: {e}"))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let labels = scan_plist_labels(); + let desc_map = description_map(); + let mut results = Vec::new(); + + for label in &labels { + let mut status = ServiceStatus { + label: label.clone(), + pid: None, + running: false, + description: desc_map + .get(label.as_str()) + .unwrap_or(&"") + .to_string(), + }; + + // 解析 launchctl list 输出: PID\tStatus\tLabel + for line in stdout.lines() { + let parts: Vec<&str> = line.split('\t').collect(); + if parts.len() >= 3 && parts[2] == label { + if let Ok(pid) = parts[0].trim().parse::() { + status.pid = Some(pid); + status.running = true; + } + // PID 为 "-" 但 label 存在于 launchctl list 中 → 已加载但未运行 + break; + } + } + results.push(status); + } + + Ok(results) +} + #[tauri::command] pub fn start_service(label: String) -> Result<(), String> { let path = plist_path(&label); - Command::new("launchctl") + let output = Command::new("launchctl") .args(["load", &path]) .output() .map_err(|e| format!("启动失败: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.trim().is_empty() { + return Err(format!("启动 {label} 失败: {stderr}")); + } + } Ok(()) } #[tauri::command] pub fn stop_service(label: String) -> Result<(), String> { let path = plist_path(&label); - Command::new("launchctl") + let output = Command::new("launchctl") .args(["unload", &path]) .output() .map_err(|e| format!("停止失败: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.trim().is_empty() { + return Err(format!("停止 {label} 失败: {stderr}")); + } + } Ok(()) } #[tauri::command] pub fn restart_service(label: String) -> Result<(), String> { let path = plist_path(&label); + // 先 unload,忽略错误(可能本来就没加载) let _ = Command::new("launchctl") .args(["unload", &path]) .output(); std::thread::sleep(std::time::Duration::from_millis(500)); - Command::new("launchctl") + + let output = Command::new("launchctl") .args(["load", &path]) .output() .map_err(|e| format!("重启失败: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.trim().is_empty() { + return Err(format!("重启 {label} 失败: {stderr}")); + } + } Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 199e98f..d0bedc5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,6 +19,8 @@ pub fn run() { config::create_backup, config::restore_backup, config::delete_backup, + config::reload_gateway, + config::test_model, // 服务 service::get_services_status, service::start_service, diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index b082c82..4ea33b2 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -18,10 +18,13 @@ async function invoke(cmd, args = {}) { function mockInvoke(cmd, args) { const mocks = { get_services_status: () => [ - { label: 'ai.openclaw.gateway', pid: 54284, running: true, description: 'OpenClaw Gateway' }, - { label: 'com.openclaw.guardian.watch', pid: 54301, running: true, description: '健康监控 (60s)' }, + { label: 'ai.openclaw.gateway', pid: null, running: false, description: 'OpenClaw Gateway' }, + { label: 'com.cftunnel.cloudflared', pid: 35218, running: true, description: 'cftunnel 隧道服务' }, + { label: 'com.openclaw.guardian.watch', pid: 55290, running: true, description: '健康监控 (60s)' }, { label: 'com.openclaw.guardian.backup', pid: null, running: false, description: '配置备份 (3600s)' }, - { label: 'com.openclaw.watchdog', pid: 54320, running: true, description: '看门狗 (120s)' }, + { label: 'com.openclaw.watchdog', pid: null, running: false, description: '看门狗 (120s)' }, + { label: 'com.openclaw.webhook-router', pid: 38983, running: true, description: 'Webhook 路由' }, + { label: 'com.openclaw.webhook-tunnel', pid: null, running: false, description: 'Webhook SSH 隧道' }, ], get_version_info: () => ({ current: '2026.2.23', @@ -99,6 +102,8 @@ function mockInvoke(cmd, args) { start_service: () => true, stop_service: () => true, restart_service: () => true, + reload_gateway: () => 'Gateway 已重载', + test_model: ({ base_url, model_id }) => `模型 ${model_id} 连通正常 (mock)`, write_env_file: () => true, list_backups: () => [ { name: 'openclaw-20260226-143000.json', size: 8542, created_at: 1740577800 }, @@ -138,6 +143,8 @@ export const api = { writeOpenclawConfig: (config) => invoke('write_openclaw_config', { config }), readMcpConfig: () => invoke('read_mcp_config'), writeMcpConfig: (config) => invoke('write_mcp_config', { config }), + reloadGateway: () => invoke('reload_gateway'), + testModel: (baseUrl, apiKey, modelId) => invoke('test_model', { base_url: baseUrl, api_key: apiKey, model_id: modelId }), // 日志 readLogTail: (logName, lines = 100) => invoke('read_log_tail', { logName, lines }), diff --git a/src/pages/gateway.js b/src/pages/gateway.js index 33fae78..8d112a2 100644 --- a/src/pages/gateway.js +++ b/src/pages/gateway.js @@ -122,7 +122,13 @@ async function saveConfig(page, state) { try { await api.writeOpenclawConfig(state.config) - toast('Gateway 配置已保存', 'success') + toast('Gateway 配置已保存,正在重载服务...', 'info') + try { + await api.reloadGateway() + toast('Gateway 已重载,配置已生效', 'success') + } catch (e) { + toast('配置已保存,但重载 Gateway 失败: ' + e, 'warning') + } } catch (e) { toast('保存失败: ' + e, 'error') } diff --git a/src/pages/models.js b/src/pages/models.js index 22e7588..551357d 100644 --- a/src/pages/models.js +++ b/src/pages/models.js @@ -145,6 +145,7 @@ function renderModelCards(providerKey, models, primary) {
+ ${!isPrimary ? `` : ''} @@ -191,6 +192,10 @@ function bindProviderEvents(page, state) { renderProviders(page, state) renderDefaultBar(page, state) toast(`已设为主模型: ${full}`, 'success') + } else if (action === 'test-model') { + const card = btn.closest('.model-card') + const idx = parseInt(card.dataset.index) + testModel(btn, state, providerKey, idx) } } }) @@ -214,7 +219,13 @@ function bindTopActions(page, state) { btn.textContent = '保存中...' try { await api.writeOpenclawConfig(state.config) - toast('模型配置已保存', 'success') + toast('模型配置已保存,正在重载 Gateway...', 'info') + try { + await api.reloadGateway() + toast('Gateway 已重载,模型配置已生效', 'success') + } catch (e) { + toast('配置已保存,但重载 Gateway 失败: ' + e, 'warning') + } } catch (e) { toast('保存失败: ' + e, 'error') } finally { @@ -236,7 +247,13 @@ function bindTopActions(page, state) { applyDefaultModel(state) await api.writeOpenclawConfig(state.config) renderDefaultBar(page, state) - toast('默认模型已应用', 'success') + toast('默认模型已应用,正在重载 Gateway...', 'info') + try { + await api.reloadGateway() + toast('Gateway 已重载,默认模型已生效', 'success') + } catch (e) { + toast('配置已保存,但重载 Gateway 失败: ' + e, 'warning') + } } catch (e) { toast('应用失败: ' + e, 'error') } finally { @@ -350,3 +367,24 @@ function editModel(page, state, providerKey, idx) { }, }) } + +// 测试模型连通性 +async function testModel(btn, state, providerKey, idx) { + const provider = state.config.models.providers[providerKey] + const model = provider.models[idx] + const modelId = typeof model === 'string' ? model : model.id + + btn.disabled = true + const origText = btn.textContent + btn.textContent = '测试中...' + + try { + const reply = await api.testModel(provider.baseUrl, provider.apiKey || '', modelId) + toast(`${modelId} 连通正常: "${reply.slice(0, 60)}"`, 'success') + } catch (e) { + toast(`${modelId} 测试失败: ${e}`, 'error') + } finally { + btn.disabled = false + btn.textContent = origText + } +}