fix: 修复功能空壳问题 + 新增模型测试

- 服务管理:动态扫描 LaunchAgents plist,不再硬编码 4 个服务
- 服务启停:检查 launchctl 执行结果,失败时返回 stderr
- 配置保存:Gateway/模型配置保存后自动重载 Gateway 服务使配置生效
- 模型测试:新增 test_model 命令,向 provider 发送 chat completion 验证连通性
- 新增 reqwest 依赖用于 HTTP 请求
This commit is contained in:
晴天
2026-02-27 01:14:34 +08:00
parent fedd2f66fc
commit 0f79ce338f
8 changed files with 565 additions and 47 deletions

302
src-tauri/Cargo.lock generated
View File

@@ -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"

View File

@@ -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 }

View File

@@ -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<String, String> {
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<String, String> {
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::<serde_json::Value>(&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::<serde_json::Value>(&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)
}

View File

@@ -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<Vec<ServiceStatus>, String> {
let output = Command::new("launchctl")
.arg("list")
.output()
.map_err(|e| format!("执行 launchctl 失败: {e}"))?;
/// 动态扫描 LaunchAgents 目录,找出所有 openclaw/cftunnel 相关 plist
fn scan_plist_labels() -> Vec<String> {
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::<u32>() {
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<Vec<ServiceStatus>, 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::<u32>() {
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(())
}

View File

@@ -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,

View File

@@ -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 }),

View File

@@ -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')
}

View File

@@ -145,6 +145,7 @@ function renderModelCards(providerKey, models, primary) {
</div>
</div>
<div style="display:flex;gap:6px;flex-shrink:0">
<button class="btn btn-sm btn-secondary" data-action="test-model">测试</button>
${!isPrimary ? `<button class="btn btn-sm btn-secondary" data-action="set-primary">设为主模型</button>` : ''}
<button class="btn btn-sm btn-secondary" data-action="edit-model">编辑</button>
<button class="btn btn-sm btn-danger" data-action="delete-model">删除</button>
@@ -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
}
}