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

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,