From 70d768be17836de4d89dd84b5c84afdbb286e1cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Sat, 11 Apr 2026 00:44:06 +0800 Subject: [PATCH] feat: new pages + dashboard enhancements + backend improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- index.html | 62 +++++- scripts/dev-api.js | 196 +++++++++++++++++++ src-tauri/src/commands/device.rs | 29 ++- src-tauri/src/commands/diagnose.rs | 290 ++++++++++++++++++++++++++++ src-tauri/src/commands/messaging.rs | 146 ++++++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/service.rs | 225 ++++++++++++++++++--- src-tauri/src/lib.rs | 10 +- src/components/sidebar.js | 16 +- src/lib/app-state.js | 70 +------ src/lib/feature-gates.js | 135 +++++++++++++ src/lib/gateway-guardian-policy.js | 38 ---- src/lib/model-presets.js | 49 +++-- src/lib/tauri-api.js | 7 +- src/lib/ws-client.js | 209 ++++++++++++++++++-- src/locales/index.js | 5 +- src/locales/modules/dashboard.js | 5 + src/locales/modules/diagnose.js | 25 +++ src/locales/modules/extensions.js | 36 ++++ src/locales/modules/routeMap.js | 26 +++ src/locales/modules/sidebar.js | 4 + src/main.js | 37 +++- src/pages/dashboard.js | 97 +++++++++- src/pages/diagnose.js | 116 +++++++++++ src/pages/plugin-hub.js | 233 ++++++++++++++++++++++ src/pages/route-map.js | 277 ++++++++++++++++++++++++++ src/style/pages.css | 180 +++++++++++++++++ 27 files changed, 2337 insertions(+), 187 deletions(-) create mode 100644 src-tauri/src/commands/diagnose.rs create mode 100644 src/lib/feature-gates.js delete mode 100644 src/lib/gateway-guardian-policy.js create mode 100644 src/locales/modules/diagnose.js create mode 100644 src/locales/modules/extensions.js create mode 100644 src/locales/modules/routeMap.js create mode 100644 src/pages/diagnose.js create mode 100644 src/pages/plugin-hub.js create mode 100644 src/pages/route-map.js diff --git a/index.html b/index.html index 46eccbf..de9b8a5 100644 --- a/index.html +++ b/index.html @@ -66,7 +66,67 @@
qt.cool
- +
diff --git a/scripts/dev-api.js b/scripts/dev-api.js index f349837..975b784 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3373,6 +3373,79 @@ const handlers = { } }, + list_all_plugins() { + const cfg = readOpenclawConfigOptional() + const entries = cfg.plugins?.entries || {} + const allowArr = cfg.plugins?.allow || [] + const extDir = path.join(OPENCLAW_DIR, 'extensions') + const plugins = [] + const seen = new Set() + + // Scan extensions directory + if (fs.existsSync(extDir)) { + for (const name of fs.readdirSync(extDir)) { + if (name.startsWith('.')) continue + const p = path.join(extDir, name) + if (!fs.statSync(p).isDirectory()) continue + const hasMarker = fs.existsSync(path.join(p, 'package.json')) || fs.existsSync(path.join(p, 'plugin.ts')) || fs.existsSync(path.join(p, 'index.js')) + if (!hasMarker) continue + seen.add(name) + const entryCfg = entries[name] + const enabled = !!entryCfg?.enabled + const allowed = allowArr.includes(name) + let version = null, description = null + try { + const pkg = JSON.parse(fs.readFileSync(path.join(p, 'package.json'), 'utf8')) + version = pkg.version || null + description = pkg.description || null + } catch {} + plugins.push({ id: name, installed: true, builtin: false, enabled, allowed, version, description, config: entryCfg?.config || null }) + } + } + + // Include entries from config not found in extensions dir + for (const [pid, val] of Object.entries(entries)) { + if (seen.has(pid)) continue + seen.add(pid) + plugins.push({ id: pid, installed: false, builtin: false, enabled: !!val?.enabled, allowed: allowArr.includes(pid), version: null, description: null, config: val?.config || null }) + } + + plugins.sort((a, b) => (b.enabled ? 1 : 0) - (a.enabled ? 1 : 0) || a.id.localeCompare(b.id)) + return { plugins } + }, + + toggle_plugin({ pluginId, enabled }) { + if (!pluginId || !pluginId.trim()) throw new Error('pluginId 不能为空') + const pid = pluginId.trim() + const cfg = readOpenclawConfigOptional() + if (!cfg.plugins) cfg.plugins = {} + if (!cfg.plugins.entries) cfg.plugins.entries = {} + if (!cfg.plugins.allow) cfg.plugins.allow = [] + + if (enabled) { + if (!cfg.plugins.allow.includes(pid)) cfg.plugins.allow.push(pid) + if (!cfg.plugins.entries[pid]) cfg.plugins.entries[pid] = {} + cfg.plugins.entries[pid].enabled = true + } else { + cfg.plugins.allow = cfg.plugins.allow.filter(v => v !== pid) + if (cfg.plugins.entries[pid]) cfg.plugins.entries[pid].enabled = false + } + + fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf8') + return { ok: true, enabled, pluginId: pid } + }, + + install_plugin({ packageName }) { + if (!packageName || !packageName.trim()) throw new Error('包名不能为空') + const spec = packageName.trim() + try { + execOpenclawSync(['plugins', 'install', spec], { timeout: 120000, cwd: homedir(), windowsHide: true }, `插件 ${spec} 安装失败`) + return { ok: true, output: '安装成功' } + } catch (e) { + throw new Error(`插件安装失败: ${e.message || e}`) + } + }, + get_channel_plugin_status({ pluginId }) { if (!pluginId || !pluginId.trim()) throw new Error('pluginId 不能为空') const pid = pluginId.trim() @@ -4480,6 +4553,129 @@ const handlers = { } }, + async probe_gateway_port() { + const port = readGatewayPort() + return new Promise(resolve => { + const net = require('net') + const sock = net.createConnection({ host: '127.0.0.1', port, timeout: 3000 }) + sock.on('connect', () => { sock.destroy(); resolve(true) }) + sock.on('error', () => resolve(false)) + sock.on('timeout', () => { sock.destroy(); resolve(false) }) + }) + }, + + async diagnose_gateway_connection() { + const steps = [] + const ocDir = openclawDir() + const configPath = path.join(ocDir, 'openclaw.json') + const port = readGatewayPort() + + // 1. 配置文件 + const t1 = Date.now() + try { + const content = fs.readFileSync(configPath, 'utf-8') + const val = JSON.parse(content) + steps.push({ name: 'config', ok: !!val.gateway, message: val.gateway ? '配置文件有效,含 gateway 配置' : '配置文件缺少 gateway 段', durationMs: Date.now() - t1 }) + } catch (e) { + steps.push({ name: 'config', ok: false, message: `配置文件异常: ${e.message}`, durationMs: Date.now() - t1 }) + } + + // 2. 设备密钥 + const t2 = Date.now() + const keyPath = path.join(ocDir, 'clawpanel-device-key.json') + const keyExists = fs.existsSync(keyPath) + steps.push({ name: 'device_key', ok: keyExists, message: keyExists ? '设备密钥存在' : '设备密钥不存在', durationMs: Date.now() - t2 }) + + // 3. allowedOrigins + const t3 = Date.now() + try { + const val = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + const origins = val?.gateway?.controlUi?.allowedOrigins + if (Array.isArray(origins) && origins.length > 0) { + steps.push({ name: 'allowed_origins', ok: true, message: `allowedOrigins: ${JSON.stringify(origins)}`, durationMs: Date.now() - t3 }) + } else { + steps.push({ name: 'allowed_origins', ok: false, message: '未配置 allowedOrigins', durationMs: Date.now() - t3 }) + } + } catch { + steps.push({ name: 'allowed_origins', ok: false, message: '配置文件不可读', durationMs: Date.now() - t3 }) + } + + // 4. TCP 端口 + const t4 = Date.now() + const tcpOk = await new Promise(resolve => { + const net = require('net') + const sock = net.createConnection({ host: '127.0.0.1', port, timeout: 3000 }) + sock.on('connect', () => { sock.destroy(); resolve(true) }) + sock.on('error', () => resolve(false)) + sock.on('timeout', () => { sock.destroy(); resolve(false) }) + }) + steps.push({ name: 'tcp_port', ok: tcpOk, message: tcpOk ? `端口 ${port} 可达` : `端口 ${port} 不可达`, durationMs: Date.now() - t4 }) + + // 5. HTTP /health + const t5 = Date.now() + let httpOk = false + let httpMsg = '' + try { + const resp = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(5000) }) + httpOk = resp.ok + httpMsg = `HTTP /health 返回 ${resp.status}` + } catch (e) { + httpMsg = `HTTP /health 请求失败: ${e.message}` + } + steps.push({ name: 'http_health', ok: httpOk, message: httpMsg, durationMs: Date.now() - t5 }) + + // 6. 错误日志 + const t6 = Date.now() + const errLogPath = path.join(ocDir, 'logs', 'gateway.err.log') + if (fs.existsSync(errLogPath)) { + const stat = fs.statSync(errLogPath) + if (stat.size === 0) { + steps.push({ name: 'err_log', ok: true, message: '错误日志为空(正常)', durationMs: Date.now() - t6 }) + } else { + const buf = Buffer.alloc(Math.min(1024, stat.size)) + const fd = fs.openSync(errLogPath, 'r') + fs.readSync(fd, buf, 0, buf.length, Math.max(0, stat.size - buf.length)) + fs.closeSync(fd) + const tail = buf.toString('utf-8').toLowerCase() + const hasFatal = tail.includes('fatal') || tail.includes('eaddrinuse') || tail.includes('config invalid') + steps.push({ name: 'err_log', ok: !hasFatal, message: hasFatal ? `错误日志含关键错误 (${stat.size} bytes)` : `错误日志存在但无致命错误 (${stat.size} bytes)`, durationMs: Date.now() - t6 }) + } + } else { + steps.push({ name: 'err_log', ok: true, message: '无错误日志(正常)', durationMs: Date.now() - t6 }) + } + + // env + let authMode = 'none' + try { + const val = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + const auth = val?.gateway?.auth + if (auth?.token) authMode = 'token' + else if (auth?.password) authMode = 'password' + } catch {} + let errLogExcerpt = '' + try { + const buf = fs.readFileSync(errLogPath) + errLogExcerpt = buf.slice(Math.max(0, buf.length - 2048)).toString('utf-8') + } catch {} + + const overallOk = steps.every(s => s.ok) + const failed = steps.filter(s => !s.ok).map(s => s.name) + return { + steps, + env: { + openclawDir: ocDir, + configExists: fs.existsSync(configPath), + port, + authMode, + deviceKeyExists: keyExists, + gatewayOwner: null, + errLogExcerpt, + }, + overallOk, + summary: overallOk ? '所有检查项通过' : `以下检查未通过: ${failed.join(', ')}`, + } + }, + guardian_status() { // Web 模式没有 Guardian 守护进程 return { enabled: false, giveUp: false } diff --git a/src-tauri/src/commands/device.rs b/src-tauri/src/commands/device.rs index e466580..54a6e82 100644 --- a/src-tauri/src/commands/device.rs +++ b/src-tauri/src/commands/device.rs @@ -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 { +pub fn create_connect_frame( + nonce: String, + gateway_token: String, + gateway_password: Option, +) -> Result { 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()), @@ -127,7 +150,7 @@ pub fn create_connect_frame(nonce: String, gateway_token: String) -> Result, + pub err_log_excerpt: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DiagnoseResult { + pub steps: Vec, + 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::(&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::(&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::(&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::(&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, + } +} diff --git a/src-tauri/src/commands/messaging.rs b/src-tauri/src/commands/messaging.rs index b75e788..2d25607 100644 --- a/src-tauri/src/commands/messaging.rs +++ b/src-tauri/src/commands/messaging.rs @@ -2099,6 +2099,152 @@ pub async fn get_channel_plugin_status(plugin_id: String) -> Result Result { + 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 = 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::(&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::(&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 { + 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 { + 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( diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 5e05c0f..9179edb 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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; diff --git a/src-tauri/src/commands/service.rs b/src-tauri/src/commands/service.rs index 7cd416a..f708bbd 100644 --- a/src-tauri/src/commands/service.rs +++ b/src-tauri/src/commands/service.rs @@ -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 { + 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 = 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 = 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>> = OnceLock::new(); static GUARDIAN_STARTED: AtomicBool = AtomicBool::new(false); static GATEWAY_CONFIG_AUTO_FIX_STATE: OnceLock>> = @@ -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> = 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 { 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 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::() { - Ok(p) => p, - Err(_) => continue, - }; + if let Ok(pid) = parts.last().unwrap().parse::() { + 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, + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0291776..7c76a99 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/components/sidebar.js b/src/components/sidebar.js index 5f832e3..f7c96aa 100644 --- a/src/components/sidebar.js +++ b/src/components/sidebar.js @@ -8,6 +8,7 @@ import { api } from '../lib/tauri-api.js' import { toast } from './toast.js' import { version as APP_VERSION } from '../../package.json' import { t, getLang, setLang, getAvailableLangs } from '../lib/i18n.js' +import { isFeatureAvailable } from '../lib/feature-gates.js' function NAV_ITEMS_FULL() { return [ { @@ -16,6 +17,7 @@ function NAV_ITEMS_FULL() { return [ { route: '/dashboard', label: t('sidebar.dashboard'), icon: 'dashboard' }, { route: '/assistant', label: t('sidebar.assistant'), icon: 'assistant' }, { route: '/chat', label: t('sidebar.chat'), icon: 'chat' }, + { route: '/route-map', label: t('sidebar.routeMap'), icon: 'route-map' }, { route: '/services', label: t('sidebar.services'), icon: 'services' }, { route: '/logs', label: t('sidebar.logs'), icon: 'logs' }, ] @@ -34,23 +36,24 @@ function NAV_ITEMS_FULL() { return [ { section: t('sidebar.sectionData'), items: [ - { route: '/memory', label: t('sidebar.memory'), icon: 'memory' }, - { route: '/dreaming', label: t('sidebar.dreaming'), icon: 'dreaming' }, - { route: '/cron', label: t('sidebar.cron'), icon: 'clock' }, + { route: '/memory', label: t('sidebar.memory'), icon: 'memory', gate: 'memory' }, + { route: '/dreaming', label: t('sidebar.dreaming'), icon: 'dreaming', gate: 'dreaming' }, + { route: '/cron', label: t('sidebar.cron'), icon: 'clock', gate: 'cron' }, { route: '/usage', label: t('sidebar.usage'), icon: 'bar-chart' }, ] }, { section: t('sidebar.sectionExtension'), items: [ - { route: '/skills', label: t('sidebar.skills'), icon: 'skills' }, + { route: '/skills', label: t('sidebar.skills'), icon: 'skills', gate: 'skills' }, + { route: '/plugin-hub', label: t('sidebar.pluginHub'), icon: 'extensions' }, ] }, { section: '', items: [ { route: '/settings', label: t('sidebar.settings'), icon: 'settings' }, - { route: '/chat-debug', label: t('sidebar.chatDebug'), icon: 'debug' }, + { route: '/chat-debug', label: t('sidebar.checkRepair'), icon: 'diagnose' }, { route: '/about', label: t('sidebar.about'), icon: 'about' }, ] } @@ -95,6 +98,8 @@ const ICONS = { 'bar-chart': '', settings: '', debug: '', + 'route-map': '', + diagnose: '', } let _delegated = false @@ -162,6 +167,7 @@ export function renderSidebar(el) { ` for (const item of section.items) { + if (item.gate && !isFeatureAvailable(item.gate)) continue const active = current === item.route ? ' active' : '' html += `
+ ${renderWsStatus()} + ${renderChannelsOverview(channels)} ${renderSessionStatus(sessions)} ` @@ -459,6 +464,82 @@ function renderSessionStatus(sessions) { ` } +function renderWsStatus() { + const connected = wsClient.connected + const ready = wsClient.gatewayReady + const reconnecting = wsClient.reconnectState === 'attempting' || wsClient.reconnectState === 'scheduled' + const attempts = wsClient.reconnectAttempts + const serverVer = wsClient.serverVersion + + let statusColor, statusLabel, statusDetail + if (ready) { + statusColor = 'var(--success)' + statusLabel = t('dashboard.wsConnected') + statusDetail = serverVer ? `Gateway ${serverVer}` : '' + } else if (connected) { + statusColor = 'var(--warning)' + statusLabel = t('dashboard.wsHandshaking') + statusDetail = '' + } else if (reconnecting) { + statusColor = 'var(--warning)' + statusLabel = t('dashboard.wsReconnecting') + statusDetail = `#${attempts}` + } else { + statusColor = 'var(--text-tertiary)' + statusLabel = t('dashboard.wsDisconnected') + statusDetail = '' + } + + return ` +
+
+ + WebSocket ${statusLabel} + ${statusDetail ? `${escapeHtml(statusDetail)}` : ''} +
+
` +} + +const CHANNEL_ICONS = { qqbot: '🐧', qq: '🐧', feishu: '🪶', dingtalk: '📌', telegram: '✈️', discord: '🎮', slack: '💬', weixin: '💚', wechat: '💚', webchat: '🌐', whatsapp: '📱', line: '🟢', teams: '👥', matrix: '🔗' } + +function renderChannelsOverview(channels) { + if (!channels || channels.length === 0) return '' + const items = channels.map(ch => { + const icon = CHANNEL_ICONS[ch.platform] || '📡' + const enabled = ch.enabled !== false + const dot = enabled ? 'var(--success)' : 'var(--text-tertiary)' + const name = ch.name || ch.platform || ch.id || '' + return ` + + ${icon} ${escapeHtml(name)} + ` + }) + return ` +
+
${t('dashboard.connectedChannels')} ${channels.length}
+
${items.join('')}
+
` +} + +function parseLogLine(line) { + // 常见日志格式: [2024-01-15 14:30:25] [INFO] message 或 2024-01-15T14:30:25 INFO message + const m = line.match(/^[\[(]?(\d{4}[-/]\d{2}[-/]\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?)\]?\s*[\[(]?\s*(DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL|TRACE)\s*[\])]?\s*(.*)$/i) + if (m) return { time: m[1].replace('T', ' ').replace(/\.\d+$/, ''), level: m[2].toUpperCase().replace('WARNING', 'WARN'), msg: m[3] } + // 简单 level 前缀: INFO: xxx / [ERROR] xxx + const m2 = line.match(/^[\[(]?\s*(DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL|TRACE)\s*[\]):]\s*(.*)$/i) + if (m2) return { time: '', level: m2[1].toUpperCase().replace('WARNING', 'WARN'), msg: m2[2] } + return { time: '', level: '', msg: line } +} + +const LOG_LEVEL_STYLE = { + ERROR: 'background:rgba(239,68,68,0.12);color:#ef4444;border:1px solid rgba(239,68,68,0.2)', + FATAL: 'background:rgba(239,68,68,0.12);color:#ef4444;border:1px solid rgba(239,68,68,0.2)', + WARN: 'background:rgba(234,179,8,0.12);color:#ca8a04;border:1px solid rgba(234,179,8,0.2)', + INFO: 'background:rgba(59,130,246,0.10);color:#3b82f6;border:1px solid rgba(59,130,246,0.15)', + DEBUG: 'background:rgba(148,163,184,0.10);color:#94a3b8;border:1px solid rgba(148,163,184,0.15)', + TRACE: 'background:rgba(148,163,184,0.08);color:#94a3b8;border:1px solid rgba(148,163,184,0.1)', +} + function renderLogs(page, logs) { const logsEl = page.querySelector('#recent-logs') if (!logs) { @@ -466,7 +547,13 @@ function renderLogs(page, logs) { return } const lines = logs.trim().split('\n') - logsEl.innerHTML = lines.map(l => `
${escapeHtml(l)}
`).join('') + logsEl.innerHTML = lines.map(l => { + const parsed = parseLogLine(l) + if (!parsed.level) return `
${escapeHtml(l)}
` + const badge = `${parsed.level}` + const time = parsed.time ? `${escapeHtml(parsed.time)}` : '' + return `
${time}${badge}${escapeHtml(parsed.msg)}
` + }).join('') logsEl.scrollTop = logsEl.scrollHeight } diff --git a/src/pages/diagnose.js b/src/pages/diagnose.js new file mode 100644 index 0000000..df4ecc7 --- /dev/null +++ b/src/pages/diagnose.js @@ -0,0 +1,116 @@ +/** + * Gateway 连接诊断页面 + */ +import { api, isTauriRuntime } from '../lib/tauri-api.js' +import { toast } from '../components/toast.js' +import { t } from '../lib/i18n.js' + +const STEP_LABELS = { + config: () => t('diagnose.stepConfig'), + device_key: () => t('diagnose.stepDeviceKey'), + allowed_origins: () => t('diagnose.stepOrigins'), + tcp_port: () => t('diagnose.stepTcp'), + http_health: () => t('diagnose.stepHttp'), + err_log: () => t('diagnose.stepErrLog'), +} + +export async function render() { + const page = document.createElement('div') + page.className = 'page' + + page.innerHTML = ` + +
+ +
+
+
+
${t('diagnose.noData')}
+
+ + ` + + const btnDiagnose = page.querySelector('#btn-diagnose') + + btnDiagnose.onclick = async () => { + btnDiagnose.disabled = true + btnDiagnose.textContent = t('diagnose.running') + page.querySelector('#diagnose-summary').innerHTML = '' + page.querySelector('#diagnose-steps').innerHTML = '
'.repeat(6) + + try { + const result = await api.diagnoseGatewayConnection() + renderResult(page, result) + } catch (e) { + toast.error(`${t('diagnose.diagnoseFailed')}: ${e}`) + page.querySelector('#diagnose-steps').innerHTML = `
${t('diagnose.diagnoseFailed')}: ${e}
` + } finally { + btnDiagnose.disabled = false + btnDiagnose.textContent = t('diagnose.runDiagnose') + } + } + + return page +} + +function renderResult(page, result) { + // Summary + const summaryEl = page.querySelector('#diagnose-summary') + if (result.overallOk) { + summaryEl.innerHTML = `
${t('diagnose.allPassed')}
` + } else { + summaryEl.innerHTML = `
⚠️ ${result.summary}
` + } + + // Steps + const stepsEl = page.querySelector('#diagnose-steps') + stepsEl.innerHTML = result.steps.map(step => { + const label = STEP_LABELS[step.name]?.() || step.name + const icon = step.ok ? '✅' : '❌' + const status = step.ok ? t('diagnose.passed') : t('diagnose.failed') + const bgColor = step.ok ? 'var(--bg-secondary,#f9fafb)' : 'var(--error-bg,#fef2f2)' + return ` +
+
+
+ ${icon} + ${label} +
+ ${step.durationMs}ms +
+
${escHtml(step.message)}
+
` + }).join('') + + // Env info + const envEl = page.querySelector('#diagnose-env') + envEl.style.display = '' + const env = result.env + const rows = [ + [t('diagnose.openclawDir'), env.openclawDir], + [t('diagnose.port'), env.port], + [t('diagnose.authMode'), env.authMode], + [t('diagnose.deviceKey'), env.deviceKeyExists ? '✅' : '❌'], + ] + let html = '' + for (const [k, v] of rows) { + html += `` + } + html += '
${k}${escHtml(String(v))}
' + + if (env.errLogExcerpt) { + html += `
${t('diagnose.errLogExcerpt')}
${escHtml(env.errLogExcerpt)}
` + } + + page.querySelector('#env-content').innerHTML = html +} + +function escHtml(s) { + return s.replace(/&/g, '&').replace(//g, '>') +} diff --git a/src/pages/plugin-hub.js b/src/pages/plugin-hub.js new file mode 100644 index 0000000..4df31f6 --- /dev/null +++ b/src/pages/plugin-hub.js @@ -0,0 +1,233 @@ +/** + * 插件中心 — OpenClaw 扩展插件管理与浏览 + */ +import { api } from '../lib/tauri-api.js' +import { toast } from '../components/toast.js' +import { navigate } from '../router.js' +import { t } from '../lib/i18n.js' +import { openAIDrawerWithError } from '../components/ai-drawer.js' + +const PLUGIN_ICONS = { + qqbot: '🐧', feishu: '🪶', dingtalk: '📌', telegram: '✈️', + discord: '🎮', slack: '💬', weixin: '💚', wechat: '💚', + webchat: '🌐', whatsapp: '📱', signal: '🔒', line: '🟢', + teams: '👥', matrix: '🔗', irc: '📡', +} + +let _allPlugins = [] +let _searchQuery = '' + +function esc(s) { return String(s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/ +

${t('extensions.title')}

+
+ + +
+ +

${t('extensions.subtitle')}

+
+
+
+ + +
+
+ + +
+
+ +
+
+
+ ` + + page.querySelector('#ph-refresh').onclick = () => loadPlugins(page) + page.querySelector('#ph-go-channels').onclick = () => navigate('/channels') + page.querySelector('#ph-install-btn').onclick = () => handleInstall(page) + page.querySelector('#ph-pkg-input').onkeydown = (e) => { if (e.key === 'Enter') handleInstall(page) } + page.querySelector('#ph-search').oninput = (e) => { + _searchQuery = e.target.value.trim().toLowerCase() + renderPluginList(page) + } + + // Event delegation for toggle buttons + page.addEventListener('click', async (e) => { + const btn = e.target.closest('[data-toggle-plugin]') + if (!btn) return + const pluginId = btn.dataset.togglePlugin + const newEnabled = btn.dataset.toggleTo === 'true' + btn.disabled = true + btn.textContent = '...' + try { + await api.togglePlugin(pluginId, newEnabled) + toast(t('extensions.toggleSuccess'), 'success') + await loadPlugins(page) + } catch (err) { + toast(`${t('extensions.toggleFailed')}: ${err}`, 'error') + btn.disabled = false + btn.textContent = newEnabled ? t('extensions.enable') : t('extensions.disable') + } + }) + + // Expand/collapse install messages + page.addEventListener('click', (e) => { + if (e.target.closest('#ph-install-msg-toggle')) { + const detail = page.querySelector('#ph-install-msg-detail') + const toggle = page.querySelector('#ph-install-msg-toggle') + if (detail && toggle) { + const expanded = detail.style.display !== 'none' + detail.style.display = expanded ? 'none' : 'block' + toggle.textContent = expanded ? t('extensions.showDetail') : t('extensions.hideDetail') + } + } + }) + + setTimeout(() => loadPlugins(page), 0) + return page +} + +async function handleInstall(page) { + const input = page.querySelector('#ph-pkg-input') + const btn = page.querySelector('#ph-install-btn') + const msgEl = page.querySelector('#ph-install-msg') + const pkg = input.value.trim() + if (!pkg) return + + btn.disabled = true + btn.textContent = t('extensions.installing') + msgEl.style.display = 'block' + msgEl.innerHTML = `
${t('extensions.installing')}
` + + try { + const result = await api.installPlugin(pkg) + const output = result.output ? esc(result.output).substring(0, 120) : '' + msgEl.innerHTML = `
+ ✅ ${t('extensions.installSuccess')}${output ? ' — ' + output : ''} +
` + toast(t('extensions.installSuccess'), 'success') + input.value = '' + await loadPlugins(page) + setTimeout(() => { msgEl.style.display = 'none' }, 5000) + } catch (e) { + const errStr = String(e.message || e) + const short = errStr.length > 100 ? errStr.substring(0, 100) + '...' : errStr + const hasDetail = errStr.length > 100 + msgEl.innerHTML = `
+
+ ❌ ${t('extensions.installFailed')}: ${esc(short)} + ${hasDetail ? `` : ''} +
+ ${hasDetail ? `` : ''} +
` + toast(t('extensions.installFailed'), 'error') + openAIDrawerWithError({ + scene: 'plugin-install', + title: t('extensions.installFailed') + ': ' + pkg, + hint: t('extensions.installPlaceholder'), + error: errStr, + }) + } finally { + btn.disabled = false + btn.textContent = t('extensions.installBtn') + } +} + +async function loadPlugins(page) { + const listEl = page.querySelector('#ph-list') + const statsEl = page.querySelector('#ph-stats') + listEl.innerHTML = `
${t('extensions.loading')}
` + + try { + const result = await api.listAllPlugins() + _allPlugins = result?.plugins || [] + + if (_allPlugins.length === 0) { + statsEl.innerHTML = '' + listEl.innerHTML = `
${t('extensions.noPlugins')}
` + return + } + + const enabled = _allPlugins.filter(p => p.enabled).length + const builtin = _allPlugins.filter(p => p.builtin).length + + statsEl.innerHTML = ` +
${_allPlugins.length}${t('extensions.statsInstalled')}
+
${enabled}${t('extensions.statsEnabled')}
+ ${builtin ? `
${builtin}${t('extensions.statsBuiltin')}
` : ''} + ` + + renderPluginList(page) + } catch (e) { + listEl.innerHTML = `
${esc(e.message || e)}
` + } +} + +function renderPluginList(page) { + const listEl = page.querySelector('#ph-list') + if (!listEl) return + + const filtered = _searchQuery + ? _allPlugins.filter(p => { + const q = _searchQuery + return (p.id || '').toLowerCase().includes(q) || + (p.description || '').toLowerCase().includes(q) || + (p.version || '').toLowerCase().includes(q) + }) + : _allPlugins + + if (filtered.length === 0 && _searchQuery) { + listEl.innerHTML = `
+ ${t('extensions.noSearchResults', { query: esc(_searchQuery) })} +
` + return + } + + listEl.innerHTML = `
${filtered.map(p => renderPluginCard(p)).join('')}
+
${t('extensions.restartHint')}
` +} + +function renderPluginCard(p) { + const icon = PLUGIN_ICONS[p.id.toLowerCase()] || '🧩' + const statusClass = p.enabled ? 'plugin-status-enabled' : (p.installed ? 'plugin-status-disabled' : 'plugin-status-missing') + const statusText = p.enabled ? t('extensions.enabled') : (p.installed ? t('extensions.disabled') : t('extensions.notInstalled')) + const badges = [] + if (p.builtin) badges.push(`${t('extensions.builtin')}`) + if (p.version) badges.push(`${t('extensions.version')} ${esc(p.version)}`) + + // Toggle button: installed plugins can be enabled/disabled + let toggleBtn = '' + if (p.installed) { + if (p.enabled) { + toggleBtn = `` + } else { + toggleBtn = `` + } + } + + return ` +
+
+ ${icon} +
+ ${esc(p.id)} +
${badges.join('')}
+
+ +
+
${esc(p.description) || t('extensions.noDescription')}
+ +
+ ` +} diff --git a/src/pages/route-map.js b/src/pages/route-map.js new file mode 100644 index 0000000..af3384b --- /dev/null +++ b/src/pages/route-map.js @@ -0,0 +1,277 @@ +/** + * 路由地图 — Channel → Binding → Agent 全局拓扑可视化 + */ +import { api } from '../lib/tauri-api.js' +import { toast } from '../components/toast.js' +import { navigate } from '../router.js' +import { t } from '../lib/i18n.js' + +const CHANNEL_COLORS = { + qqbot: '#22d3ee', qq: '#22d3ee', telegram: '#3b82f6', discord: '#818cf8', + slack: '#f59e0b', feishu: '#6366f1', dingtalk: '#3b82f6', weixin: '#22c55e', + wechat: '#22c55e', webchat: '#a78bfa', whatsapp: '#22c55e', signal: '#60a5fa', + line: '#22c55e', teams: '#6366f1', matrix: '#f472b6', irc: '#94a3b8', +} + +const NODE_W = 180, NODE_H = 56, COL_GAP = 260, ROW_GAP = 16, PAD_TOP = 80, PAD_LEFT = 40 + +function escAttr(s) { return String(s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/ +

${t('routeMap.title')}

+
+ ${t('routeMap.clickToNavigate')} + +
+ +

${t('routeMap.subtitle')}

+
+
+
+
+ ` + + async function loadAndRender() { + const canvas = page.querySelector('#rm-canvas') + const statsEl = page.querySelector('#rm-stats') + canvas.innerHTML = '
' + t('routeMap.loading') + '
' + + try { + const [agentsRaw, bindingsRaw, platformsRaw] = await Promise.all([ + api.listAgents(), + api.listAllBindings().catch(() => []), + api.listConfiguredPlatforms().catch(() => []), + ]) + + const agents = Array.isArray(agentsRaw) ? agentsRaw : (agentsRaw?.agents || []) + const bindings = Array.isArray(bindingsRaw) ? bindingsRaw : (bindingsRaw?.bindings || []) + const platforms = Array.isArray(platformsRaw) ? platformsRaw : [] + + if (agents.length === 0 && platforms.length === 0) { + canvas.innerHTML = `
${t('routeMap.noData')}
` + statsEl.innerHTML = '' + return + } + + // Fetch agent details to get sub-agent relationships + const agentDetails = await Promise.all( + agents.map(a => api.getAgentDetail(a.id || a.name || 'main').catch(() => null)) + ) + // Merge detail data into agents + for (let i = 0; i < agents.length; i++) { + if (agentDetails[i]) { + agents[i] = { ...agents[i], ...agentDetails[i] } + } + } + + // Stats bar + const subAgentCount = agents.filter(a => { + const allow = a.tools?.agentToAgent?.allow + return Array.isArray(allow) && allow.length > 0 + }).length + statsEl.innerHTML = ` +
${agents.length}${t('routeMap.statsAgents')}
+
${platforms.length}${t('routeMap.statsChannels')}
+
${bindings.length}${t('routeMap.statsBindings')}
+ ${subAgentCount ? `
${subAgentCount}${t('routeMap.subAgentRelations')}
` : ''} + ` + + renderTopology(canvas, agents, bindings, platforms) + } catch (e) { + canvas.innerHTML = `
${escAttr(e.message || e)}
` + } + } + + page.querySelector('#rm-refresh').onclick = () => loadAndRender() + setTimeout(loadAndRender, 0) + return page +} + +function renderTopology(container, agents, bindings, platforms) { + // Build channel nodes (left column) + const channelNodes = platforms.map((p, i) => { + const id = p.platform || p.id || p.channel || `ch-${i}` + const label = p.label || p.platform || id + const enabled = p.enabled !== false + return { id, label, enabled, color: CHANNEL_COLORS[id.toLowerCase()] || '#94a3b8', type: 'channel', originalIndex: i } + }) + + // Build agent nodes (right column) + const defaultAgentId = agents.find(a => a.default || a.isDefault)?.id || agents[0]?.id || 'main' + const agentNodes = agents.map((a, i) => { + const id = a.id || a.name || `agent-${i}` + const identity = a.identity || {} + const emoji = identity.emoji || '🤖' + const label = identity.name || id + const isDefault = a.default || a.isDefault || id === defaultAgentId + return { id, label, emoji, isDefault, type: 'agent', originalIndex: i } + }) + + // Build edges from bindings + const edges = [] + for (const b of bindings) { + const agentId = b.agentId || b.agent || '' + const channel = b.match?.channel || b.channel || '' + const enabled = b.enabled !== false + const peer = b.match?.peer + const accountId = b.match?.accountId + let hint = '' + if (peer) hint = t('routeMap.peer') + else if (accountId) hint = `${t('routeMap.account')}: ${accountId}` + edges.push({ from: channel, to: agentId, enabled, hint, channel, agentId }) + } + + // Add implicit default agent edges for channels without bindings + const boundChannels = new Set(edges.map(e => e.from)) + for (const ch of channelNodes) { + if (!boundChannels.has(ch.id) && ch.enabled) { + edges.push({ from: ch.id, to: defaultAgentId, enabled: true, hint: t('routeMap.defaultAgent'), channel: ch.id, agentId: defaultAgentId, implicit: true }) + } + } + + // Layout: 3 columns — Channels | gap | Agents + const leftCount = Math.max(channelNodes.length, 1) + const rightCount = Math.max(agentNodes.length, 1) + const maxRows = Math.max(leftCount, rightCount) + const svgW = PAD_LEFT * 2 + NODE_W * 2 + COL_GAP + const svgH = PAD_TOP + maxRows * (NODE_H + ROW_GAP) + 40 + + // Position nodes + channelNodes.forEach((n, i) => { + n.x = PAD_LEFT + n.y = PAD_TOP + i * (NODE_H + ROW_GAP) + (maxRows - leftCount) * (NODE_H + ROW_GAP) / 2 + }) + agentNodes.forEach((n, i) => { + n.x = PAD_LEFT + NODE_W + COL_GAP + n.y = PAD_TOP + i * (NODE_H + ROW_GAP) + (maxRows - rightCount) * (NODE_H + ROW_GAP) / 2 + }) + + // Build agent-to-agent edges from tools.agentToAgent.allow + const a2aEdges = [] + for (const a of agents) { + const allow = a.tools?.agentToAgent?.allow + if (!Array.isArray(allow) || a.tools?.agentToAgent?.enabled === false) continue + const fromId = a.id || a.name || 'main' + for (const targetId of allow) { + if (targetId && targetId !== fromId) { + a2aEdges.push({ from: fromId, to: targetId }) + } + } + } + + // Build node lookup + const nodeMap = {} + for (const n of [...channelNodes, ...agentNodes]) nodeMap[n.id] = n + + // Extra height for legend if we have a2a edges + const legendH = a2aEdges.length > 0 ? 60 : 20 + + // Render SVG + let svg = `` + + // Column headers + svg += `${t('routeMap.channels')}` + svg += `${t('routeMap.agents')}` + svg += `${t('routeMap.bindings')}` + + // Draw edges + for (const e of edges) { + const src = nodeMap[e.from] + const dst = nodeMap[e.to] + if (!src || !dst) continue + const x1 = src.x + NODE_W + const y1 = src.y + NODE_H / 2 + const x2 = dst.x + const y2 = dst.y + NODE_H / 2 + const cx1 = x1 + COL_GAP * 0.35 + const cx2 = x2 - COL_GAP * 0.35 + const opacity = e.enabled ? 0.7 : 0.25 + const dash = e.implicit ? '6,4' : 'none' + const color = e.enabled ? (src.color || '#6366f1') : '#94a3b8' + svg += `` + // Arrow + svg += `` + // Edge label + if (e.hint) { + const mx = (x1 + x2) / 2 + const my = (y1 + y2) / 2 + svg += `${escAttr(e.hint)}` + } + } + + // Draw channel nodes + for (const n of channelNodes) { + const opacity = n.enabled ? 1 : 0.45 + svg += ` + + + ${n.enabled ? '📡' : '⏸'} + ${escAttr(n.label)} + ${n.enabled ? t('routeMap.enabled') : t('routeMap.disabled')} + ` + } + + // Draw agent nodes + for (const n of agentNodes) { + svg += ` + + ${n.emoji} + ${escAttr(n.label)} + ${n.isDefault ? '⭐ ' + t('routeMap.defaultAgent') : n.id} + ` + } + + // Draw agent-to-agent sub-agent edges (amber dashed, curved right of agent column) + for (let i = 0; i < a2aEdges.length; i++) { + const e = a2aEdges[i] + const src = nodeMap[e.from] + const dst = nodeMap[e.to] + if (!src || !dst) continue + const x1 = src.x + NODE_W + const y1 = src.y + NODE_H / 2 + const x2 = dst.x + NODE_W + const y2 = dst.y + NODE_H / 2 + const bulge = 40 + i * 12 + const cx = Math.max(x1, x2) + bulge + svg += `` + svg += `` + const mx = cx - 4 + const my = (y1 + y2) / 2 + svg += `${t('routeMap.subAgentCall')}` + } + + // Legend + const ly = svgH + (a2aEdges.length > 0 ? 10 : 0) + svg += `` + let lx = PAD_LEFT + // Solid line = explicit binding + svg += `` + svg += `${t('routeMap.legendBinding')}` + lx += 110 + // Dashed line = default route + svg += `` + svg += `${t('routeMap.legendDefault')}` + if (a2aEdges.length > 0) { + lx += 110 + svg += `` + svg += `${t('routeMap.subAgentCall')}` + } + svg += `` + + svg += '' + + container.innerHTML = `
${svg}
` + + // Click to navigate + container.querySelectorAll('.route-map-node').forEach(el => { + el.addEventListener('click', () => { + const target = el.dataset.nav + if (target) navigate('/' + target) + }) + }) +} diff --git a/src/style/pages.css b/src/style/pages.css index c5dc858..3545a65 100644 --- a/src/style/pages.css +++ b/src/style/pages.css @@ -1483,6 +1483,186 @@ transform: translateX(18px); } +/* 插件中心 */ +.plugin-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: var(--space-md); +} +.plugin-card { + background: var(--bg-card); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + padding: var(--space-md); + transition: all var(--transition-fast); +} +.plugin-card:hover { + background: var(--bg-card-hover); + border-color: var(--accent); +} +.plugin-card-inactive { + opacity: 0.65; +} +.plugin-card-header { + display: flex; + align-items: center; + gap: var(--space-sm); + margin-bottom: var(--space-sm); +} +.plugin-card-icon { + font-size: 28px; + line-height: 1; + flex-shrink: 0; +} +.plugin-card-title { + flex: 1; + min-width: 0; +} +.plugin-card-name { + font-weight: 700; + font-size: var(--font-size-md); + color: var(--text-primary); + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.plugin-card-badges { + display: flex; + gap: 4px; + margin-top: 2px; + flex-wrap: wrap; +} +.plugin-badge { + font-size: 10px; + padding: 1px 6px; + border-radius: var(--radius-sm); + white-space: nowrap; +} +.plugin-badge-builtin { + background: var(--accent-bg); + color: var(--accent); +} +.plugin-badge-version { + background: var(--bg-tertiary); + color: var(--text-tertiary); +} +.plugin-card-desc { + font-size: var(--font-size-sm); + color: var(--text-secondary); + line-height: 1.5; + margin-bottom: var(--space-sm); + min-height: 36px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.plugin-card-footer { + display: flex; + align-items: center; + justify-content: space-between; +} +.plugin-card-status { + font-size: var(--font-size-xs); + color: var(--text-tertiary); +} +.plugin-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.plugin-status-enabled { + background: var(--success); + box-shadow: 0 0 4px var(--success); +} +.plugin-status-disabled { + background: var(--text-tertiary); +} +.plugin-status-missing { + background: var(--error); +} + +/* 路由地图 */ +.route-map-stats { + display: flex; + gap: var(--space-md); + margin-bottom: var(--space-md); +} +.route-map-stat { + display: flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-sm) var(--space-md); + background: var(--bg-card); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + font-size: var(--font-size-sm); +} +.route-map-stat-num { + font-size: var(--font-size-xl); + font-weight: 700; + color: var(--accent); + min-width: 24px; + text-align: center; +} +.route-map-stat-label { + color: var(--text-secondary); +} +.route-map-canvas { + background: var(--bg-card); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + overflow: hidden; +} +.route-map-scroll { + overflow: auto; + padding: var(--space-sm); +} +.route-map-svg { + display: block; + min-width: 100%; +} +.route-map-col-label { + fill: var(--text-secondary); + font-size: 13px; + font-weight: 600; + letter-spacing: 0.02em; +} +.route-map-card { + fill: var(--bg-card); + stroke-width: 1.5; + stroke: var(--border-primary); + filter: drop-shadow(0 1px 2px rgba(0,0,0,0.06)); + transition: all 0.15s; +} +.route-map-card-default { + stroke: var(--accent); + stroke-width: 2; +} +.route-map-node:hover .route-map-card { + fill: var(--bg-card-hover); + stroke-width: 2; +} +.route-map-node-label { + fill: var(--text-primary); + font-size: 13px; + font-weight: 600; +} +.route-map-node-sub { + fill: var(--text-tertiary); + font-size: 10px; +} +.route-map-node-emoji { + fill: var(--text-primary); +} +.route-map-edge-label { + fill: var(--text-tertiary); + font-size: 9px; + pointer-events: none; +} + /* 加载占位 */ .loading-placeholder { background: linear-gradient(90deg, var(--bg-secondary) 25%, var(--bg-tertiary) 50%, var(--bg-secondary) 75%);