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:
晴天
2026-04-11 00:44:06 +08:00
parent c1fb674c44
commit 70d768be17
27 changed files with 2337 additions and 187 deletions

View File

@@ -66,7 +66,67 @@
<div class="sp-bar"><div class="sp-bar-inner"></div></div>
<div class="sp-site"><a href="https://qt.cool" target="_blank">qt.cool</a></div>
</div>
<script>window._splashTimer=setTimeout(function(){var s=document.getElementById('splash');if(s){var app=document.getElementById('content');if(app&&app.children.length>0){s.classList.add('hide');setTimeout(function(){s.remove()},500)}else{s.innerHTML='<div style="text-align:center;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif"><div style="font-size:40px;margin-bottom:12px">\u26A0\uFE0F</div><div style="font-size:16px;font-weight:600;color:#18181b;margin-bottom:8px">\u9875\u9762\u52A0\u8F7D\u5931\u8D25</div><div style="font-size:12px;color:#71717a;margin-bottom:16px;line-height:1.7">\u5E94\u7528\u542F\u52A8\u9636\u6BB5\u6CA1\u6709\u5B8C\u6210\u524D\u7AEF\u52A0\u8F7D\u3002\u8FD9\u901A\u5E38\u610F\u5473\u7740 WebView2 \u672A\u5B89\u88C5/\u5DF2\u635F\u574F\uFF0C\u6216\u8005\u524D\u7AEF\u8D44\u6E90\u88AB\u62E6\u622A\u3002<br>\u8BF7\u5148\u68C0\u67E5\u63A7\u5236\u53F0\u662F\u5426\u6709\u62A5\u9519\uFF1B\u5982\u786E\u8BA4 WebView2 \u672A\u5B89\u88C5\uFF0C\u8BF7\u4E0B\u8F7D <a href="https://go.microsoft.com/fwlink/p/?LinkId=2124703" style="color:#6366f1">WebView2 Runtime</a></div><button onclick="location.reload()" style="padding:6px 16px;border-radius:6px;border:none;background:#6366f1;color:#fff;font-size:12px;cursor:pointer">\u5237\u65B0\u91CD\u8BD5</button></div>'}}},30000)</script>
<script>
// 多阶段启动检测:区分"JS未加载WebView2问题"与"JS已加载但启动慢"
window._splashStart = Date.now();
window._jsLoaded = false; // main.js 入口会设为 true
window._bootDone = false; // boot() 完成后设为 true
window._splashTimer = setInterval(function () {
var s = document.getElementById('splash');
if (!s) { clearInterval(window._splashTimer); return; }
var app = document.getElementById('content');
// 已有内容 → 正常隐藏
if (app && app.children.length > 0) {
s.classList.add('hide');
setTimeout(function () { s.remove(); }, 500);
clearInterval(window._splashTimer);
return;
}
if (window._bootDone) { clearInterval(window._splashTimer); return; }
var elapsed = Date.now() - window._splashStart;
var sec = Math.floor(elapsed / 1000);
// 阶段115秒内 JS 模块未加载 → 可能是真正的 WebView2/资源问题
if (!window._jsLoaded && elapsed > 15000) {
clearInterval(window._splashTimer);
var dk = window.matchMedia && window.matchMedia('(prefers-color-scheme:dark)').matches;
s.innerHTML = '<div style="text-align:center;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif">'
+ '<div style="font-size:40px;margin-bottom:12px">\u26A0\uFE0F</div>'
+ '<div style="font-size:16px;font-weight:600;color:' + (dk ? '#e4e4e7' : '#18181b') + ';margin-bottom:8px">\u524D\u7AEF\u8D44\u6E90\u52A0\u8F7D\u5931\u8D25</div>'
+ '<div style="font-size:12px;color:#71717a;margin-bottom:16px;line-height:1.7">'
+ 'JavaScript \u6A21\u5757\u672A\u80FD\u5728 15 \u79D2\u5185\u52A0\u8F7D\u3002\u53EF\u80FD\u539F\u56E0\uFF1A<br>'
+ '\u2022 WebView2 Runtime \u672A\u5B89\u88C5\u6216\u5DF2\u635F\u574F<br>'
+ '\u2022 \u524D\u7AEF\u8D44\u6E90\u88AB\u5B89\u5168\u8F6F\u4EF6\u62E6\u622A<br>'
+ '\u2022 \u5E94\u7528\u5B89\u88C5\u4E0D\u5B8C\u6574<br><br>'
+ '\u8BF7\u5C1D\u8BD5\u5237\u65B0\uFF1B\u5982\u591A\u6B21\u5931\u8D25\uFF0C\u8BF7\u786E\u8BA4 <a href="https://go.microsoft.com/fwlink/p/?LinkId=2124703" style="color:#6366f1">WebView2 Runtime</a> \u5DF2\u5B89\u88C5</div>'
+ '<button onclick="location.reload()" style="padding:6px 16px;border-radius:6px;border:none;background:#6366f1;color:#fff;font-size:12px;cursor:pointer">\u5237\u65B0\u91CD\u8BD5</button></div>';
return;
}
// 阶段2JS 已加载但 boot() 仍在运行 → 显示等待提示(不报错)
if (window._jsLoaded && elapsed > 20000) {
var hint = s.querySelector('.sp-hint');
if (!hint) {
hint = document.createElement('div');
hint.className = 'sp-hint';
hint.style.cssText = 'font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-size:12px;color:#a1a1aa;margin-top:16px';
s.appendChild(hint);
}
hint.textContent = '\u6B63\u5728\u521D\u59CB\u5316\u73AF\u5883\uFF0C\u8BF7\u7A0D\u5019... (' + sec + 's)';
}
// 阶段390秒仍未完成 → 才显示真正的错误
if (elapsed > 90000) {
clearInterval(window._splashTimer);
var dk2 = window.matchMedia && window.matchMedia('(prefers-color-scheme:dark)').matches;
s.innerHTML = '<div style="text-align:center;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif">'
+ '<div style="font-size:40px;margin-bottom:12px">\u26A0\uFE0F</div>'
+ '<div style="font-size:16px;font-weight:600;color:' + (dk2 ? '#e4e4e7' : '#18181b') + ';margin-bottom:8px">\u542F\u52A8\u8D85\u65F6</div>'
+ '<div style="font-size:12px;color:#71717a;margin-bottom:16px;line-height:1.7">'
+ '\u5E94\u7528\u5DF2\u7B49\u5F85 90 \u79D2\u4ECD\u672A\u5B8C\u6210\u521D\u59CB\u5316\u3002<br>'
+ '\u53EF\u80FD\u662F\u7F51\u7EDC\u73AF\u5883\u68C0\u6D4B\u8D85\u65F6\u6216\u540E\u7AEF\u670D\u52A1\u5F02\u5E38\u3002<br>'
+ '\u8BF7\u5C1D\u8BD5\u5237\u65B0\u6216\u91CD\u542F\u5E94\u7528\u3002</div>'
+ '<button onclick="location.reload()" style="padding:6px 16px;border-radius:6px;border:none;background:#6366f1;color:#fff;font-size:12px;cursor:pointer">\u5237\u65B0\u91CD\u8BD5</button></div>';
}
}, 3000);
</script>
<div id="app">
<aside id="sidebar"></aside>

View File

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

View File

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

View 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, "未配置 allowedOriginsautoPair 会自动修复)", 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,
}
}

View File

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

View File

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

View File

@@ -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 /health3s 超时
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,
}
}

View File

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

View File

@@ -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': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="20" x2="12" y2="10"/><line x1="18" y1="20" x2="18" y2="4"/><line x1="6" y1="20" x2="6" y2="16"/></svg>',
settings: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>',
debug: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/><circle cx="12" cy="12" r="3"/></svg>',
'route-map': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="5" cy="6" r="2"/><circle cx="19" cy="6" r="2"/><circle cx="5" cy="18" r="2"/><circle cx="19" cy="18" r="2"/><path d="M7 6h10M7 18h10M5 8v8M19 8v8"/></svg>',
diagnose: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>',
}
let _delegated = false
@@ -162,6 +167,7 @@ export function renderSidebar(el) {
<div class="nav-section-title">${section.section}</div>`
for (const item of section.items) {
if (item.gate && !isFeatureAvailable(item.gate)) continue
const active = current === item.route ? ' active' : ''
html += `<div class="nav-item${active}" data-route="${item.route}">
${ICONS[item.icon] || ''}

View File

@@ -3,10 +3,6 @@
* 管理 openclaw 安装状态,供各组件查询
*/
import { api } from './tauri-api.js'
import {
evaluateAutoRestartAttempt,
shouldResetAutoRestartCount,
} from './gateway-guardian-policy.js'
const isTauri = !!window.__TAURI_INTERNALS__
@@ -22,10 +18,8 @@ let _gwListeners = []
let _gwStopCount = 0 // 连续检测到"停止"的次数,防抖用
let _isUpgrading = false // 升级/切换版本期间,阻止 setup 跳转
let _userStopped = false // 用户主动停止,不自动拉起
let _autoRestartCount = 0 // 自动重启次数
let _lastRestartTime = 0 // 上次重启时间
let _gatewayRunningSince = 0 // Gateway 最近一次进入稳定运行状态的时间
let _guardianListeners = [] // 守护放弃时的回调
let _guardianListeners = [] // 守护放弃时的回调(后端 guardian-event 触发)
/** openclaw 是否就绪CLI 已安装 + 配置文件存在) */
export function isOpenclawReady() {
@@ -41,10 +35,8 @@ export function isUpgrading() { return _isUpgrading }
/** 标记用户主动停止 Gateway不触发自动重启 */
export function setUserStopped(v) { _userStopped = !!v }
/** 重置自动重启计数(用户手动启动后重置) */
/** 重置守护状态(用户手动启动后重置) */
export function resetAutoRestart() {
_autoRestartCount = 0
_lastRestartTime = 0
_gatewayRunningSince = 0
_userStopped = false
}
@@ -158,8 +150,8 @@ function _setGatewayRunning(val, foreign = false) {
_gatewayRunningSince = Date.now()
} else if (wasRunning && !_userStopped && !_isUpgrading && _openclawReady && !foreign) {
_gatewayRunningSince = 0
// Gateway 意外停止,尝试自动重启
_tryAutoRestart()
// Gateway 意外停止 → 后端 Rust guardian 负责自动重启,前端仅更新 UI 状态
console.log('[app-state] Gateway 意外停止,等待后端 guardian 重启...')
} else if (!val) {
_gatewayRunningSince = 0
}
@@ -167,54 +159,6 @@ function _setGatewayRunning(val, foreign = false) {
}
}
async function _tryAutoRestart() {
const now = Date.now()
const decision = evaluateAutoRestartAttempt({
now,
lastRestartTime: _lastRestartTime,
autoRestartCount: _autoRestartCount,
})
if (decision.action === 'cooldown') return
if (decision.action === 'give_up') {
console.warn('[guardian] Gateway 已达到自动重启上限,停止守护,请手动检查')
_guardianListeners.forEach(fn => { try { fn() } catch {} })
return
}
// 延迟 3 秒后再次确认端口确实空闲,防止瞬态 TCP 超时误判触发不必要的重启
await new Promise(r => setTimeout(r, 3000))
try {
const { invalidate } = await import('./tauri-api.js')
invalidate('get_services_status')
const services = await api.getServicesStatus()
const gw = services?.find?.(s => s.label === 'ai.openclaw.gateway') || services?.[0]
if (gw?.running) {
console.log(gw?.owned_by_current_instance === false
? '[guardian] 检测到外部 Gateway 正在占用端口,跳过自动重启'
: '[guardian] 端口仍在使用中,跳过自动重启')
_gwStopCount = 0
if (gw?.owned_by_current_instance !== false) {
_gatewayRunning = true
_gatewayRunningSince = Date.now()
_gwListeners.forEach(fn => { try { fn(true) } catch {} })
}
return
}
} catch {}
_autoRestartCount = decision.autoRestartCount
_lastRestartTime = decision.lastRestartTime
console.log(`[guardian] Gateway 意外停止,自动重启 (${_autoRestartCount}/3)...`)
try {
await api.startService('ai.openclaw.gateway')
console.log('[guardian] Gateway 自动重启成功')
} catch (e) {
console.error('[guardian] Gateway 自动重启失败:', e)
}
}
/** 刷新 Gateway 运行状态(轻量,仅查服务状态)
* 防抖running→stopped 需要连续 3 次检测才切换,避免瞬态误判 */
export async function refreshGatewayStatus() {
@@ -229,12 +173,6 @@ export async function refreshGatewayStatus() {
_gwStopCount = 0
if (!_gatewayRunning) {
_setGatewayRunning(true, false)
} else if (shouldResetAutoRestartCount({
autoRestartCount: _autoRestartCount,
runningSince: _gatewayRunningSince,
now: Date.now(),
})) {
_autoRestartCount = 0
}
} else {
if (foreignRunning) {

135
src/lib/feature-gates.js Normal file
View File

@@ -0,0 +1,135 @@
/**
* 功能版本门控 — 根据 OpenClaw 版本动态显示/隐藏功能
*
* 工作原理:
* 1. 从 api.getVersionInfo() 获取当前 OpenClaw 版本
* 2. 对比功能所需最低版本
* 3. sidebar 和页面可调用 isFeatureAvailable() 判断是否显示
*
* 版本格式: x.y.z 或 x.y.z-zh.w汉化版
*/
import { api } from './tauri-api.js'
import { wsClient } from './ws-client.js'
// 功能 → 最低版本映射(语义化版本号,不含 -zh 后缀)
const FEATURE_MIN_VERSIONS = {
dreaming: '0.11.0',
cron: '0.10.0',
skills: '0.10.0',
'route-map': '0.9.0',
'plugin-hub': '0.9.0',
memory: '0.8.0',
}
let _cachedVersion = null
let _cacheTime = 0
const CACHE_TTL = 60000
/**
* 解析版本号为可比较的数组 [major, minor, patch]
* 支持 '0.11.6', '0.11.6-zh.2', '2026.3.18' 等格式
*/
function parseVersion(ver) {
if (!ver) return null
// 移除 -zh.xxx / -beta.xxx 等后缀,只保留主版本号
const base = ver.replace(/-.*$/, '')
const parts = base.split('.').map(Number)
if (parts.some(isNaN)) return null
while (parts.length < 3) parts.push(0)
return parts.slice(0, 3)
}
/**
* 比较版本: a >= b 返回 true
*/
function versionGte(a, b) {
const pa = parseVersion(a)
const pb = parseVersion(b)
if (!pa || !pb) return true // 无法解析时默认允许
for (let i = 0; i < 3; i++) {
if (pa[i] > pb[i]) return true
if (pa[i] < pb[i]) return false
}
return true // equal
}
/**
* 获取当前 OpenClaw 版本(带缓存)
*/
async function getCurrentVersion() {
if (_cachedVersion && Date.now() - _cacheTime < CACHE_TTL) return _cachedVersion
// 优先从 wsClient.serverVersion 获取(实时)
if (wsClient.serverVersion) {
_cachedVersion = wsClient.serverVersion
_cacheTime = Date.now()
return _cachedVersion
}
// 回退到 API
try {
const info = await api.getVersionInfo()
if (info?.current) {
_cachedVersion = info.current
_cacheTime = Date.now()
}
} catch {}
return _cachedVersion
}
/**
* 同步获取上次缓存的版本(不发请求)
*/
export function getCachedVersion() {
return _cachedVersion || wsClient.serverVersion || null
}
/**
* 同步检查功能是否可用(基于缓存版本)
* 如果版本信息尚未获取,默认返回 true避免隐藏功能
*/
export function isFeatureAvailable(featureId) {
const minVer = FEATURE_MIN_VERSIONS[featureId]
if (!minVer) return true // 无门控 → 始终可用
const currentVer = getCachedVersion()
if (!currentVer) return true // 版本未知 → 默认显示
return versionGte(currentVer, minVer)
}
/**
* 异步检查功能是否可用(会先获取版本)
*/
export async function checkFeatureAvailable(featureId) {
await getCurrentVersion()
return isFeatureAvailable(featureId)
}
/**
* 初始化:预加载版本信息
*/
export async function initFeatureGates() {
await getCurrentVersion()
}
/**
* 刷新缓存
*/
export function invalidateVersionCache() {
_cachedVersion = null
_cacheTime = 0
}
/**
* 获取所有功能门控状态(调试用)
*/
export function getAllFeatureStatus() {
const ver = getCachedVersion()
const result = {}
for (const [feature, minVer] of Object.entries(FEATURE_MIN_VERSIONS)) {
result[feature] = { minVersion: minVer, available: isFeatureAvailable(feature) }
}
return { currentVersion: ver, features: result }
}

View File

@@ -1,38 +0,0 @@
/**
* Gateway 守护策略
* 纯函数,便于测试自动重启与计数重置规则
*/
export const MAX_AUTO_RESTART = 3
export const RESTART_COOLDOWN = 60000
export const STABLE_RUNNING_MS = 120000
export function evaluateAutoRestartAttempt({
now,
lastRestartTime,
autoRestartCount,
}) {
if (now - lastRestartTime < RESTART_COOLDOWN) {
return { action: 'cooldown' }
}
if (autoRestartCount >= MAX_AUTO_RESTART) {
return { action: 'give_up' }
}
return {
action: 'restart',
autoRestartCount: autoRestartCount + 1,
lastRestartTime: now,
}
}
export function shouldResetAutoRestartCount({
autoRestartCount,
runningSince,
now,
}) {
if (autoRestartCount <= 0) return false
if (!runningSince) return false
return now - runningSince >= STABLE_RUNNING_MS
}

View File

@@ -5,11 +5,14 @@
// API 接口类型选项
export const API_TYPES = [
{ value: 'openai-completions', label: 'OpenAI 兼容 (最常用)' },
{ value: 'anthropic-messages', label: 'Anthropic 原生' },
{ value: 'openai-completions', label: 'OpenAI Chat Completions (最常用)' },
{ value: 'anthropic-messages', label: 'Anthropic Messages' },
{ value: 'openai-responses', label: 'OpenAI Responses' },
{ value: 'openai-codex-responses', label: 'OpenAI Codex Responses' },
{ value: 'google-generative-ai', label: 'Google Gemini' },
{ value: 'ollama', label: 'Ollama 原生' },
{ value: 'github-copilot', label: 'GitHub Copilot' },
{ value: 'bedrock-converse-stream', label: 'AWS Bedrock' },
{ value: 'ollama', label: 'Ollama 本地模型' },
]
// 服务商快捷预设
@@ -20,13 +23,17 @@ export const PROVIDER_PRESETS = [
{ key: 'volcengine', label: '火山引擎', baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', api: 'openai-completions', site: 'https://volcengine.com/L/Ph1OP5I3_GY', desc: '字节跳动旗下云平台,支持豆包等模型' },
{ key: 'aliyun', label: '阿里云百炼', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', api: 'openai-completions', site: 'https://www.aliyun.com/benefit/ai/aistar?userCode=keahn2zr&clubBiz=subTask..12435175..10263..', desc: '阿里云 AI 大模型平台,支持通义千问全系列' },
{ key: 'zhipu', label: '智谱 AI', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', api: 'openai-completions', site: 'https://www.bigmodel.cn/glm-coding?ic=3F6F9XYKTS', desc: '国产大模型领军企业,支持 GLM-4 全系列' },
{ key: 'minimax', label: 'MiniMax', baseUrl: 'https://api.minimax.io/v1', api: 'openai-completions', site: 'https://platform.minimaxi.com/', desc: '国产多模态大模型,支持 MiniMax-M2.7 / M2.5 系列,兼容 OpenAI 接口' },
{ key: 'openai', label: 'OpenAI 官方', baseUrl: 'https://api.openai.com/v1', api: 'openai-completions' },
{ key: 'anthropic', label: 'Anthropic 官方', baseUrl: 'https://api.anthropic.com', api: 'anthropic-messages' },
{ key: 'deepseek', label: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1', api: 'openai-completions' },
{ key: 'google', label: 'Google Gemini', baseUrl: 'https://generativelanguage.googleapis.com/v1beta', api: 'google-generative-ai' },
{ key: 'nvidia', label: 'NVIDIA NIM', baseUrl: 'https://integrate.api.nvidia.com/v1', api: 'openai-completions', desc: '英伟达推理平台,支持 Llama、Mistral 等模型' },
{ key: 'ollama', label: 'Ollama (本地)', baseUrl: 'http://127.0.0.1:11434/v1', api: 'openai-completions' },
{ key: 'minimax', label: 'MiniMax', baseUrl: 'https://api.minimaxi.com/anthropic/v1', api: 'anthropic-messages', site: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', desc: '国产多模态大模型,支持 MiniMax-M2.7 / M2.5 系列' },
{ key: 'moonshot', label: 'Moonshot / Kimi', baseUrl: 'https://api.moonshot.ai/v1', api: 'openai-completions', site: 'https://platform.moonshot.ai/console/api-keys', desc: 'Kimi 大模型平台,支持超长上下文' },
{ key: 'openai', label: 'OpenAI 官方', baseUrl: 'https://api.openai.com/v1', api: 'openai-completions', site: 'https://platform.openai.com/api-keys' },
{ key: 'anthropic', label: 'Anthropic 官方', baseUrl: 'https://api.anthropic.com/v1', api: 'anthropic-messages', site: 'https://console.anthropic.com/settings/keys' },
{ key: 'deepseek', label: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1', api: 'openai-completions', site: 'https://platform.deepseek.com/api_keys' },
{ key: 'google', label: 'Google Gemini', baseUrl: 'https://generativelanguage.googleapis.com/v1beta', api: 'google-generative-ai', site: 'https://aistudio.google.com/app/apikey' },
{ key: 'xai', label: 'xAI (Grok)', baseUrl: 'https://api.x.ai/v1', api: 'openai-completions', site: 'https://console.x.ai/', desc: 'Elon Musk 旗下 AI支持 Grok 系列模型' },
{ key: 'groq', label: 'Groq', baseUrl: 'https://api.groq.com/openai/v1', api: 'openai-completions', site: 'https://console.groq.com/keys', desc: '超快推理平台,支持 Llama、Mixtral 等开源模型' },
{ key: 'openrouter', label: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', api: 'openai-completions', site: 'https://openrouter.ai/keys', desc: '模型聚合路由,一个 Key 访问所有主流模型' },
{ key: 'nvidia', label: 'NVIDIA NIM', baseUrl: 'https://integrate.api.nvidia.com/v1', api: 'openai-completions', site: 'https://build.nvidia.com/models', desc: '英伟达推理平台,支持 Llama、Mistral 等模型' },
{ key: 'ollama', label: 'Ollama (本地)', baseUrl: 'http://127.0.0.1:11434/v1', api: 'openai-completions', site: 'https://ollama.com/' },
]
// 晴辰云配置
@@ -71,15 +78,25 @@ export const MODEL_PRESETS = {
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', contextWindow: 1000000 },
],
minimax: [
{ id: 'MiniMax-M2.7', name: 'MiniMax M2.7', contextWindow: 1000000 },
{ id: 'MiniMax-M2.7-highspeed', name: 'MiniMax M2.7 Highspeed', contextWindow: 1000000 },
{ id: 'MiniMax-M2.5', name: 'MiniMax M2.5', contextWindow: 204000 },
{ id: 'MiniMax-M2.5-highspeed', name: 'MiniMax M2.5 Highspeed', contextWindow: 204000 },
],
moonshot: [
{ id: 'kimi-k2.5', name: 'Kimi K2.5', contextWindow: 131072 },
{ id: 'kimi-k2', name: 'Kimi K2', contextWindow: 131072 },
{ id: 'kimi-latest', name: 'Kimi Latest', contextWindow: 131072 },
],
xai: [
{ id: 'grok-4', name: 'Grok 4', contextWindow: 131072 },
{ id: 'grok-4-fast', name: 'Grok 4 Fast', contextWindow: 131072 },
],
groq: [
{ id: 'llama-3.3-70b-versatile', name: 'Llama 3.3 70B', contextWindow: 32768 },
{ id: 'mixtral-8x7b-32768', name: 'Mixtral 8x7B', contextWindow: 32768 },
],
ollama: [
{ id: 'qwen2.5:7b', name: 'Qwen 2.5 7B', contextWindow: 32768 },
{ id: 'llama3.2', name: 'Llama 3.2', contextWindow: 8192 },
{ id: 'gemma3', name: 'Gemma 3', contextWindow: 32768 },
{ id: 'qwen3:32b', name: 'Qwen 3 32B', contextWindow: 32768 },
{ id: 'llama3.3:70b', name: 'Llama 3.3 70B', contextWindow: 8192 },
{ id: 'deepseek-r1:32b', name: 'DeepSeek R1 32B', contextWindow: 32768, reasoning: true },
],
}

View File

@@ -189,6 +189,8 @@ export const api = {
stopService: (label) => { invalidate('get_services_status'); return invoke('stop_service', { label }) },
restartService: (label) => { invalidate('get_services_status'); return invoke('restart_service', { label }) },
claimGateway: () => { invalidate('get_services_status'); return invoke('claim_gateway') },
probeGatewayPort: () => invoke('probe_gateway_port'),
diagnoseGatewayConnection: () => invoke('diagnose_gateway_connection'),
guardianStatus: () => invoke('guardian_status'),
// 配置(读缓存,写清缓存)
@@ -256,6 +258,9 @@ export const api = {
return invoke('repair_qqbot_channel_setup')
},
listConfiguredPlatforms: () => cachedInvoke('list_configured_platforms', {}, 5000),
listAllPlugins: () => cachedInvoke('list_all_plugins', {}, 5000),
togglePlugin: (pluginId, enabled) => { invalidate('list_all_plugins'); return invoke('toggle_plugin', { pluginId, enabled }) },
installPlugin: (packageName) => { invalidate('list_all_plugins'); return invoke('install_plugin', { packageName }) },
getChannelPluginStatus: (pluginId) => invoke('get_channel_plugin_status', { pluginId }),
installQqbotPlugin: (version = null) => invoke('install_qqbot_plugin', { version }),
installChannelPlugin: (packageName, pluginId, version = null) => invoke('install_channel_plugin', { packageName, pluginId, version }),
@@ -302,7 +307,7 @@ export const api = {
deleteBackup: (name) => { invalidate('list_backups'); return invoke('delete_backup', { name }) },
// 设备密钥 + Gateway 握手
createConnectFrame: (nonce, gatewayToken) => invoke('create_connect_frame', { nonce, gatewayToken }),
createConnectFrame: (nonce, gatewayToken, gatewayPassword) => invoke('create_connect_frame', { nonce, gatewayToken, gatewayPassword: gatewayPassword || null }),
// 设备配对
autoPairDevice: () => invoke('auto_pair_device'),

View File

@@ -52,6 +52,8 @@ export class WsClient {
this._challengeTimer = null
this._wsId = 0
this._autoPairAttempts = 0
this._authRetryCount = 0
this._password = ''
this._serverVersion = null
// 增强状态追踪
@@ -111,6 +113,7 @@ export class WsClient {
this._intentionalClose = false
this._autoPairAttempts = 0
this._token = token || ''
this._password = opts.password || ''
// 自动检测协议:如果页面通过 HTTPS 加载(反代场景),使用 wss://
const proto = opts.secure ?? (typeof location !== 'undefined' && location.protocol === 'https:') ? 'wss' : 'ws'
const nextUrl = `${proto}://${host}/ws?token=${encodeURIComponent(this._token)}`
@@ -143,6 +146,7 @@ export class WsClient {
this._intentionalClose = false
this._reconnectAttempts = 0
this._autoPairAttempts = 0
this._authRetryCount = 0
this._missedHeartbeats = 0
this._stopPing()
this._stopHeartbeat()
@@ -196,23 +200,91 @@ export class WsClient {
this._ws = null
this._connecting = false
this._clearChallengeTimer()
if (e.code === 4001 || e.code === 4003 || e.code === 4004) {
this._setConnected(false, 'auth_failed', e.reason || 'Token 认证失败')
this._intentionalClose = true
this._flushPending()
const reason = (e.reason || '').toLowerCase()
// ── 4001: Gateway 配置热重载 / 设备被移除 ──
// 上游仅在 config reload 和 device removal 时发 4001
// 正确做法:短延迟后自动重连,而非永久断开
if (e.code === 4001) {
console.log('[ws] Gateway 配置变更3秒后自动重连:', e.reason)
this._setConnected(false, 'reconnecting', 'Gateway 配置已更新,自动重连中...')
this._gatewayReady = false
this._handshaking = false
this._stopPing()
setTimeout(() => {
if (!this._intentionalClose) {
this._reconnectAttempts = 0
this._doConnect()
}
}, 3000)
return
}
// ── 1008: 握手期策略拒绝(按 reason 文本精确分流)──
if (e.code === 1008 && !this._intentionalClose) {
if (this._autoPairAttempts < 1) {
console.log('[ws] origin not allowed (1008),尝试自动修复...')
this._setConnected(false, 'reconnecting', 'origin not allowed修复中...')
this._autoPairAndReconnect()
if (/origin not allowed/i.test(reason)) {
// Origin 不在白名单 → 自动配对(写 allowedOrigins + reload
if (this._autoPairAttempts < 1) {
console.log('[ws] origin not allowed尝试自动修复...')
this._setConnected(false, 'reconnecting', 'origin 修复中...')
this._autoPairAndReconnect()
return
}
this._setConnected(false, 'error', 'origin not allowed请检查 gateway.controlUi.allowedOrigins 配置')
return
}
console.warn('[ws] origin 1008 自动修复已尝试过,显示错误')
this._setConnected(false, 'error', e.reason || 'origin not allowed请点击「修复并重连」')
if (/unauthorized/i.test(reason)) {
// Token/password 不匹配 → 尝试刷新凭据并重连
if (this._authRetryCount < 2) {
this._authRetryCount++
console.log(`[ws] 认证失败,刷新凭据 (${this._authRetryCount}/2):`, e.reason)
this._setConnected(false, 'reconnecting', `认证失败,刷新凭据中 (${this._authRetryCount}/2)...`)
this._refreshCredentialsAndReconnect()
return
}
this._setConnected(false, 'auth_failed', `认证失败: ${e.reason || 'token mismatch'}。请检查 Gateway Token 配置。`)
this._intentionalClose = true
this._flushPending()
return
}
if (/pairing required/i.test(reason) || /not.paired/i.test(reason)) {
// 设备未配对 → 自动配对
if (this._autoPairAttempts < 1) {
console.log('[ws] 设备未配对,尝试自动配对...')
this._setConnected(false, 'reconnecting', '设备配对中...')
this._autoPairAndReconnect()
return
}
this._setConnected(false, 'error', '设备配对失败,请手动执行 openclaw pairing approve')
return
}
if (/device identity required/i.test(reason) || /device auth/i.test(reason)) {
// 设备认证问题 → 重新配对
if (this._autoPairAttempts < 1) {
console.log('[ws] 设备认证问题,尝试重新配对:', e.reason)
this._setConnected(false, 'reconnecting', '设备认证修复中...')
this._autoPairAndReconnect()
return
}
this._setConnected(false, 'error', `设备认证失败: ${e.reason}`)
return
}
if (/rate.?limit/i.test(reason)) {
// 被限流 → 等待后重试
console.log('[ws] 被限流30秒后重试')
this._setConnected(false, 'reconnecting', '请求过于频繁30秒后重试...')
setTimeout(() => {
if (!this._intentionalClose) this._doConnect()
}, 30000)
return
}
// 其他 1008如 invalid role、protocol mismatch→ 显示错误
console.warn('[ws] 收到 1008 关闭:', e.reason)
this._setConnected(false, 'error', e.reason || '连接被 Gateway 拒绝')
return
}
// ── 其他关闭码 → 普通断线重连 ──
this._setConnected(false)
this._gatewayReady = false
this._handshaking = false
@@ -247,21 +319,90 @@ export class WsClient {
if (!msg.ok || msg.error) {
const errMsg = msg.error?.message || 'Gateway 握手失败'
const errCode = msg.error?.code
console.error('[ws] connect 失败:', errMsg, errCode)
const details = msg.error?.details || {}
const detailCode = details.code || ''
const nextStep = details.recommendedNextStep || ''
console.error('[ws] connect 失败:', { errCode, detailCode, nextStep, errMsg })
// 如果是配对/origin 错误,尝试自动配对(仅一次,防止无限循环
if (errCode === 'NOT_PAIRED' || errCode === 'PAIRING_REQUIRED' || /origin not allowed/i.test(errMsg)) {
if (this._autoPairAttempts < 1) {
console.log('[ws] 检测到配对/origin 错误,尝试自动修复...', errCode || errMsg)
this._autoPairAndReconnect()
// 按 detailCode 精确分流(上游 ConnectErrorDetailCodes
let handled = false
switch (detailCode) {
case 'PAIRING_REQUIRED':
case 'CONTROL_UI_ORIGIN_NOT_ALLOWED':
// 可自动修复:配对 + 写 origins
if (this._autoPairAttempts < 1) {
console.log('[ws] 自动修复:', detailCode)
this._autoPairAndReconnect()
return
}
break
case 'AUTH_TOKEN_MISMATCH':
case 'AUTH_TOKEN_MISSING':
case 'AUTH_TOKEN_NOT_CONFIGURED':
case 'AUTH_PASSWORD_MISMATCH':
case 'AUTH_PASSWORD_MISSING':
case 'AUTH_PASSWORD_NOT_CONFIGURED':
case 'AUTH_DEVICE_TOKEN_MISMATCH':
// 认证凭据问题 → 刷新凭据重试
if (this._authRetryCount < 2) {
this._authRetryCount++
console.log(`[ws] 认证失败 (${detailCode}),刷新凭据 (${this._authRetryCount}/2)`)
this._refreshCredentialsAndReconnect()
return
}
handled = true
break
case 'AUTH_RATE_LIMITED': {
// 被限流 → 等待后重试
const retryMs = msg.error?.retryAfterMs || 30000
console.log(`[ws] 被限流,${Math.round(retryMs / 1000)}秒后重试`)
this._setConnected(false, 'reconnecting', `请求过于频繁,${Math.round(retryMs / 1000)}秒后重试...`)
setTimeout(() => { if (!this._intentionalClose) this._doConnect() }, retryMs)
return
}
console.warn('[ws] 自动修复已尝试过,不再重试')
case 'DEVICE_IDENTITY_REQUIRED':
case 'CONTROL_UI_DEVICE_IDENTITY_REQUIRED':
case 'DEVICE_AUTH_SIGNATURE_INVALID':
case 'DEVICE_AUTH_NONCE_MISMATCH':
case 'DEVICE_AUTH_NONCE_REQUIRED':
case 'DEVICE_AUTH_PUBLIC_KEY_INVALID':
case 'DEVICE_AUTH_INVALID':
// 设备签名/认证问题 → 重新配对
if (this._autoPairAttempts < 1) {
console.log('[ws] 设备认证问题:', detailCode)
this._autoPairAndReconnect()
return
}
break
default:
// 兼容旧版 Gateway不含 details按 errCode / errMsg 分流
if (errCode === 'NOT_PAIRED' || /origin not allowed/i.test(errMsg)) {
if (this._autoPairAttempts < 1) {
console.log('[ws] 检测到配对/origin 错误,尝试自动修复...', errCode || errMsg)
this._autoPairAndReconnect()
return
}
}
if (/unauthorized/i.test(errMsg) && this._authRetryCount < 2) {
this._authRetryCount++
this._refreshCredentialsAndReconnect()
return
}
}
this._setConnected(false, 'error', errMsg)
// 使用 recommendedNextStep 给用户更好的提示
const hints = {
'retry_with_device_token': '设备令牌需要更新,请重启面板',
'update_auth_configuration': '请检查 Gateway 认证配置',
'update_auth_credentials': '请检查 Gateway Token 是否正确',
'wait_then_retry': '请稍后重试',
'review_auth_configuration': '请检查 Gateway 安全配置',
}
const hint = hints[nextStep] || ''
const displayMsg = hint ? `${errMsg}${hint}` : errMsg
this._setConnected(false, 'error', displayMsg)
this._readyCallbacks.forEach(fn => {
try { fn(null, null, { error: true, message: errMsg }) } catch {}
try { fn(null, null, { error: true, message: displayMsg, detailCode, nextStep }) } catch {}
})
return
}
@@ -340,10 +481,37 @@ export class WsClient {
}
}
async _refreshCredentialsAndReconnect() {
try {
// 重新从 openclaw.json 读取最新凭据
const config = await api.readOpenclawConfig()
const newToken = config?.gateway?.auth?.token || ''
const newPassword = config?.gateway?.auth?.password || ''
if ((newToken && newToken !== this._token) || (newPassword && newPassword !== this._password)) {
console.log('[ws] 检测到凭据变更,使用新凭据重连')
this._token = newToken
this._password = newPassword
const base = this._url.split('?')[0]
this._url = `${base}?token=${encodeURIComponent(this._token)}`
}
// 确保配对和 origins
try { await api.autoPairDevice() } catch {}
// 3秒后重连
setTimeout(() => {
if (!this._intentionalClose) {
this._doConnect()
}
}, 3000)
} catch (e) {
console.error('[ws] 刷新凭据失败:', e)
this._setConnected(false, 'error', `凭据刷新失败: ${e}`)
}
}
async _sendConnectFrame(nonce) {
this._handshaking = true
try {
const frame = await api.createConnectFrame(nonce, this._token)
const frame = await api.createConnectFrame(nonce, this._token, this._password)
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
console.log('[ws] 发送 connect frame')
this._ws.send(JSON.stringify(frame))
@@ -356,6 +524,7 @@ export class WsClient {
_handleConnectSuccess(payload) {
this._autoPairAttempts = 0
this._authRetryCount = 0
this._hello = payload || null
this._snapshot = payload?.snapshot || null
this._serverVersion = payload?.serverVersion || null

View File

@@ -31,12 +31,15 @@ import assistant from './modules/assistant.js'
import toast from './modules/toast.js'
import modal from './modules/modal.js'
import engagement from './modules/engagement.js'
import diagnose from './modules/diagnose.js'
import routeMap from './modules/routeMap.js'
import extensions from './modules/extensions.js'
const MODULES = {
common, sidebar, instance, dashboard, services, settings,
models, agents, agentDetail, gateway, security, communication, channels,
memory, dreaming, cron, usage, skills, chat, chatDebug, setup, about,
ext, logs, assistant, toast, modal, engagement,
ext, logs, assistant, toast, modal, engagement, diagnose, routeMap, extensions,
}
/** 构建所有语言字典 { 'zh-CN': { common: {...}, sidebar: {...}, ... }, ... } */

View File

@@ -109,4 +109,9 @@ export default {
startSent: _('已发送启动命令', 'Start command sent', '已發送啟動指令', '起動コマンド送信済み', '시작 명령 전송됨'),
foreignGatewayBanner: _('检测到外部 Gateway 正在运行,当前面板无法管理', 'An external Gateway is running, not managed by this panel', '偵測到外部 Gateway 正在執行,目前面板無法管理', '外部 Gateway が実行中です。このパネルでは管理できません', '외부 Gateway가 실행 중이며, 이 패널에서 관리할 수 없습니다', 'Đã phát hiện Gateway bên ngoài đang chạy, bảng điều khiển này không thể quản lý', 'Se detectó un Gateway externo en ejecución, no gestionado por este panel', 'Um Gateway externo está em execução, não gerenciado por este painel', 'Обнаружен внешний Gateway, не управляемый этой панелью', 'Un Gateway externe est en cours d\'exécution, non géré par ce panneau', 'Ein externer Gateway läuft, der nicht von diesem Panel verwaltet wird'),
claimGateway: _('认领 Gateway', 'Claim Gateway', '認領 Gateway', 'Gateway を引き取る', 'Gateway 인수', 'Nhận Gateway', 'Reclamar Gateway', 'Reivindicar Gateway', 'Принять Gateway', 'Revendiquer Gateway', 'Gateway übernehmen'),
wsConnected: _('已连接', 'Connected', '已連線', '接続済み', '연결됨', 'Đã kết nối', 'Conectado', 'Conectado', 'Подключено', 'Connecté', 'Verbunden'),
wsDisconnected: _('未连接', 'Disconnected', '未連線', '未接続', '연결 안됨', 'Chưa kết nối', 'Desconectado', 'Desconectado', 'Отключено', 'Déconnecté', 'Getrennt'),
wsHandshaking: _('握手中', 'Handshaking', '握手中', 'ハンドシェイク中', '핸드셰이크 중', 'Đang bắt tay', 'Negociando', 'Negociando', 'Рукопожатие', 'Négociation', 'Handshake'),
wsReconnecting: _('重连中', 'Reconnecting', '重連中', '再接続中', '재연결 중', 'Đang kết nối lại', 'Reconectando', 'Reconectando', 'Переподключение', 'Reconnexion', 'Neuverbindung'),
connectedChannels: _('消息渠道', 'Channels', '訊息渠道', 'チャネル', '메시지 채널', 'Kênh nhắn tin', 'Canales', 'Canais', 'Каналы', 'Canaux', 'Kanäle'),
}

View File

@@ -0,0 +1,25 @@
import { _ } from '../helper.js'
export default {
title: _('连接诊断', 'Connection Diagnosis', '連線診斷', '接続診断', '연결 진단', 'Chẩn đoán kết nối', 'Diagnóstico de conexión', 'Diagnóstico de conexão', 'Диагностика подключения', 'Diagnostic de connexion', 'Verbindungsdiagnose'),
desc: _('检测 Gateway 连接各环节是否正常', 'Check Gateway connection health step by step', '檢測 Gateway 連線各環節是否正常', 'Gateway 接続の各ステップを確認', 'Gateway 연결 상태를 단계별로 확인', 'Kiểm tra kết nối Gateway từng bước', 'Verificar estado de conexión Gateway', 'Verificar conexão do Gateway passo a passo', 'Проверка подключения к Gateway пошагово', 'Vérifier la connexion Gateway étape par étape', 'Gateway-Verbindung schrittweise prüfen'),
runDiagnose: _('开始诊断', 'Run Diagnosis', '開始診斷', '診断開始', '진단 시작', 'Bắt đầu chẩn đoán', 'Iniciar diagnóstico', 'Iniciar diagnóstico', 'Запустить диагностику', 'Lancer le diagnostic', 'Diagnose starten'),
running: _('正在诊断...', 'Diagnosing...', '正在診斷...', '診断中...', '진단 중...', 'Đang chẩn đoán...', 'Diagnosticando...', 'Diagnosticando...', 'Диагностика...', 'Diagnostic en cours...', 'Diagnose läuft...'),
passed: _('通过', 'Passed', '通過', '合格', '통과', 'Đạt', 'Aprobado', 'Aprovado', 'Пройден', 'Réussi', 'Bestanden'),
failed: _('未通过', 'Failed', '未通過', '不合格', '실패', 'Thất bại', 'Fallido', 'Falhou', 'Не пройден', 'Échoué', 'Fehlgeschlagen'),
allPassed: _('✅ 所有检查项通过', '✅ All checks passed', '✅ 所有檢查項通過', '✅ すべてのチェックに合格', '✅ 모든 검사 통과', '✅ Tất cả kiểm tra đạt', '✅ Todas las verificaciones pasaron', '✅ Todas as verificações passaram', '✅ Все проверки пройдены', '✅ Tous les contrôles réussis', '✅ Alle Prüfungen bestanden'),
envInfo: _('环境信息', 'Environment Info', '環境資訊', '環境情報', '환경 정보', 'Thông tin môi trường', 'Info del entorno', 'Info do ambiente', 'Информация об окружении', 'Info environnement', 'Umgebungsinfo'),
openclawDir: _('配置目录', 'Config Directory', '配置目錄', '設定ディレクトリ', '설정 디렉토리'),
port: _('端口', 'Port', '連接埠', 'ポート', '포트'),
authMode: _('认证模式', 'Auth Mode', '認證模式', '認証モード', '인증 모드'),
deviceKey: _('设备密钥', 'Device Key', '裝置金鑰', 'デバイスキー', '장치 키'),
errLogExcerpt: _('错误日志摘要', 'Error Log Excerpt', '錯誤日誌摘要', 'エラーログ抜粋', '오류 로그 요약'),
stepConfig: _('配置文件', 'Config File', '配置檔案', '設定ファイル', '설정 파일'),
stepDeviceKey: _('设备密钥', 'Device Key', '裝置金鑰', 'デバイスキー', '장치 키'),
stepOrigins: _('允许来源', 'Allowed Origins', '允許來源', '許可オリジン', '허용 출처'),
stepTcp: _('TCP 端口', 'TCP Port', 'TCP 連接埠', 'TCP ポート', 'TCP 포트'),
stepHttp: _('HTTP 健康', 'HTTP Health', 'HTTP 健康', 'HTTP ヘルス', 'HTTP 상태'),
stepErrLog: _('错误日志', 'Error Log', '錯誤日誌', 'エラーログ', '오류 로그'),
diagnoseFailed: _('诊断执行失败', 'Diagnosis failed', '診斷執行失敗', '診断の実行に失敗', '진단 실행 실패'),
noData: _('尚未运行诊断,点击上方按钮开始', 'No diagnosis yet, click the button above to start', '尚未執行診斷,點擊上方按鈕開始', 'まだ診断を実行していません。上のボタンをクリックして開始してください', '아직 진단을 실행하지 않았습니다. 위 버튼을 클릭하여 시작하세요'),
}

View File

@@ -0,0 +1,36 @@
import { _ } from '../helper.js'
export default {
title: _('扩展中心', 'Extension Center', '擴充中心', '拡張センター', '확장 센터', 'Trung tâm mở rộng', 'Centro de extensiones', 'Centro de extensões', 'Центр расширений', 'Centre d\'extensions', 'Erweiterungszentrum'),
subtitle: _('管理已安装的扩展插件,查看状态与配置', 'Manage installed extensions, view status and configuration', '管理已安裝的擴充插件,查看狀態與設定', 'インストール済み拡張の管理', '설치된 확장 관리', 'Quản lý tiện ích mở rộng', 'Administrar extensiones instaladas', 'Gerenciar extensões instaladas', 'Управление расширениями', 'Gérer les extensions installées', 'Installierte Erweiterungen verwalten'),
refresh: _('刷新', 'Refresh', '重新整理', '更新', '새로고침', 'Làm mới', 'Actualizar', 'Atualizar', 'Обновить', 'Actualiser', 'Aktualisieren'),
loading: _('正在加载扩展列表…', 'Loading extensions…', '正在載入擴充列表…', '拡張を読み込み中…', '확장 로딩 중…', 'Đang tải…', 'Cargando…', 'Carregando…', 'Загрузка…', 'Chargement…', 'Laden…'),
noPlugins: _('暂无已安装的扩展', 'No extensions installed', '暫無已安裝的擴充', 'インストール済み拡張なし', '설치된 확장 없음', 'Chưa có tiện ích', 'Sin extensiones instaladas', 'Sem extensões instaladas', 'Нет расширений', 'Aucune extension', 'Keine Erweiterungen'),
installed: _('已安装', 'Installed', '已安裝', 'インストール済み', '설치됨', 'Đã cài đặt', 'Instalado', 'Instalado', 'Установлено', 'Installé', 'Installiert'),
notInstalled: _('未安装', 'Not Installed', '未安裝', '未インストール', '미설치', 'Chưa cài đặt', 'No instalado', 'Não instalado', 'Не установлено', 'Non installé', 'Nicht installiert'),
enabled: _('已启用', 'Enabled', '已啟用', '有効', '활성화', 'Đã bật', 'Habilitado', 'Habilitado', 'Включено', 'Activé', 'Aktiviert'),
disabled: _('已禁用', 'Disabled', '已停用', '無効', '비활성화', 'Đã tắt', 'Deshabilitado', 'Desabilitado', 'Отключено', 'Désactivé', 'Deaktiviert'),
builtin: _('内置', 'Built-in', '內建', 'ビルトイン', '내장', 'Tích hợp', 'Integrado', 'Integrado', 'Встроенное', 'Intégré', 'Eingebaut'),
version: _('版本', 'Version', '版本', 'バージョン', '버전', 'Phiên bản', 'Versión', 'Versão', 'Версия', 'Version', 'Version'),
noDescription: _('暂无描述', 'No description', '暫無描述', '説明なし', '설명 없음', 'Không có mô tả', 'Sin descripción', 'Sem descrição', 'Нет описания', 'Aucune description', 'Keine Beschreibung'),
statsInstalled: _('已安装', 'Installed', '已安裝', 'インストール済み', '설치됨', 'Đã cài đặt', 'Instaladas', 'Instaladas', 'Установлено', 'Installées', 'Installiert'),
statsEnabled: _('已启用', 'Enabled', '已啟用', '有効', '활성화', 'Đã bật', 'Habilitadas', 'Habilitadas', 'Включено', 'Activées', 'Aktiviert'),
statsBuiltin: _('内置', 'Built-in', '內建', 'ビルトイン', '내장', 'Tích hợp', 'Integradas', 'Integradas', 'Встроенных', 'Intégrées', 'Eingebaut'),
configTitle: _('扩展配置', 'Extension Config', '擴充設定', '拡張設定', '확장 설정', 'Cấu hình', 'Configuración', 'Configuração', 'Конфигурация', 'Configuration', 'Konfiguration'),
goToChannels: _('前往渠道配置', 'Go to Channels', '前往頻道設定', 'チャネル設定へ', '채널 설정으로 이동', 'Đi đến kênh', 'Ir a canales', 'Ir para canais', 'К каналам', 'Aller aux canaux', 'Zu Kanälen'),
enable: _('启用', 'Enable', '啟用', '有効化', '활성화', 'Bật', 'Habilitar', 'Habilitar', 'Включить', 'Activer', 'Aktivieren'),
disable: _('禁用', 'Disable', '停用', '無効化', '비활성화', 'Tắt', 'Deshabilitar', 'Desabilitar', 'Отключить', 'Désactiver', 'Deaktivieren'),
toggleSuccess: _('插件状态已更新', 'Plugin status updated', '插件狀態已更新', 'プラグイン状態が更新されました', '플러그인 상태 업데이트됨', 'Đã cập nhật trạng thái', 'Estado del plugin actualizado', 'Status do plugin atualizado', 'Статус плагина обновлен', 'État du plugin mis à jour', 'Plugin-Status aktualisiert'),
toggleFailed: _('操作失败', 'Operation failed', '操作失敗', '操作失敗', '작업 실㊌', 'Thất bại', 'Operación fallida', 'Operação falhou', 'Операция не удалась', 'Échec de l\'opération', 'Vorgang fehlgeschlagen'),
installTitle: _('安装新插件', 'Install Plugin', '安裝新插件', '新しいプラグインをインストール', '새 플러그인 설치', 'Cài đặt plugin mới', 'Instalar nuevo plugin', 'Instalar novo plugin', 'Установить плагин', 'Installer un plugin', 'Plugin installieren'),
installPlaceholder: _('输入 npm 包名,如 @openclaw/feishu', 'Enter npm package name, e.g. @openclaw/feishu', '輸入 npm 包名,如 @openclaw/feishu'),
installBtn: _('安装', 'Install', '安裝', 'インストール', '설치', 'Cài đặt', 'Instalar', 'Instalar', 'Установить', 'Installer', 'Installieren'),
installing: _('安装中...', 'Installing...', '安裝中...', 'インストール中...', '설치 중...', 'Đang cài đặt...', 'Instalando...', 'Instalando...', 'Установка...', 'Installation...', 'Installation...'),
installSuccess: _('插件安装成功', 'Plugin installed successfully', '插件安裝成功', 'プラグインがインストールされました', '플러그인 설치 완료', 'Cài đặt thành công', 'Plugin instalado correctamente', 'Plugin instalado com sucesso', 'Плагин установлен', 'Plugin installé', 'Plugin installiert'),
installFailed: _('插件安装失败', 'Plugin installation failed', '插件安裝失敗', 'インストール失敗', '설치 실패', 'Cài đặt thất bại', 'Instalación fallida', 'Instalação falhou', 'Ошибка установки', 'Échec de l\'installation', 'Installation fehlgeschlagen'),
restartHint: _('插件状态变更后,建议重启 Gateway 生效', 'After changing plugin status, restart Gateway to take effect', '插件狀態變更後,建議重啟 Gateway 生效'),
searchPlaceholder: _('搜索插件...', 'Search plugins...', '搜尋插件...', 'プラグインを検索...', '플러그인 검색...', 'Tìm plugin...', 'Buscar plugins...', 'Buscar plugins...', 'Поиск плагинов...', 'Rechercher des plugins...', 'Plugins suchen...'),
noSearchResults: _('未找到匹配「{query}」的插件', 'No plugins matching "{query}"', '未找到匹配「{query}」的插件', '「{query}」に一致するプラグインなし', '"{query}"와 일치하는 플러그인 없음', 'Không tìm thấy plugin "{query}"', 'No se encontraron plugins "{query}"', 'Nenhum plugin encontrado "{query}"', 'Плагины по запросу «{query}» не найдены', 'Aucun plugin correspondant à « {query} »', 'Keine Plugins für „{query}" gefunden'),
showDetail: _('查看详情', 'Show details', '查看詳情', '詳細を表示', '상세 보기', 'Xem chi tiết', 'Ver detalles', 'Ver detalhes', 'Подробнее', 'Voir les détails', 'Details anzeigen'),
hideDetail: _('收起', 'Hide', '收起', '閉じる', '닫기', 'Ẩn', 'Ocultar', 'Ocultar', 'Скрыть', 'Masquer', 'Ausblenden'),
}

View File

@@ -0,0 +1,26 @@
import { _ } from '../helper.js'
export default {
title: _('路由地图', 'Route Map', '路由地圖', 'ルートマップ', '라우트 맵', 'Bản đồ tuyến', 'Mapa de rutas', 'Mapa de rotas', 'Карта маршрутов', 'Carte des routes', 'Routenkarte'),
subtitle: _('可视化查看渠道 → 绑定 → 智能体的全局路由关系', 'Visualize Channel → Binding → Agent routing relationships', '可視化查看頻道 → 綁定 → 智能體的路由關係', 'チャンネル → バインディング → エージェントのルーティング関係を視覚化', '채널 → 바인딩 → 에이전트 라우팅 관계 시각화', 'Trực quan hóa kênh → ràng buộc → tác nhân', 'Visualizar relaciones de enrutamiento', 'Visualizar relações de roteamento', 'Визуализация маршрутов', 'Visualiser les relations de routage', 'Routing-Beziehungen visualisieren'),
refresh: _('刷新', 'Refresh', '重新整理', '更新', '새로고침', 'Làm mới', 'Actualizar', 'Atualizar', 'Обновить', 'Actualiser', 'Aktualisieren'),
loading: _('正在加载路由数据…', 'Loading route data…', '正在載入路由資料…', 'ルートデータ読み込み中…', '라우트 데이터 로딩 중…', 'Đang tải…', 'Cargando…', 'Carregando…', 'Загрузка…', 'Chargement…', 'Laden…'),
noData: _('暂无路由数据,请先配置 Agent 和消息渠道', 'No route data. Please configure Agents and Channels first.', '暫無路由資料,請先設定 Agent 和訊息頻道', 'ルートデータがありません', '라우트 데이터 없음', 'Chưa có dữ liệu', 'Sin datos', 'Sem dados', 'Нет данных', 'Aucune donnée', 'Keine Daten'),
channels: _('消息渠道', 'Channels', '訊息頻道', 'チャンネル', '채널', 'Kênh', 'Canales', 'Canais', 'Каналы', 'Canaux', 'Kanäle'),
agents: _('智能体', 'Agents', '智能體', 'エージェント', '에이전트', 'Tác nhân', 'Agentes', 'Agentes', 'Агенты', 'Agents', 'Agenten'),
bindings: _('路由规则', 'Bindings', '路由規則', 'バインディング', '바인딩', 'Ràng buộc', 'Vínculos', 'Vínculos', 'Привязки', 'Liaisons', 'Bindungen'),
defaultAgent: _('默认 Agent', 'Default Agent', '預設 Agent', 'デフォルト Agent', '기본 Agent', 'Agent mặc định', 'Agente predeterminado', 'Agente padrão', 'Агент по умолчанию', 'Agent par défaut', 'Standard-Agent'),
enabled: _('已启用', 'Enabled', '已啟用', '有効', '활성화됨', 'Đã bật', 'Habilitado', 'Habilitado', 'Включено', 'Activé', 'Aktiviert'),
disabled: _('已禁用', 'Disabled', '已停用', '無効', '비활성화됨', 'Đã tắt', 'Deshabilitado', 'Desabilitado', 'Отключено', 'Désactivé', 'Deaktiviert'),
matchAll: _('匹配所有', 'Match All', '匹配所有', 'すべて一致', '모두 일치', 'Khớp tất cả', 'Coincidir todo', 'Corresponder tudo', 'Все совпадения', 'Tout', 'Alle'),
peer: _('指定对象', 'Specific Peer', '指定對象', '指定ピア', '특정 피어', 'Đối tượng', 'Par específico', 'Par específico', 'Конкретный пир', 'Pair spécifique', 'Spezifischer Peer'),
account: _('账号', 'Account', '帳號', 'アカウント', '계정', 'Tài khoản', 'Cuenta', 'Conta', 'Аккаунт', 'Compte', 'Konto'),
statsAgents: _('智能体', 'Agents', '智能體', 'エージェント', '에이전트', 'Tác nhân', 'Agentes', 'Agentes', 'Агенты', 'Agents', 'Agenten'),
statsChannels: _('渠道', 'Channels', '頻道', 'チャンネル', '채널', 'Kênh', 'Canales', 'Canais', 'Каналы', 'Canaux', 'Kanäle'),
statsBindings: _('绑定', 'Bindings', '綁定', 'バインディング', '바인딩', 'Ràng buộc', 'Vínculos', 'Vínculos', 'Привязки', 'Liaisons', 'Bindungen'),
subAgentRelations: _('子代理关系', 'Sub-agent Relations', '子代理關係', 'サブエージェント関係', '서브 에이전트 관계', 'Quan hệ phụ', 'Relaciones de sub-agente', 'Relações de sub-agente', 'Связи субагентов', 'Relations sous-agent', 'Sub-Agent-Beziehungen'),
subAgentCall: _('子代理调用', 'Sub-agent Call', '子代理調用', 'サブエージェント呼出', '서브 에이전트 호출', 'Gọi phụ', 'Llamada sub-agente', 'Chamada sub-agente', 'Вызов субагента', 'Appel sous-agent', 'Sub-Agent-Aufruf'),
legendBinding: _('显式绑定', 'Explicit Binding', '顯式綁定', '明示的バインド', '명시적 바인딩', 'Ràng buộc rõ ràng', 'Vinculación explícita', 'Vinculação explícita', 'Явная привязка', 'Liaison explicite', 'Explizite Bindung'),
legendDefault: _('默认路由', 'Default Route', '預設路由', 'デフォルトルート', '기본 라우트', 'Tuyến mặc định', 'Ruta predeterminada', 'Rota padrão', 'Маршрут по умолчанию', 'Route par défaut', 'Standardroute'),
clickToNavigate: _('点击卡片跳转到对应配置页', 'Click card to navigate to config page', '點擊卡片跳轉到對應設定頁', 'カードをクリックして設定ページへ', '카드를 클릭하여 설정 페이지로 이동', 'Nhấp để chuyển đến trang cấu hình', 'Haga clic para ir a la configuración', 'Clique para ir à configuração', 'Нажмите для перехода', 'Cliquez pour accéder', 'Klicken Sie zur Konfiguration'),
}

View File

@@ -25,8 +25,12 @@ export default {
cron: _('定时任务', 'Cron Jobs', '定時任務', 'スケジュールタスク', '예약 작업', 'Tác vụ định kỳ', 'Tareas', 'Tarefas', 'Планировщик', 'Tâches planifiées', 'Geplante Aufgaben'),
usage: _('使用情况', 'Usage', '使用情況', '使用状況', '사용 현황', 'Sử dụng', 'Uso', 'Uso', 'Использование', 'Utilisation', 'Nutzung'),
skills: _('Skills', 'Skills'),
pluginHub: _('插件中心', 'Plugin Hub', '插件中心', 'プラグインハブ', '플러그인 허브', 'Trung tâm plugin', 'Centro de plugins', 'Centro de plugins', 'Центр плагинов', 'Centre de plugins', 'Plugin-Hub'),
settings: _('面板设置', 'Settings', '面板設定', 'パネル設定', '패널 설정', 'Cài đặt', 'Configuración', 'Configurações', 'Настройки', 'Paramètres', 'Einstellungen'),
diagnose: _('连接诊断', 'Connection Diagnosis', '連線診斷', '接続診断', '연결 진단', 'Chẩn đoán kết nối', 'Diagnóstico de conexión', 'Diagnóstico de conexão', 'Диагностика подключения', 'Diagnostic de connexion', 'Verbindungsdiagnose'),
chatDebug: _('系统诊断', 'Diagnostics', '系統诊斷', 'システム診断', '시스템 진단', 'Chẩn đoán', 'Diagnóstico', 'Diagnóstico', 'Диагностика', 'Diagnostic', 'Diagnose'),
checkRepair: _('检测与修复', 'Check & Repair', '檢測與修復', '検出と修復', '검사 및 수리', 'Kiểm tra & Sửa chữa', 'Verificar y reparar', 'Verificar e reparar', 'Проверка и ремонт', 'Vérifier et réparer', 'Prüfen & Reparieren'),
routeMap: _('路由地图', 'Route Map', '路由地圖', 'ルートマップ', '라우트 맵', 'Bản đồ tuyến', 'Mapa de rutas', 'Mapa de rotas', 'Карта маршрутов', 'Carte des routes', 'Routenkarte'),
about: _('关于', 'About', '關於', 'について', '정보', 'Giới thiệu', 'Acerca de', 'Sobre', 'О программе', 'À propos', 'Über'),
setup: _('初始设置', 'Setup', '初始設定', '初期設定', '초기 설정', 'Thiết lập', 'Configuración inicial', 'Configuração inicial', 'Начальная настройка', 'Configuration initiale', 'Ersteinrichtung'),
}

View File

@@ -2,8 +2,8 @@
* ClawPanel 入口
*/
// 模块已加载,取消 splash 超时回退(防止假阳性的 "页面加载失败" 提示
if (window._splashTimer) { clearTimeout(window._splashTimer); window._splashTimer = null }
// 标记 JS 模块已加载(供 index.html 多阶段启动检测使用
window._jsLoaded = true
import { registerRoute, initRouter, navigate, setDefaultRoute } from './router.js'
import { renderSidebar, openMobileSidebar } from './components/sidebar.js'
@@ -17,6 +17,7 @@ import { isForeignGatewayError, showGatewayConflictGuidance } from './lib/gatewa
import { tryShowEngagement } from './components/engagement.js'
import { toast } from './components/toast.js'
import { initI18n, t } from './lib/i18n.js'
import { initFeatureGates } from './lib/feature-gates.js'
// 样式
import './style/variables.css'
@@ -331,6 +332,9 @@ async function boot() {
registerRoute('/usage', () => import('./pages/usage.js'))
registerRoute('/communication', () => import('./pages/communication.js'))
registerRoute('/settings', () => import('./pages/settings.js'))
registerRoute('/route-map', () => import('./pages/route-map.js'))
registerRoute('/plugin-hub', () => import('./pages/plugin-hub.js'))
registerRoute('/diagnose', () => import('./pages/chat-debug.js'))
renderSidebar(sidebar)
initRouter(content)
@@ -382,8 +386,8 @@ async function boot() {
}).catch(() => {})
: Promise.resolve()
ensureWebSession.then(() => loadActiveInstance()).then(() => detectOpenclawStatus()).then(() => {
// 重新渲染侧边栏(检测完成后 isOpenclawReady 状态已更新)
ensureWebSession.then(() => loadActiveInstance()).then(() => detectOpenclawStatus()).then(() => initFeatureGates().catch(() => {})).then(() => {
// 重新渲染侧边栏(检测完成后 isOpenclawReady + 功能门控状态已更新)
renderSidebar(sidebar)
if (!isOpenclawReady()) {
setDefaultRoute('/setup')
@@ -471,6 +475,8 @@ async function autoConnectWebSocket() {
const port = config?.gateway?.port || 18789
const rawToken = config?.gateway?.auth?.token
const token = (typeof rawToken === 'string') ? rawToken : ''
const rawPassword = config?.gateway?.auth?.password
const password = (typeof rawPassword === 'string') ? rawPassword : ''
// 启动前先确保设备已配对 + allowedOrigins 已写入,无需用户手动操作
let needReload = false
@@ -508,6 +514,23 @@ async function autoConnectWebSocket() {
}
}
// TCP 端口就绪探测:等待 Gateway 端口可达后再发起 WS 连接(仅 Tauri 桌面端)
if (isTauriRuntime()) {
const probeStart = Date.now()
const probeTimeout = 20000
let portReady = false
while (Date.now() - probeStart < probeTimeout) {
try {
portReady = await api.probeGatewayPort()
if (portReady) break
} catch {}
await new Promise(r => setTimeout(r, 2000))
}
if (!portReady) {
console.warn(`[main] Gateway 端口 ${port}${probeTimeout / 1000}s 内未就绪,仍尝试连接`)
}
}
let host
const inst2 = getActiveInstance()
if (inst2.type !== 'local' && inst2.endpoint) {
@@ -520,8 +543,8 @@ async function autoConnectWebSocket() {
} else {
host = isTauriRuntime() ? `127.0.0.1:${port}` : location.host
}
wsClient.connect(host, token)
console.log(`[main] WebSocket 连接已启动 -> ${host}`)
wsClient.connect(host, token, { password })
console.log(`[main] WebSocket 连接已启动 -> ${host}${password ? ' (password mode)' : ''}`)
} catch (e) {
console.error('[main] 自动连接 WebSocket 失败:', e)
}
@@ -795,7 +818,9 @@ function startUpdateChecker() {
if (!auth.ok) await showLoginOverlay(auth.defaultPw)
try {
await boot()
window._bootDone = true
} catch (bootErr) {
window._bootDone = true
console.error('[main] boot() 失败:', bootErr)
_hideSplash()
const app = document.getElementById('app')

View File

@@ -7,6 +7,7 @@ import { getActiveInstance, onGatewayChange } from '../lib/app-state.js'
import { isForeignGatewayError, isForeignGatewayService, maybeShowForeignGatewayBindingPrompt, showGatewayConflictGuidance } from '../lib/gateway-ownership.js'
import { navigate } from '../router.js'
import { t } from '../lib/i18n.js'
import { wsClient } from '../lib/ws-client.js'
let _unsubGw = null
let _loadInFlight = false
@@ -136,7 +137,8 @@ async function _loadDashboardDataInner(page, fullRefresh) {
api.listAgents(),
api.readMcpConfig(),
api.listBackups(),
]), 15000).catch(() => [{ status: 'rejected' }, { status: 'rejected' }, { status: 'rejected' }])
api.listConfiguredPlatforms().catch(() => []),
]), 15000).catch(() => [{ status: 'rejected' }, { status: 'rejected' }, { status: 'rejected' }, { status: 'rejected' }])
const logsP = api.readLogTail('gateway', 20).catch(() => '')
// 第一波:服务状态 + 配置 + 版本 → 立即渲染统计卡片
@@ -189,10 +191,11 @@ async function _loadDashboardDataInner(page, fullRefresh) {
}
// 第二波Agent、MCP、备份 → 更新卡片 + 渲染总览
const [agentsRes, mcpRes, backupsRes] = await secondaryP
const [agentsRes, mcpRes, backupsRes, channelsRes] = await secondaryP
const agents = agentsRes.status === 'fulfilled' ? agentsRes.value : []
const mcpConfig = mcpRes.status === 'fulfilled' ? mcpRes.value : null
const backups = backupsRes.status === 'fulfilled' ? backupsRes.value : []
const channels = channelsRes.status === 'fulfilled' ? (channelsRes.value || []) : []
let statusSummary = null
if (shouldLoadStatusSummary) {
try {
@@ -206,7 +209,7 @@ async function _loadDashboardDataInner(page, fullRefresh) {
}
renderStatCards(page, services, version, agents, config, panelConfig)
renderOverview(page, services, mcpConfig, backups, config, agents, statusSummary)
renderOverview(page, services, mcpConfig, backups, config, agents, statusSummary, channels)
// 第三波:日志(最低优先级)
const logs = await logsP
@@ -310,7 +313,7 @@ function renderStatCards(page, services, version, agents, config, panelConfig) {
`
}
function renderOverview(page, services, mcpConfig, backups, config, agents, statusSummary) {
function renderOverview(page, services, mcpConfig, backups, config, agents, statusSummary, channels) {
const containerEl = page.querySelector('#dashboard-overview-container')
const gw = services.find(s => s.label === 'ai.openclaw.gateway')
const foreignGateway = isForeignGatewayService(gw)
@@ -414,6 +417,8 @@ function renderOverview(page, services, mcpConfig, backups, config, agents, stat
</div>
</div>
</div>
${renderWsStatus()}
${renderChannelsOverview(channels)}
${renderSessionStatus(sessions)}
</div>
`
@@ -459,6 +464,82 @@ function renderSessionStatus(sessions) {
</div>`
}
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 `
<div class="config-section" style="margin-top:16px">
<div class="config-section-title" style="display:flex;align-items:center;gap:8px">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${statusColor}"></span>
WebSocket ${statusLabel}
${statusDetail ? `<span style="font-weight:normal;color:var(--text-tertiary);font-size:var(--font-size-xs)">${escapeHtml(statusDetail)}</span>` : ''}
</div>
</div>`
}
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 `<span style="display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border-radius:20px;background:var(--bg-secondary);font-size:var(--font-size-xs);white-space:nowrap">
<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:${dot}"></span>
${icon} ${escapeHtml(name)}
</span>`
})
return `
<div class="config-section" style="margin-top:12px">
<div class="config-section-title">${t('dashboard.connectedChannels')} <span style="font-weight:normal;color:var(--text-tertiary);font-size:var(--font-size-xs)">${channels.length}</span></div>
<div style="display:flex;flex-wrap:wrap;gap:8px">${items.join('')}</div>
</div>`
}
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 => `<div class="log-line">${escapeHtml(l)}</div>`).join('')
logsEl.innerHTML = lines.map(l => {
const parsed = parseLogLine(l)
if (!parsed.level) return `<div class="log-line">${escapeHtml(l)}</div>`
const badge = `<span style="display:inline-block;padding:1px 6px;border-radius:4px;font-size:10px;font-weight:600;letter-spacing:0.5px;${LOG_LEVEL_STYLE[parsed.level] || ''}">${parsed.level}</span>`
const time = parsed.time ? `<span style="color:var(--text-tertiary);font-size:11px;opacity:0.7;margin-right:4px">${escapeHtml(parsed.time)}</span>` : ''
return `<div class="log-line" style="display:flex;align-items:center;gap:6px">${time}${badge}<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis">${escapeHtml(parsed.msg)}</span></div>`
}).join('')
logsEl.scrollTop = logsEl.scrollHeight
}

116
src/pages/diagnose.js Normal file
View File

@@ -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 = `
<div class="page-header">
<h1 class="page-title">${t('diagnose.title')}</h1>
<p class="page-desc">${t('diagnose.desc')}</p>
</div>
<div style="margin-bottom:16px">
<button class="btn btn-primary" id="btn-diagnose">${t('diagnose.runDiagnose')}</button>
</div>
<div id="diagnose-summary" style="margin-bottom:16px"></div>
<div id="diagnose-steps" class="card-grid" style="margin-bottom:24px">
<div class="empty-state" style="padding:32px;text-align:center;color:var(--text-tertiary)">${t('diagnose.noData')}</div>
</div>
<div id="diagnose-env" style="display:none">
<h3 style="margin-bottom:12px">${t('diagnose.envInfo')}</h3>
<div class="stat-card" id="env-content" style="font-size:var(--font-size-sm);overflow-x:auto"></div>
</div>
`
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 = '<div class="stat-card loading-placeholder" style="height:40px;margin:8px 0"></div>'.repeat(6)
try {
const result = await api.diagnoseGatewayConnection()
renderResult(page, result)
} catch (e) {
toast.error(`${t('diagnose.diagnoseFailed')}: ${e}`)
page.querySelector('#diagnose-steps').innerHTML = `<div class="empty-state" style="padding:32px;color:var(--text-error)">${t('diagnose.diagnoseFailed')}: ${e}</div>`
} 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 = `<div class="stat-card" style="background:var(--success-bg,#f0fdf4);border:1px solid var(--success-border,#86efac);padding:12px 16px">${t('diagnose.allPassed')}</div>`
} else {
summaryEl.innerHTML = `<div class="stat-card" style="background:var(--error-bg,#fef2f2);border:1px solid var(--error-border,#fca5a5);padding:12px 16px">⚠️ ${result.summary}</div>`
}
// 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 `
<div class="stat-card" style="background:${bgColor};padding:12px 16px;margin-bottom:8px">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
<div style="display:flex;align-items:center;gap:8px;min-width:0">
<span>${icon}</span>
<strong style="white-space:nowrap">${label}</strong>
</div>
<span style="font-size:var(--font-size-xs);color:var(--text-tertiary);white-space:nowrap">${step.durationMs}ms</span>
</div>
<div style="margin-top:6px;font-size:var(--font-size-sm);color:var(--text-secondary);word-break:break-all">${escHtml(step.message)}</div>
</div>`
}).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 = '<table style="width:100%;border-collapse:collapse">'
for (const [k, v] of rows) {
html += `<tr><td style="padding:4px 12px 4px 0;font-weight:600;white-space:nowrap;color:var(--text-secondary)">${k}</td><td style="padding:4px 0;word-break:break-all">${escHtml(String(v))}</td></tr>`
}
html += '</table>'
if (env.errLogExcerpt) {
html += `<details style="margin-top:12px"><summary style="cursor:pointer;font-weight:600;color:var(--text-secondary)">${t('diagnose.errLogExcerpt')}</summary><pre style="margin-top:8px;font-size:12px;max-height:200px;overflow:auto;background:var(--bg-tertiary,#1e1e1e);color:var(--text-primary);padding:8px;border-radius:6px">${escHtml(env.errLogExcerpt)}</pre></details>`
}
page.querySelector('#env-content').innerHTML = html
}
function escHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}

233
src/pages/plugin-hub.js Normal file
View File

@@ -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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;') }
export async function render() {
const page = document.createElement('div')
page.className = 'page'
_searchQuery = ''
page.innerHTML = `
<div class="page-header">
<h1 class="page-title">${t('extensions.title')}</h1>
<div class="page-actions" style="display:flex;align-items:center;gap:var(--space-sm)">
<button class="btn btn-sm btn-secondary" id="ph-refresh">${t('extensions.refresh')}</button>
<button class="btn btn-sm btn-secondary" id="ph-go-channels">${t('extensions.goToChannels')}</button>
</div>
</div>
<p class="form-hint" style="margin-bottom:var(--space-md)">${t('extensions.subtitle')}</p>
<div id="ph-stats" class="route-map-stats"></div>
<div style="display:flex;gap:10px;margin-bottom:var(--space-md);flex-wrap:wrap">
<div style="flex:1;min-width:200px;position:relative">
<input type="text" class="form-input" id="ph-search" placeholder="${t('extensions.searchPlaceholder')}" style="width:100%;padding-left:32px">
<svg style="position:absolute;left:10px;top:50%;transform:translateY(-50%);width:14px;height:14px;color:var(--text-tertiary)" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
</div>
<div style="display:flex;gap:6px;align-items:center">
<input type="text" class="form-input" id="ph-pkg-input" placeholder="${t('extensions.installPlaceholder')}" style="width:220px">
<button class="btn btn-primary btn-sm" id="ph-install-btn" style="white-space:nowrap">${t('extensions.installBtn')}</button>
</div>
</div>
<div id="ph-install-msg" style="display:none;margin-bottom:var(--space-md)"></div>
<div id="ph-list">
<div class="stat-card loading-placeholder" style="height:200px"></div>
</div>
`
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 = `<div style="padding:10px 14px;border-radius:8px;background:var(--bg-secondary);color:var(--text-tertiary);font-size:13px">${t('extensions.installing')}</div>`
try {
const result = await api.installPlugin(pkg)
const output = result.output ? esc(result.output).substring(0, 120) : ''
msgEl.innerHTML = `<div style="padding:10px 14px;border-radius:8px;background:var(--success-bg,#f0fdf4);border:1px solid var(--success-border,#86efac);color:var(--success);font-size:13px">
${t('extensions.installSuccess')}${output ? ' — ' + output : ''}
</div>`
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 = `<div style="padding:10px 14px;border-radius:8px;background:var(--error-bg,#fef2f2);border:1px solid var(--error-border,#fca5a5);font-size:13px">
<div style="display:flex;align-items:center;gap:8px;color:var(--error)">
<span>❌ ${t('extensions.installFailed')}: ${esc(short)}</span>
${hasDetail ? `<button id="ph-install-msg-toggle" style="background:none;border:none;color:var(--accent);cursor:pointer;font-size:12px;white-space:nowrap;padding:0">${t('extensions.showDetail')}</button>` : ''}
</div>
${hasDetail ? `<pre id="ph-install-msg-detail" style="display:none;margin-top:8px;font-size:11px;max-height:200px;overflow:auto;white-space:pre-wrap;word-break:break-all;color:var(--text-secondary);background:var(--bg-secondary);padding:8px;border-radius:6px">${esc(errStr)}</pre>` : ''}
</div>`
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 = `<div class="stat-card loading-placeholder" style="height:200px;display:flex;align-items:center;justify-content:center;color:var(--text-tertiary)">${t('extensions.loading')}</div>`
try {
const result = await api.listAllPlugins()
_allPlugins = result?.plugins || []
if (_allPlugins.length === 0) {
statsEl.innerHTML = ''
listEl.innerHTML = `<div class="stat-card" style="padding:var(--space-xl);text-align:center;color:var(--text-tertiary)">${t('extensions.noPlugins')}</div>`
return
}
const enabled = _allPlugins.filter(p => p.enabled).length
const builtin = _allPlugins.filter(p => p.builtin).length
statsEl.innerHTML = `
<div class="route-map-stat"><span class="route-map-stat-num">${_allPlugins.length}</span><span class="route-map-stat-label">${t('extensions.statsInstalled')}</span></div>
<div class="route-map-stat"><span class="route-map-stat-num">${enabled}</span><span class="route-map-stat-label">${t('extensions.statsEnabled')}</span></div>
${builtin ? `<div class="route-map-stat"><span class="route-map-stat-num">${builtin}</span><span class="route-map-stat-label">${t('extensions.statsBuiltin')}</span></div>` : ''}
`
renderPluginList(page)
} catch (e) {
listEl.innerHTML = `<div class="stat-card" style="padding:var(--space-lg);color:var(--error)">${esc(e.message || e)}</div>`
}
}
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 = `<div class="stat-card" style="padding:var(--space-lg);text-align:center;color:var(--text-tertiary)">
${t('extensions.noSearchResults', { query: esc(_searchQuery) })}
</div>`
return
}
listEl.innerHTML = `<div class="plugin-grid">${filtered.map(p => renderPluginCard(p)).join('')}</div>
<div class="form-hint" style="margin-top:var(--space-md);font-size:var(--font-size-xs)">${t('extensions.restartHint')}</div>`
}
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(`<span class="plugin-badge plugin-badge-builtin">${t('extensions.builtin')}</span>`)
if (p.version) badges.push(`<span class="plugin-badge plugin-badge-version">${t('extensions.version')} ${esc(p.version)}</span>`)
// Toggle button: installed plugins can be enabled/disabled
let toggleBtn = ''
if (p.installed) {
if (p.enabled) {
toggleBtn = `<button class="btn btn-sm btn-secondary" data-toggle-plugin="${esc(p.id)}" data-toggle-to="false">${t('extensions.disable')}</button>`
} else {
toggleBtn = `<button class="btn btn-sm btn-primary" data-toggle-plugin="${esc(p.id)}" data-toggle-to="true">${t('extensions.enable')}</button>`
}
}
return `
<div class="plugin-card ${p.enabled ? '' : 'plugin-card-inactive'}">
<div class="plugin-card-header">
<span class="plugin-card-icon">${icon}</span>
<div class="plugin-card-title">
<span class="plugin-card-name">${esc(p.id)}</span>
<div class="plugin-card-badges">${badges.join('')}</div>
</div>
<span class="plugin-status-dot ${statusClass}" title="${statusText}"></span>
</div>
<div class="plugin-card-desc">${esc(p.description) || t('extensions.noDescription')}</div>
<div class="plugin-card-footer">
<span class="plugin-card-status">${statusText}</span>
${toggleBtn}
</div>
</div>
`
}

277
src/pages/route-map.js Normal file
View File

@@ -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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;') }
export async function render() {
const page = document.createElement('div')
page.className = 'page'
page.innerHTML = `
<div class="page-header">
<h1 class="page-title">${t('routeMap.title')}</h1>
<div class="page-actions" style="display:flex;align-items:center;gap:var(--space-sm)">
<span style="font-size:var(--font-size-xs);color:var(--text-tertiary)">${t('routeMap.clickToNavigate')}</span>
<button class="btn btn-sm btn-secondary" id="rm-refresh">${t('routeMap.refresh')}</button>
</div>
</div>
<p class="form-hint" style="margin-bottom:var(--space-md)">${t('routeMap.subtitle')}</p>
<div id="rm-stats" class="route-map-stats"></div>
<div id="rm-canvas" class="route-map-canvas">
<div class="stat-card loading-placeholder" style="height:300px"></div>
</div>
`
async function loadAndRender() {
const canvas = page.querySelector('#rm-canvas')
const statsEl = page.querySelector('#rm-stats')
canvas.innerHTML = '<div class="stat-card loading-placeholder" style="height:300px;display:flex;align-items:center;justify-content:center;color:var(--text-tertiary)">' + t('routeMap.loading') + '</div>'
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 = `<div class="stat-card" style="padding:var(--space-xl);text-align:center;color:var(--text-tertiary)">${t('routeMap.noData')}</div>`
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 = `
<div class="route-map-stat"><span class="route-map-stat-num">${agents.length}</span><span class="route-map-stat-label">${t('routeMap.statsAgents')}</span></div>
<div class="route-map-stat"><span class="route-map-stat-num">${platforms.length}</span><span class="route-map-stat-label">${t('routeMap.statsChannels')}</span></div>
<div class="route-map-stat"><span class="route-map-stat-num">${bindings.length}</span><span class="route-map-stat-label">${t('routeMap.statsBindings')}</span></div>
${subAgentCount ? `<div class="route-map-stat"><span class="route-map-stat-num">${subAgentCount}</span><span class="route-map-stat-label">${t('routeMap.subAgentRelations')}</span></div>` : ''}
`
renderTopology(canvas, agents, bindings, platforms)
} catch (e) {
canvas.innerHTML = `<div class="stat-card" style="padding:var(--space-lg);color:var(--text-danger)">${escAttr(e.message || e)}</div>`
}
}
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 = `<svg xmlns="http://www.w3.org/2000/svg" width="${svgW}" height="${svgH + legendH}" viewBox="0 0 ${svgW} ${svgH + legendH}" class="route-map-svg">`
// Column headers
svg += `<text x="${PAD_LEFT + NODE_W / 2}" y="30" text-anchor="middle" class="route-map-col-label">${t('routeMap.channels')}</text>`
svg += `<text x="${PAD_LEFT + NODE_W + COL_GAP + NODE_W / 2}" y="30" text-anchor="middle" class="route-map-col-label">${t('routeMap.agents')}</text>`
svg += `<text x="${PAD_LEFT + NODE_W + COL_GAP / 2}" y="30" text-anchor="middle" class="route-map-col-label" style="font-size:11px;opacity:0.5">${t('routeMap.bindings')}</text>`
// 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 += `<path d="M${x1},${y1} C${cx1},${y1} ${cx2},${y2} ${x2},${y2}" fill="none" stroke="${color}" stroke-width="2" stroke-opacity="${opacity}" stroke-dasharray="${dash}"/>`
// Arrow
svg += `<circle cx="${x2 - 3}" cy="${y2}" r="3" fill="${color}" fill-opacity="${opacity}"/>`
// Edge label
if (e.hint) {
const mx = (x1 + x2) / 2
const my = (y1 + y2) / 2
svg += `<text x="${mx}" y="${my - 6}" text-anchor="middle" class="route-map-edge-label">${escAttr(e.hint)}</text>`
}
}
// Draw channel nodes
for (const n of channelNodes) {
const opacity = n.enabled ? 1 : 0.45
svg += `<g class="route-map-node" data-nav="channels" style="cursor:pointer;opacity:${opacity}">
<rect x="${n.x}" y="${n.y}" width="${NODE_W}" height="${NODE_H}" rx="10" class="route-map-card" style="stroke:${n.color}"/>
<circle cx="${n.x + 22}" cy="${n.y + NODE_H / 2}" r="8" fill="${n.color}" fill-opacity="0.15"/>
<text x="${n.x + 22}" y="${n.y + NODE_H / 2 + 1}" text-anchor="middle" class="route-map-node-emoji" style="font-size:10px">${n.enabled ? '📡' : '⏸'}</text>
<text x="${n.x + 40}" y="${n.y + NODE_H / 2 - 4}" class="route-map-node-label">${escAttr(n.label)}</text>
<text x="${n.x + 40}" y="${n.y + NODE_H / 2 + 12}" class="route-map-node-sub">${n.enabled ? t('routeMap.enabled') : t('routeMap.disabled')}</text>
</g>`
}
// Draw agent nodes
for (const n of agentNodes) {
svg += `<g class="route-map-node" data-nav="agents" style="cursor:pointer">
<rect x="${n.x}" y="${n.y}" width="${NODE_W}" height="${NODE_H}" rx="10" class="route-map-card ${n.isDefault ? 'route-map-card-default' : ''}"/>
<text x="${n.x + 22}" y="${n.y + NODE_H / 2 + 5}" text-anchor="middle" style="font-size:16px">${n.emoji}</text>
<text x="${n.x + 40}" y="${n.y + NODE_H / 2 - 4}" class="route-map-node-label">${escAttr(n.label)}</text>
<text x="${n.x + 40}" y="${n.y + NODE_H / 2 + 12}" class="route-map-node-sub">${n.isDefault ? '⭐ ' + t('routeMap.defaultAgent') : n.id}</text>
</g>`
}
// 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 += `<path d="M${x1},${y1} Q${cx},${(y1 + y2) / 2} ${x2},${y2}" fill="none" stroke="#f59e0b" stroke-width="1.5" stroke-dasharray="4,3" stroke-opacity="0.7"/>`
svg += `<circle cx="${x2}" cy="${y2}" r="2.5" fill="#f59e0b" fill-opacity="0.7"/>`
const mx = cx - 4
const my = (y1 + y2) / 2
svg += `<text x="${mx}" y="${my - 4}" text-anchor="end" class="route-map-edge-label" style="fill:#f59e0b">${t('routeMap.subAgentCall')}</text>`
}
// Legend
const ly = svgH + (a2aEdges.length > 0 ? 10 : 0)
svg += `<g class="route-map-legend">`
let lx = PAD_LEFT
// Solid line = explicit binding
svg += `<line x1="${lx}" y1="${ly}" x2="${lx + 24}" y2="${ly}" stroke="var(--accent)" stroke-width="2"/>`
svg += `<text x="${lx + 30}" y="${ly + 4}" class="route-map-edge-label" style="font-size:10px;fill:var(--text-secondary)">${t('routeMap.legendBinding')}</text>`
lx += 110
// Dashed line = default route
svg += `<line x1="${lx}" y1="${ly}" x2="${lx + 24}" y2="${ly}" stroke="#94a3b8" stroke-width="2" stroke-dasharray="6,4"/>`
svg += `<text x="${lx + 30}" y="${ly + 4}" class="route-map-edge-label" style="font-size:10px;fill:var(--text-secondary)">${t('routeMap.legendDefault')}</text>`
if (a2aEdges.length > 0) {
lx += 110
svg += `<line x1="${lx}" y1="${ly}" x2="${lx + 24}" y2="${ly}" stroke="#f59e0b" stroke-width="1.5" stroke-dasharray="4,3"/>`
svg += `<text x="${lx + 30}" y="${ly + 4}" class="route-map-edge-label" style="font-size:10px;fill:#f59e0b">${t('routeMap.subAgentCall')}</text>`
}
svg += `</g>`
svg += '</svg>'
container.innerHTML = `<div class="route-map-scroll">${svg}</div>`
// Click to navigate
container.querySelectorAll('.route-map-node').forEach(el => {
el.addEventListener('click', () => {
const target = el.dataset.nav
if (target) navigate('/' + target)
})
})
}

View File

@@ -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%);