mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-12 02:20:58 +08:00
feat: new pages + dashboard enhancements + backend improvements
New pages: - Plugin Hub: grid cards, search, install/toggle/enable plugins - Route Map: SVG visualization of channels→agents bindings with legends - Diagnose: gateway connectivity diagnosis with step-by-step checks Dashboard enhancements: - WebSocket status indicator (connected/handshaking/reconnecting/disconnected) - Connected channels overview with platform icons - Colored log level badges (ERROR/WARN/INFO/DEBUG) with timestamps - Channels data loading in dashboard secondary fetch Splash screen: - Multi-stage boot detection (JS not loaded vs boot slow vs timeout) - 15s: WebView2/resource load failure - 20s: "initializing..." hint with elapsed counter - 90s: true timeout error Backend (Rust): - diagnose.rs: gateway connectivity diagnosis command - messaging.rs: plugin management commands - service.rs: improvements - lib.rs: register new commands Frontend libs: - feature-gates.js: feature flag system - ws-client.js: reconnect state tracking - tauri-api.js: new API bindings - model-presets.js: provider fixes - Remove gateway-guardian-policy.js (unused) Dev API (scripts/dev-api.js): - list_all_plugins, toggle_plugin, install_plugin handlers - probe_gateway_port, diagnose_gateway_connection handlers i18n: dashboard, sidebar, diagnose, extensions, routeMap locale modules CSS: plugin-hub cards, route-map SVG styles
This commit is contained in:
@@ -88,8 +88,14 @@ mod hex {
|
||||
}
|
||||
|
||||
/// 生成 Gateway connect 帧(含 Ed25519 签名)
|
||||
/// gateway_token: token 模式认证凭据(可为空)
|
||||
/// gateway_password: password 模式认证凭据(可为空,新增)
|
||||
#[tauri::command]
|
||||
pub fn create_connect_frame(nonce: String, gateway_token: String) -> Result<Value, String> {
|
||||
pub fn create_connect_frame(
|
||||
nonce: String,
|
||||
gateway_token: String,
|
||||
gateway_password: Option<String>,
|
||||
) -> Result<Value, String> {
|
||||
let (device_id, pub_b64, signing_key) = get_or_create_key()?;
|
||||
let signed_at = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -99,17 +105,34 @@ pub fn create_connect_frame(nonce: String, gateway_token: String) -> Result<Valu
|
||||
let platform = std::env::consts::OS; // "windows" | "macos" | "linux"
|
||||
let device_family = "desktop";
|
||||
|
||||
// v3 签名 payload 中 token 字段:优先 token,其次 password,最后空串
|
||||
let auth_secret = if !gateway_token.is_empty() {
|
||||
&gateway_token
|
||||
} else {
|
||||
gateway_password.as_deref().unwrap_or("")
|
||||
};
|
||||
|
||||
let scopes_str = SCOPES.join(",");
|
||||
// v3 格式:v3|deviceId|clientId|clientMode|role|scopes|signedAt|token|nonce|platform|deviceFamily
|
||||
// 使用 openclaw-control-ui + ui 模式,使 Gateway 识别为 Control UI 客户端,
|
||||
// 本地连接时触发静默自动配对(shouldAllowSilentLocalPairing = true)
|
||||
let payload_str = format!(
|
||||
"v3|{device_id}|openclaw-control-ui|ui|operator|{scopes_str}|{signed_at}|{gateway_token}|{nonce}|{platform}|{device_family}"
|
||||
"v3|{device_id}|openclaw-control-ui|ui|operator|{scopes_str}|{signed_at}|{auth_secret}|{nonce}|{platform}|{device_family}"
|
||||
);
|
||||
|
||||
let signature = signing_key.sign(payload_str.as_bytes());
|
||||
let sig_b64 = base64_url_encode(&signature.to_bytes());
|
||||
|
||||
// 构建 auth 对象:根据有无 token/password 选择填充字段
|
||||
let password = gateway_password.unwrap_or_default();
|
||||
let auth = if !gateway_token.is_empty() {
|
||||
serde_json::json!({ "token": gateway_token })
|
||||
} else if !password.is_empty() {
|
||||
serde_json::json!({ "password": password })
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
|
||||
let frame = serde_json::json!({
|
||||
"type": "req",
|
||||
"id": format!("connect-{:08x}-{:04x}", signed_at as u32, rand::random::<u16>()),
|
||||
@@ -127,7 +150,7 @@ pub fn create_connect_frame(nonce: String, gateway_token: String) -> Result<Valu
|
||||
"role": "operator",
|
||||
"scopes": SCOPES,
|
||||
"caps": ["tool-events"],
|
||||
"auth": { "token": gateway_token },
|
||||
"auth": auth,
|
||||
"device": {
|
||||
"id": device_id,
|
||||
"publicKey": pub_b64,
|
||||
|
||||
290
src-tauri/src/commands/diagnose.rs
Normal file
290
src-tauri/src/commands/diagnose.rs
Normal file
@@ -0,0 +1,290 @@
|
||||
/// Gateway 连接诊断命令
|
||||
///
|
||||
/// 执行一系列检查步骤,返回结构化诊断结果,帮助用户定位连接问题。
|
||||
use serde::Serialize;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DiagnoseStep {
|
||||
pub name: String,
|
||||
pub ok: bool,
|
||||
pub message: String,
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DiagnoseEnv {
|
||||
pub openclaw_dir: String,
|
||||
pub config_exists: bool,
|
||||
pub port: u16,
|
||||
pub auth_mode: String,
|
||||
pub device_key_exists: bool,
|
||||
pub gateway_owner: Option<String>,
|
||||
pub err_log_excerpt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DiagnoseResult {
|
||||
pub steps: Vec<DiagnoseStep>,
|
||||
pub env: DiagnoseEnv,
|
||||
pub overall_ok: bool,
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
fn step_timer() -> Instant {
|
||||
Instant::now()
|
||||
}
|
||||
|
||||
fn finish_step(name: &str, ok: bool, message: &str, start: Instant) -> DiagnoseStep {
|
||||
DiagnoseStep {
|
||||
name: name.to_string(),
|
||||
ok,
|
||||
message: message.to_string(),
|
||||
duration_ms: start.elapsed().as_millis() as u64,
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取环境信息
|
||||
fn collect_env() -> DiagnoseEnv {
|
||||
let openclaw_dir = crate::commands::openclaw_dir();
|
||||
let config_path = openclaw_dir.join("openclaw.json");
|
||||
let config_exists = config_path.exists();
|
||||
let port = crate::commands::gateway_listen_port();
|
||||
|
||||
// 认证模式
|
||||
let auth_mode = if let Ok(content) = std::fs::read_to_string(&config_path) {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
let auth = val.get("gateway").and_then(|g| g.get("auth"));
|
||||
if let Some(auth) = auth {
|
||||
if auth.get("token").and_then(|t| t.as_str()).map(|s| !s.is_empty()).unwrap_or(false) {
|
||||
"token".to_string()
|
||||
} else if auth.get("password").and_then(|p| p.as_str()).map(|s| !s.is_empty()).unwrap_or(false) {
|
||||
"password".to_string()
|
||||
} else {
|
||||
"none".to_string()
|
||||
}
|
||||
} else {
|
||||
"none".to_string()
|
||||
}
|
||||
} else {
|
||||
"config_parse_error".to_string()
|
||||
}
|
||||
} else {
|
||||
"config_missing".to_string()
|
||||
};
|
||||
|
||||
// 设备密钥
|
||||
let device_key_path = openclaw_dir.join("clawpanel-device-key.json");
|
||||
let device_key_exists = device_key_path.exists();
|
||||
|
||||
// Gateway owner
|
||||
let owner_path = openclaw_dir.join("gateway-owner.json");
|
||||
let gateway_owner = std::fs::read_to_string(&owner_path).ok();
|
||||
|
||||
// 错误日志
|
||||
let err_log_path = openclaw_dir.join("logs").join("gateway.err.log");
|
||||
let err_log_excerpt = if let Ok(bytes) = std::fs::read(&err_log_path) {
|
||||
let max = 2048;
|
||||
let tail = if bytes.len() > max { &bytes[bytes.len() - max..] } else { &bytes[..] };
|
||||
String::from_utf8_lossy(tail).to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
DiagnoseEnv {
|
||||
openclaw_dir: openclaw_dir.display().to_string(),
|
||||
config_exists,
|
||||
port,
|
||||
auth_mode,
|
||||
device_key_exists,
|
||||
gateway_owner,
|
||||
err_log_excerpt,
|
||||
}
|
||||
}
|
||||
|
||||
/// TCP 端口探测
|
||||
async fn check_tcp_port(port: u16) -> DiagnoseStep {
|
||||
let t = step_timer();
|
||||
let addr = format!("127.0.0.1:{port}");
|
||||
match tokio::net::TcpStream::connect(&addr).await {
|
||||
Ok(_) => finish_step("tcp_port", true, &format!("端口 {port} 可达"), t),
|
||||
Err(e) => finish_step("tcp_port", false, &format!("端口 {port} 不可达: {e}"), t),
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查配置文件
|
||||
fn check_config() -> DiagnoseStep {
|
||||
let t = step_timer();
|
||||
let config_path = crate::commands::openclaw_dir().join("openclaw.json");
|
||||
if !config_path.exists() {
|
||||
return finish_step("config", false, "openclaw.json 不存在", t);
|
||||
}
|
||||
match std::fs::read_to_string(&config_path) {
|
||||
Ok(content) => {
|
||||
match serde_json::from_str::<serde_json::Value>(&content) {
|
||||
Ok(val) => {
|
||||
if val.get("gateway").is_some() {
|
||||
finish_step("config", true, "配置文件有效,含 gateway 配置", t)
|
||||
} else {
|
||||
finish_step("config", false, "配置文件缺少 gateway 段", t)
|
||||
}
|
||||
}
|
||||
Err(e) => finish_step("config", false, &format!("JSON 解析失败: {e}"), t),
|
||||
}
|
||||
}
|
||||
Err(e) => finish_step("config", false, &format!("读取失败: {e}"), t),
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查设备密钥
|
||||
fn check_device_key() -> DiagnoseStep {
|
||||
let t = step_timer();
|
||||
let key_path = crate::commands::openclaw_dir().join("clawpanel-device-key.json");
|
||||
if key_path.exists() {
|
||||
match std::fs::read_to_string(&key_path) {
|
||||
Ok(content) => {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
if val.get("deviceId").is_some() && val.get("publicKey").is_some() {
|
||||
finish_step("device_key", true, "设备密钥有效", t)
|
||||
} else {
|
||||
finish_step("device_key", false, "设备密钥文件缺少必要字段", t)
|
||||
}
|
||||
} else {
|
||||
finish_step("device_key", false, "设备密钥文件 JSON 无效", t)
|
||||
}
|
||||
}
|
||||
Err(e) => finish_step("device_key", false, &format!("读取失败: {e}"), t),
|
||||
}
|
||||
} else {
|
||||
finish_step("device_key", false, "设备密钥不存在(将在首次连接时自动生成)", t)
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查 allowedOrigins 配置
|
||||
fn check_allowed_origins() -> DiagnoseStep {
|
||||
let t = step_timer();
|
||||
let config_path = crate::commands::openclaw_dir().join("openclaw.json");
|
||||
match std::fs::read_to_string(&config_path) {
|
||||
Ok(content) => {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
let origins = val
|
||||
.get("gateway")
|
||||
.and_then(|g| g.get("controlUi"))
|
||||
.and_then(|c| c.get("allowedOrigins"))
|
||||
.and_then(|o| o.as_array());
|
||||
match origins {
|
||||
Some(arr) if !arr.is_empty() => {
|
||||
let list: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect();
|
||||
let has_tauri = list.iter().any(|o| o.contains("tauri://") || o.contains("https://tauri.localhost"));
|
||||
if has_tauri {
|
||||
finish_step("allowed_origins", true, &format!("allowedOrigins 包含 Tauri origin: {:?}", list), t)
|
||||
} else {
|
||||
finish_step("allowed_origins", false, &format!("allowedOrigins 缺少 Tauri origin: {:?}", list), t)
|
||||
}
|
||||
}
|
||||
Some(_) => finish_step("allowed_origins", false, "allowedOrigins 为空数组", t),
|
||||
None => finish_step("allowed_origins", false, "未配置 allowedOrigins(autoPair 会自动修复)", t),
|
||||
}
|
||||
} else {
|
||||
finish_step("allowed_origins", false, "配置文件解析失败", t)
|
||||
}
|
||||
}
|
||||
Err(_) => finish_step("allowed_origins", false, "配置文件不可读", t),
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP /health 探测(尝试性,上游可能未暴露)
|
||||
async fn check_http_health(port: u16) -> DiagnoseStep {
|
||||
let t = step_timer();
|
||||
let url = format!("http://127.0.0.1:{port}/health");
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build();
|
||||
match client {
|
||||
Ok(c) => match c.get(&url).send().await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
finish_step("http_health", true, &format!("HTTP /health 返回 {status}"), t)
|
||||
} else {
|
||||
finish_step("http_health", false, &format!("HTTP /health 返回 {status}"), t)
|
||||
}
|
||||
}
|
||||
Err(e) => finish_step("http_health", false, &format!("HTTP /health 请求失败: {e}"), t),
|
||||
},
|
||||
Err(e) => finish_step("http_health", false, &format!("HTTP client 创建失败: {e}"), t),
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查 Gateway 错误日志
|
||||
fn check_error_log() -> DiagnoseStep {
|
||||
let t = step_timer();
|
||||
let log_path = crate::commands::openclaw_dir().join("logs").join("gateway.err.log");
|
||||
if !log_path.exists() {
|
||||
return finish_step("err_log", true, "无错误日志(正常)", t);
|
||||
}
|
||||
match std::fs::metadata(&log_path) {
|
||||
Ok(meta) => {
|
||||
let size = meta.len();
|
||||
if size == 0 {
|
||||
finish_step("err_log", true, "错误日志为空(正常)", t)
|
||||
} else {
|
||||
// 读最后 1KB 看有没有关键错误
|
||||
let content = std::fs::read(&log_path).unwrap_or_default();
|
||||
let tail = if content.len() > 1024 { &content[content.len() - 1024..] } else { &content[..] };
|
||||
let text = String::from_utf8_lossy(tail).to_lowercase();
|
||||
let has_fatal = text.contains("fatal") || text.contains("eaddrinuse") || text.contains("config invalid");
|
||||
if has_fatal {
|
||||
finish_step("err_log", false, &format!("错误日志含关键错误 ({size} bytes)"), t)
|
||||
} else {
|
||||
finish_step("err_log", true, &format!("错误日志存在但无致命错误 ({size} bytes)"), t)
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => finish_step("err_log", false, &format!("无法读取日志: {e}"), t),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn diagnose_gateway_connection() -> DiagnoseResult {
|
||||
let env = collect_env();
|
||||
let port = env.port;
|
||||
|
||||
let mut steps = Vec::new();
|
||||
|
||||
// 1. 配置文件检查
|
||||
steps.push(check_config());
|
||||
|
||||
// 2. 设备密钥检查
|
||||
steps.push(check_device_key());
|
||||
|
||||
// 3. allowedOrigins 检查
|
||||
steps.push(check_allowed_origins());
|
||||
|
||||
// 4. TCP 端口探测
|
||||
steps.push(check_tcp_port(port).await);
|
||||
|
||||
// 5. HTTP /health 探测
|
||||
steps.push(check_http_health(port).await);
|
||||
|
||||
// 6. 错误日志检查
|
||||
steps.push(check_error_log());
|
||||
|
||||
let overall_ok = steps.iter().all(|s| s.ok);
|
||||
let failed: Vec<&str> = steps.iter().filter(|s| !s.ok).map(|s| s.name.as_str()).collect();
|
||||
let summary = if overall_ok {
|
||||
"所有检查项通过".to_string()
|
||||
} else {
|
||||
format!("以下检查未通过: {}", failed.join(", "))
|
||||
};
|
||||
|
||||
DiagnoseResult {
|
||||
steps,
|
||||
env,
|
||||
overall_ok,
|
||||
summary,
|
||||
}
|
||||
}
|
||||
@@ -2099,6 +2099,152 @@ pub async fn get_channel_plugin_status(plugin_id: String) -> Result<Value, Strin
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_all_plugins() -> Result<Value, String> {
|
||||
let cfg = super::config::load_openclaw_json().unwrap_or_else(|_| json!({}));
|
||||
let entries = cfg
|
||||
.pointer("/plugins/entries")
|
||||
.and_then(|v| v.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let allow_arr = cfg
|
||||
.pointer("/plugins/allow")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let ext_dir = super::openclaw_dir().join("extensions");
|
||||
let mut plugins: Vec<Value> = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
// Scan extensions directory
|
||||
if ext_dir.is_dir() {
|
||||
if let Ok(rd) = std::fs::read_dir(&ext_dir) {
|
||||
for entry in rd.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with('.') { continue; }
|
||||
let p = entry.path();
|
||||
if !p.is_dir() { continue; }
|
||||
let has_marker = p.join("package.json").is_file()
|
||||
|| p.join("plugin.ts").is_file()
|
||||
|| p.join("index.js").is_file();
|
||||
if !has_marker { continue; }
|
||||
|
||||
let plugin_id = name.clone();
|
||||
seen.insert(plugin_id.clone());
|
||||
|
||||
let entry_cfg = entries.get(&plugin_id);
|
||||
let enabled = entry_cfg
|
||||
.and_then(|e| e.get("enabled"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let allowed = allow_arr.iter().any(|v| v.as_str() == Some(&plugin_id));
|
||||
let builtin = is_plugin_builtin(&plugin_id);
|
||||
|
||||
// Try to read version from package.json
|
||||
let version = std::fs::read_to_string(p.join("package.json"))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
|
||||
.and_then(|v| v.get("version").and_then(|v| v.as_str().map(String::from)));
|
||||
|
||||
let description = std::fs::read_to_string(p.join("package.json"))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
|
||||
.and_then(|v| v.get("description").and_then(|v| v.as_str().map(String::from)));
|
||||
|
||||
plugins.push(json!({
|
||||
"id": plugin_id,
|
||||
"installed": true,
|
||||
"builtin": builtin,
|
||||
"enabled": enabled,
|
||||
"allowed": allowed,
|
||||
"version": version,
|
||||
"description": description,
|
||||
"config": entry_cfg.and_then(|e| e.get("config")),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also include entries from config that might not be in extensions dir (built-in)
|
||||
for (pid, entry_val) in &entries {
|
||||
if seen.contains(pid.as_str()) { continue; }
|
||||
seen.insert(pid.clone());
|
||||
let enabled = entry_val.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let allowed = allow_arr.iter().any(|v| v.as_str() == Some(pid.as_str()));
|
||||
let builtin = is_plugin_builtin(pid);
|
||||
plugins.push(json!({
|
||||
"id": pid,
|
||||
"installed": builtin,
|
||||
"builtin": builtin,
|
||||
"enabled": enabled,
|
||||
"allowed": allowed,
|
||||
"version": null,
|
||||
"description": null,
|
||||
"config": entry_val.get("config"),
|
||||
}));
|
||||
}
|
||||
|
||||
plugins.sort_by(|a, b| {
|
||||
let ae = a.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let be = b.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
be.cmp(&ae).then_with(|| {
|
||||
let an = a.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let bn = b.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
an.cmp(bn)
|
||||
})
|
||||
});
|
||||
|
||||
Ok(json!({ "plugins": plugins }))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn toggle_plugin(plugin_id: String, enabled: bool) -> Result<Value, String> {
|
||||
let plugin_id = plugin_id.trim();
|
||||
if plugin_id.is_empty() {
|
||||
return Err("plugin_id 不能为空".into());
|
||||
}
|
||||
|
||||
let config_path = super::openclaw_dir().join("openclaw.json");
|
||||
let mut cfg = super::config::load_openclaw_json().unwrap_or_else(|_| json!({}));
|
||||
|
||||
if enabled {
|
||||
ensure_plugin_allowed(&mut cfg, plugin_id)?;
|
||||
} else {
|
||||
disable_legacy_plugin(&mut cfg, plugin_id);
|
||||
}
|
||||
|
||||
let content = serde_json::to_string_pretty(&cfg).map_err(|e| format!("序列化失败: {e}"))?;
|
||||
std::fs::write(&config_path, content).map_err(|e| format!("写入配置失败: {e}"))?;
|
||||
|
||||
Ok(json!({ "ok": true, "enabled": enabled, "pluginId": plugin_id }))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_plugin(package_name: String) -> Result<Value, String> {
|
||||
let package_name = package_name.trim().to_string();
|
||||
if package_name.is_empty() {
|
||||
return Err("包名不能为空".into());
|
||||
}
|
||||
|
||||
let cli = crate::utils::resolve_openclaw_cli_path()
|
||||
.ok_or_else(|| "找不到 OpenClaw CLI,请先安装".to_string())?;
|
||||
let output = std::process::Command::new(&cli)
|
||||
.args(["plugins", "install", &package_name])
|
||||
.current_dir(dirs::home_dir().unwrap_or_default())
|
||||
.output()
|
||||
.map_err(|e| format!("执行 openclaw plugins install 失败: {e}"))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!("安装失败: {}{}", stdout, stderr));
|
||||
}
|
||||
|
||||
Ok(json!({ "ok": true, "output": format!("{}{}", stdout, stderr).trim().to_string() }))
|
||||
}
|
||||
|
||||
// ── Slack / Matrix / Discord 凭证校验 ─────────────────────
|
||||
|
||||
async fn verify_slack(
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod agent;
|
||||
pub mod assistant;
|
||||
pub mod config;
|
||||
pub mod device;
|
||||
pub mod diagnose;
|
||||
pub mod extensions;
|
||||
pub mod logs;
|
||||
pub mod memory;
|
||||
|
||||
@@ -277,6 +277,85 @@ fn looks_like_gateway_config_mismatch(reason: &str) -> bool {
|
||||
|| (has_newer_version && mentions_doctor_fix)
|
||||
}
|
||||
|
||||
/// 直接修复 openclaw.json 中 plugins.entries.*.config 的多余属性
|
||||
/// 当 `openclaw doctor --fix` 无法修复时作为二级回退
|
||||
fn try_direct_config_strip() -> Result<bool, String> {
|
||||
let config_path = crate::commands::openclaw_dir().join("openclaw.json");
|
||||
let raw = std::fs::read_to_string(&config_path)
|
||||
.map_err(|e| format!("读取配置文件失败: {e}"))?;
|
||||
let mut doc: serde_json::Value =
|
||||
serde_json::from_str(&raw).map_err(|e| format!("解析配置文件失败: {e}"))?;
|
||||
|
||||
// 从错误日志中提取哪些 plugin entry 有 additional properties
|
||||
let err_log = read_gateway_error_log_excerpt(8192).to_lowercase();
|
||||
let mut changed = false;
|
||||
|
||||
// 匹配形如 "plugins.entries.XXX.config: invalid config" 的模式
|
||||
if let Some(entries) = doc
|
||||
.pointer_mut("/plugins/entries")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
let entry_names: Vec<String> = entries.keys().cloned().collect();
|
||||
for name in &entry_names {
|
||||
let pattern = format!("plugins.entries.{}.config", name).to_lowercase();
|
||||
if err_log.contains(&pattern) {
|
||||
if let Some(entry) = entries.get_mut(name) {
|
||||
if let Some(obj) = entry.as_object_mut() {
|
||||
if obj.contains_key("config") {
|
||||
guardian_log(&format!(
|
||||
"直接修复: 清空 plugins.entries.{name}.config(含多余属性)"
|
||||
));
|
||||
obj.remove("config");
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 通用回退:如果错误日志提到 additional properties 但没匹配到具体 entry,
|
||||
// 清空所有 plugin entry 的 config
|
||||
if !changed
|
||||
&& (err_log.contains("additional properties") || err_log.contains("additional property"))
|
||||
{
|
||||
if let Some(entries) = doc
|
||||
.pointer_mut("/plugins/entries")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
let entry_names: Vec<String> = entries.keys().cloned().collect();
|
||||
for name in &entry_names {
|
||||
if let Some(entry) = entries.get_mut(name) {
|
||||
if let Some(obj) = entry.as_object_mut() {
|
||||
if obj.contains_key("config") {
|
||||
let config = obj.get("config").unwrap();
|
||||
if config.is_object()
|
||||
&& config.as_object().map(|m| !m.is_empty()).unwrap_or(false)
|
||||
{
|
||||
guardian_log(&format!(
|
||||
"直接修复(通用回退): 清空 plugins.entries.{name}.config"
|
||||
));
|
||||
obj.remove("config");
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
let formatted = serde_json::to_string_pretty(&doc)
|
||||
.map_err(|e| format!("序列化配置失败: {e}"))?;
|
||||
std::fs::write(&config_path, formatted)
|
||||
.map_err(|e| format!("写入配置文件失败: {e}"))?;
|
||||
guardian_log("直接修复: 已写回 openclaw.json");
|
||||
}
|
||||
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
static GUARDIAN_STATE: OnceLock<Arc<Mutex<GuardianRuntimeState>>> = OnceLock::new();
|
||||
static GUARDIAN_STARTED: AtomicBool = AtomicBool::new(false);
|
||||
static GATEWAY_CONFIG_AUTO_FIX_STATE: OnceLock<Arc<Mutex<GatewayConfigAutoFixState>>> =
|
||||
@@ -623,6 +702,47 @@ async fn start_service_impl_internal(
|
||||
Ok(())
|
||||
}
|
||||
Err(retry_err) => {
|
||||
// 二级回退:doctor --fix 没解决问题,尝试直接修改 JSON
|
||||
if looks_like_gateway_config_mismatch(&retry_err) {
|
||||
guardian_log("doctor --fix 后仍失败,尝试直接修复 openclaw.json");
|
||||
match try_direct_config_strip() {
|
||||
Ok(true) => {
|
||||
emit_guardian_event(
|
||||
app,
|
||||
"auto_fix_retry",
|
||||
"已直接修复配置文件,正在再次重试启动 Gateway…",
|
||||
);
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
platform::cleanup_zombie_gateway_processes();
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
match start_service_impl_internal_once(label).await {
|
||||
Ok(()) => {
|
||||
emit_guardian_event(
|
||||
app,
|
||||
"auto_fix_success",
|
||||
"已直接修复配置并成功启动 Gateway。",
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
emit_guardian_event(
|
||||
app,
|
||||
"auto_fix_failure",
|
||||
format!("直接修复后仍启动失败:{e}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
guardian_log("直接修复未找到可清理的配置项");
|
||||
}
|
||||
Err(e) => {
|
||||
guardian_log(&format!("直接修复失败: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
emit_guardian_event(
|
||||
app,
|
||||
"auto_fix_failure",
|
||||
@@ -631,7 +751,7 @@ async fn start_service_impl_internal(
|
||||
),
|
||||
);
|
||||
Err(format!(
|
||||
"{retry_err}\n(已自动执行 openclaw doctor --fix 并重试启动 Gateway)"
|
||||
"{retry_err}\n(已自动执行 openclaw doctor --fix + 直接修复并重试启动 Gateway)"
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1033,23 +1153,47 @@ mod platform {
|
||||
/// 记录当前活跃的 Gateway 子进程(用于 stop 时精确 kill)
|
||||
static ACTIVE_GATEWAY_CHILD: Mutex<Option<u32>> = Mutex::new(None);
|
||||
|
||||
/// 清理残留的僵尸 Gateway 进程(启动时调用,防止 Windows 重启后多进程堆积)
|
||||
pub(crate) fn cleanup_zombie_gateway_processes() {
|
||||
let port = crate::commands::gateway_listen_port();
|
||||
/// 检查 Gateway 端口是否有响应(阻塞式 HTTP /health,3s 超时)
|
||||
fn is_gateway_port_responsive(port: u16) -> bool {
|
||||
use std::io::{Read, Write as IoWrite};
|
||||
use std::net::TcpStream;
|
||||
let addr = format!("127.0.0.1:{port}");
|
||||
let mut stream = match TcpStream::connect_timeout(
|
||||
&addr.parse().unwrap(),
|
||||
Duration::from_secs(3),
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_secs(3)));
|
||||
let _ = stream.set_write_timeout(Some(Duration::from_secs(2)));
|
||||
let req = format!("GET /health HTTP/1.0\r\nHost: 127.0.0.1:{port}\r\n\r\n");
|
||||
if stream.write_all(req.as_bytes()).is_err() {
|
||||
return false;
|
||||
}
|
||||
let mut buf = [0u8; 256];
|
||||
match stream.read(&mut buf) {
|
||||
Ok(n) if n > 0 => {
|
||||
let resp = String::from_utf8_lossy(&buf[..n]);
|
||||
resp.contains("200") || resp.contains("OK")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// 用 netstat 找到端口 18789 的所有监听进程 PID
|
||||
/// 从 netstat 输出中提取监听指定端口的所有 PID
|
||||
fn find_listening_pids(port: u16) -> Vec<u32> {
|
||||
let output = match StdCommand::new("netstat")
|
||||
.args(["-ano", "-p", "TCP"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
{
|
||||
Ok(o) => String::from_utf8_lossy(&o.stdout).to_string(),
|
||||
Err(_) => return,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
|
||||
let mut pids = vec![];
|
||||
for line in output.lines() {
|
||||
let line = line.trim();
|
||||
// 匹配 TCP 0.0.0.0:18789 0.0.0.0:0 LISTENING <PID>
|
||||
if !line.contains(&format!(":{port}")) || !line.contains("LISTENING") {
|
||||
continue;
|
||||
}
|
||||
@@ -1057,28 +1201,52 @@ mod platform {
|
||||
if parts.len() < 5 {
|
||||
continue;
|
||||
}
|
||||
let pid_str = parts.last().unwrap();
|
||||
let pid = match pid_str.parse::<u32>() {
|
||||
Ok(p) => p,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if let Ok(pid) = parts.last().unwrap().parse::<u32>() {
|
||||
if pid > 0 && !pids.contains(&pid) {
|
||||
pids.push(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
pids
|
||||
}
|
||||
|
||||
/// 清理残留的僵尸 Gateway 进程(启动时调用,防止 Windows 重启后多进程堆积)
|
||||
/// 增强:检测端口占用但 /health 无响应的僵尸进程,强制杀掉
|
||||
pub(crate) fn cleanup_zombie_gateway_processes() {
|
||||
let port = crate::commands::gateway_listen_port();
|
||||
let pids = find_listening_pids(port);
|
||||
if pids.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 先检查 /health 是否有响应 —— 如果端口有进程但无响应,说明是僵尸
|
||||
let responsive = is_gateway_port_responsive(port);
|
||||
|
||||
for pid in &pids {
|
||||
let pid = *pid;
|
||||
|
||||
// 验证这个 PID 的命令行是否确实是 Gateway
|
||||
if let Some(cmdline) = read_process_command_line(pid) {
|
||||
let cmdline_lower = cmdline.to_lowercase();
|
||||
// 只要包含 openclaw 且包含 gateway 就认为是 Gateway 进程
|
||||
// 排除纯 node.exe(可能是其他应用)
|
||||
if cmdline_lower.contains("openclaw") && cmdline_lower.contains("gateway") {
|
||||
// 只杀我们自己的 PID,不杀记录中的"已知好进程"
|
||||
let our_pid = *LAST_KNOWN_GATEWAY_PID.lock().unwrap();
|
||||
if Some(pid) != our_pid {
|
||||
let is_gateway = cmdline_lower.contains("openclaw") && cmdline_lower.contains("gateway");
|
||||
let our_pid = *LAST_KNOWN_GATEWAY_PID.lock().unwrap();
|
||||
|
||||
if is_gateway {
|
||||
if !responsive {
|
||||
// /health 无响应 → 僵尸进程,无条件杀掉(包括"已知好进程")
|
||||
super::guardian_log(&format!(
|
||||
"检测到僵尸 Gateway 进程 (PID {pid}):端口 {port} 占用但 /health 无响应,强制终止"
|
||||
));
|
||||
kill_process_tree(pid);
|
||||
} else if Some(pid) != our_pid {
|
||||
// /health 有响应但不是我们启动的 → 旧进程残留
|
||||
super::guardian_log(&format!(
|
||||
"清理残留 Gateway 进程 (PID {pid}):非当前实例"
|
||||
));
|
||||
kill_process_tree(pid);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 读不到命令行时,不做假设,避免误杀其他进程
|
||||
continue;
|
||||
}
|
||||
// 读不到命令行时,不做假设,避免误杀其他进程
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1941,3 +2109,14 @@ pub async fn claim_gateway() -> Result<(), String> {
|
||||
write_gateway_owner(pid)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 轻量 TCP 端口探测:检测 Gateway 端口是否可连通(用于 WS 连接前的就绪等待)
|
||||
#[tauri::command]
|
||||
pub async fn probe_gateway_port() -> bool {
|
||||
let port = crate::commands::gateway_listen_port();
|
||||
let addr = format!("127.0.0.1:{port}");
|
||||
match tokio::net::TcpStream::connect(&addr).await {
|
||||
Ok(_) => true,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ mod tray;
|
||||
mod utils;
|
||||
|
||||
use commands::{
|
||||
agent, assistant, config, device, extensions, logs, memory, messaging, pairing, service,
|
||||
skills, update,
|
||||
agent, assistant, config, device, diagnose, extensions, logs, memory, messaging, pairing,
|
||||
service, skills, update,
|
||||
};
|
||||
|
||||
pub fn run() {
|
||||
@@ -125,7 +125,10 @@ pub fn run() {
|
||||
service::stop_service,
|
||||
service::restart_service,
|
||||
service::claim_gateway,
|
||||
service::probe_gateway_port,
|
||||
service::guardian_status,
|
||||
// 诊断
|
||||
diagnose::diagnose_gateway_connection,
|
||||
// 日志
|
||||
logs::read_log_tail,
|
||||
logs::search_log,
|
||||
@@ -183,6 +186,9 @@ pub fn run() {
|
||||
messaging::repair_qqbot_channel_setup,
|
||||
messaging::list_configured_platforms,
|
||||
messaging::get_channel_plugin_status,
|
||||
messaging::list_all_plugins,
|
||||
messaging::toggle_plugin,
|
||||
messaging::install_plugin,
|
||||
messaging::install_channel_plugin,
|
||||
messaging::install_qqbot_plugin,
|
||||
messaging::run_channel_action,
|
||||
|
||||
Reference in New Issue
Block a user