mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-03 06:40:10 +08:00
fix: 修复功能空壳问题 + 新增模型测试
- 服务管理:动态扫描 LaunchAgents plist,不再硬编码 4 个服务 - 服务启停:检查 launchctl 执行结果,失败时返回 stderr - 配置保存:Gateway/模型配置保存后自动重载 Gateway 服务使配置生效 - 模型测试:新增 test_model 命令,向 provider 发送 chat completion 验证连通性 - 新增 reqwest 依赖用于 HTTP 请求
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user