Skip to content

Commit 70d768b

Browse files
committed
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
1 parent c1fb674 commit 70d768b

27 files changed

+2338
-188
lines changed

index.html

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,67 @@
6666
<div class="sp-bar"><div class="sp-bar-inner"></div></div>
6767
<div class="sp-site"><a href="https://qt.cool" target="_blank">qt.cool</a></div>
6868
</div>
69-
<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>
69+
<script>
70+
// 多阶段启动检测:区分"JS未加载(WebView2问题)"与"JS已加载但启动慢"
71+
window._splashStart = Date.now();
72+
window._jsLoaded = false; // main.js 入口会设为 true
73+
window._bootDone = false; // boot() 完成后设为 true
74+
window._splashTimer = setInterval(function () {
75+
var s = document.getElementById('splash');
76+
if (!s) { clearInterval(window._splashTimer); return; }
77+
var app = document.getElementById('content');
78+
// 已有内容 → 正常隐藏
79+
if (app && app.children.length > 0) {
80+
s.classList.add('hide');
81+
setTimeout(function () { s.remove(); }, 500);
82+
clearInterval(window._splashTimer);
83+
return;
84+
}
85+
if (window._bootDone) { clearInterval(window._splashTimer); return; }
86+
var elapsed = Date.now() - window._splashStart;
87+
var sec = Math.floor(elapsed / 1000);
88+
// 阶段1:15秒内 JS 模块未加载 → 可能是真正的 WebView2/资源问题
89+
if (!window._jsLoaded && elapsed > 15000) {
90+
clearInterval(window._splashTimer);
91+
var dk = window.matchMedia && window.matchMedia('(prefers-color-scheme:dark)').matches;
92+
s.innerHTML = '<div style="text-align:center;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif">'
93+
+ '<div style="font-size:40px;margin-bottom:12px">\u26A0\uFE0F</div>'
94+
+ '<div style="font-size:16px;font-weight:600;color:' + (dk ? '#e4e4e7' : '#18181b') + ';margin-bottom:8px">\u524D\u7AEF\u8D44\u6E90\u52A0\u8F7D\u5931\u8D25</div>'
95+
+ '<div style="font-size:12px;color:#71717a;margin-bottom:16px;line-height:1.7">'
96+
+ 'JavaScript \u6A21\u5757\u672A\u80FD\u5728 15 \u79D2\u5185\u52A0\u8F7D\u3002\u53EF\u80FD\u539F\u56E0\uFF1A<br>'
97+
+ '\u2022 WebView2 Runtime \u672A\u5B89\u88C5\u6216\u5DF2\u635F\u574F<br>'
98+
+ '\u2022 \u524D\u7AEF\u8D44\u6E90\u88AB\u5B89\u5168\u8F6F\u4EF6\u62E6\u622A<br>'
99+
+ '\u2022 \u5E94\u7528\u5B89\u88C5\u4E0D\u5B8C\u6574<br><br>'
100+
+ '\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>'
101+
+ '<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>';
102+
return;
103+
}
104+
// 阶段2:JS 已加载但 boot() 仍在运行 → 显示等待提示(不报错)
105+
if (window._jsLoaded && elapsed > 20000) {
106+
var hint = s.querySelector('.sp-hint');
107+
if (!hint) {
108+
hint = document.createElement('div');
109+
hint.className = 'sp-hint';
110+
hint.style.cssText = 'font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-size:12px;color:#a1a1aa;margin-top:16px';
111+
s.appendChild(hint);
112+
}
113+
hint.textContent = '\u6B63\u5728\u521D\u59CB\u5316\u73AF\u5883\uFF0C\u8BF7\u7A0D\u5019... (' + sec + 's)';
114+
}
115+
// 阶段3:90秒仍未完成 → 才显示真正的错误
116+
if (elapsed > 90000) {
117+
clearInterval(window._splashTimer);
118+
var dk2 = window.matchMedia && window.matchMedia('(prefers-color-scheme:dark)').matches;
119+
s.innerHTML = '<div style="text-align:center;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif">'
120+
+ '<div style="font-size:40px;margin-bottom:12px">\u26A0\uFE0F</div>'
121+
+ '<div style="font-size:16px;font-weight:600;color:' + (dk2 ? '#e4e4e7' : '#18181b') + ';margin-bottom:8px">\u542F\u52A8\u8D85\u65F6</div>'
122+
+ '<div style="font-size:12px;color:#71717a;margin-bottom:16px;line-height:1.7">'
123+
+ '\u5E94\u7528\u5DF2\u7B49\u5F85 90 \u79D2\u4ECD\u672A\u5B8C\u6210\u521D\u59CB\u5316\u3002<br>'
124+
+ '\u53EF\u80FD\u662F\u7F51\u7EDC\u73AF\u5883\u68C0\u6D4B\u8D85\u65F6\u6216\u540E\u7AEF\u670D\u52A1\u5F02\u5E38\u3002<br>'
125+
+ '\u8BF7\u5C1D\u8BD5\u5237\u65B0\u6216\u91CD\u542F\u5E94\u7528\u3002</div>'
126+
+ '<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>';
127+
}
128+
}, 3000);
129+
</script>
70130

71131
<div id="app">
72132
<aside id="sidebar"></aside>

scripts/dev-api.js

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3373,6 +3373,79 @@ const handlers = {
33733373
}
33743374
},
33753375

3376+
list_all_plugins() {
3377+
const cfg = readOpenclawConfigOptional()
3378+
const entries = cfg.plugins?.entries || {}
3379+
const allowArr = cfg.plugins?.allow || []
3380+
const extDir = path.join(OPENCLAW_DIR, 'extensions')
3381+
const plugins = []
3382+
const seen = new Set()
3383+
3384+
// Scan extensions directory
3385+
if (fs.existsSync(extDir)) {
3386+
for (const name of fs.readdirSync(extDir)) {
3387+
if (name.startsWith('.')) continue
3388+
const p = path.join(extDir, name)
3389+
if (!fs.statSync(p).isDirectory()) continue
3390+
const hasMarker = fs.existsSync(path.join(p, 'package.json')) || fs.existsSync(path.join(p, 'plugin.ts')) || fs.existsSync(path.join(p, 'index.js'))
3391+
if (!hasMarker) continue
3392+
seen.add(name)
3393+
const entryCfg = entries[name]
3394+
const enabled = !!entryCfg?.enabled
3395+
const allowed = allowArr.includes(name)
3396+
let version = null, description = null
3397+
try {
3398+
const pkg = JSON.parse(fs.readFileSync(path.join(p, 'package.json'), 'utf8'))
3399+
version = pkg.version || null
3400+
description = pkg.description || null
3401+
} catch {}
3402+
plugins.push({ id: name, installed: true, builtin: false, enabled, allowed, version, description, config: entryCfg?.config || null })
3403+
}
3404+
}
3405+
3406+
// Include entries from config not found in extensions dir
3407+
for (const [pid, val] of Object.entries(entries)) {
3408+
if (seen.has(pid)) continue
3409+
seen.add(pid)
3410+
plugins.push({ id: pid, installed: false, builtin: false, enabled: !!val?.enabled, allowed: allowArr.includes(pid), version: null, description: null, config: val?.config || null })
3411+
}
3412+
3413+
plugins.sort((a, b) => (b.enabled ? 1 : 0) - (a.enabled ? 1 : 0) || a.id.localeCompare(b.id))
3414+
return { plugins }
3415+
},
3416+
3417+
toggle_plugin({ pluginId, enabled }) {
3418+
if (!pluginId || !pluginId.trim()) throw new Error('pluginId 不能为空')
3419+
const pid = pluginId.trim()
3420+
const cfg = readOpenclawConfigOptional()
3421+
if (!cfg.plugins) cfg.plugins = {}
3422+
if (!cfg.plugins.entries) cfg.plugins.entries = {}
3423+
if (!cfg.plugins.allow) cfg.plugins.allow = []
3424+
3425+
if (enabled) {
3426+
if (!cfg.plugins.allow.includes(pid)) cfg.plugins.allow.push(pid)
3427+
if (!cfg.plugins.entries[pid]) cfg.plugins.entries[pid] = {}
3428+
cfg.plugins.entries[pid].enabled = true
3429+
} else {
3430+
cfg.plugins.allow = cfg.plugins.allow.filter(v => v !== pid)
3431+
if (cfg.plugins.entries[pid]) cfg.plugins.entries[pid].enabled = false
3432+
}
3433+
3434+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf8')
3435+
return { ok: true, enabled, pluginId: pid }
3436+
},
3437+
3438+
install_plugin({ packageName }) {
3439+
if (!packageName || !packageName.trim()) throw new Error('包名不能为空')
3440+
const spec = packageName.trim()
3441+
try {
3442+
execOpenclawSync(['plugins', 'install', spec], { timeout: 120000, cwd: homedir(), windowsHide: true }, `插件 ${spec} 安装失败`)
3443+
return { ok: true, output: '安装成功' }
3444+
} catch (e) {
3445+
throw new Error(`插件安装失败: ${e.message || e}`)
3446+
}
3447+
},
3448+
33763449
get_channel_plugin_status({ pluginId }) {
33773450
if (!pluginId || !pluginId.trim()) throw new Error('pluginId 不能为空')
33783451
const pid = pluginId.trim()
@@ -4480,6 +4553,129 @@ const handlers = {
44804553
}
44814554
},
44824555

4556+
async probe_gateway_port() {
4557+
const port = readGatewayPort()
4558+
return new Promise(resolve => {
4559+
const net = require('net')
4560+
const sock = net.createConnection({ host: '127.0.0.1', port, timeout: 3000 })
4561+
sock.on('connect', () => { sock.destroy(); resolve(true) })
4562+
sock.on('error', () => resolve(false))
4563+
sock.on('timeout', () => { sock.destroy(); resolve(false) })
4564+
})
4565+
},
4566+
4567+
async diagnose_gateway_connection() {
4568+
const steps = []
4569+
const ocDir = openclawDir()
4570+
const configPath = path.join(ocDir, 'openclaw.json')
4571+
const port = readGatewayPort()
4572+
4573+
// 1. 配置文件
4574+
const t1 = Date.now()
4575+
try {
4576+
const content = fs.readFileSync(configPath, 'utf-8')
4577+
const val = JSON.parse(content)
4578+
steps.push({ name: 'config', ok: !!val.gateway, message: val.gateway ? '配置文件有效,含 gateway 配置' : '配置文件缺少 gateway 段', durationMs: Date.now() - t1 })
4579+
} catch (e) {
4580+
steps.push({ name: 'config', ok: false, message: `配置文件异常: ${e.message}`, durationMs: Date.now() - t1 })
4581+
}
4582+
4583+
// 2. 设备密钥
4584+
const t2 = Date.now()
4585+
const keyPath = path.join(ocDir, 'clawpanel-device-key.json')
4586+
const keyExists = fs.existsSync(keyPath)
4587+
steps.push({ name: 'device_key', ok: keyExists, message: keyExists ? '设备密钥存在' : '设备密钥不存在', durationMs: Date.now() - t2 })
4588+
4589+
// 3. allowedOrigins
4590+
const t3 = Date.now()
4591+
try {
4592+
const val = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
4593+
const origins = val?.gateway?.controlUi?.allowedOrigins
4594+
if (Array.isArray(origins) && origins.length > 0) {
4595+
steps.push({ name: 'allowed_origins', ok: true, message: `allowedOrigins: ${JSON.stringify(origins)}`, durationMs: Date.now() - t3 })
4596+
} else {
4597+
steps.push({ name: 'allowed_origins', ok: false, message: '未配置 allowedOrigins', durationMs: Date.now() - t3 })
4598+
}
4599+
} catch {
4600+
steps.push({ name: 'allowed_origins', ok: false, message: '配置文件不可读', durationMs: Date.now() - t3 })
4601+
}
4602+
4603+
// 4. TCP 端口
4604+
const t4 = Date.now()
4605+
const tcpOk = await new Promise(resolve => {
4606+
const net = require('net')
4607+
const sock = net.createConnection({ host: '127.0.0.1', port, timeout: 3000 })
4608+
sock.on('connect', () => { sock.destroy(); resolve(true) })
4609+
sock.on('error', () => resolve(false))
4610+
sock.on('timeout', () => { sock.destroy(); resolve(false) })
4611+
})
4612+
steps.push({ name: 'tcp_port', ok: tcpOk, message: tcpOk ? `端口 ${port} 可达` : `端口 ${port} 不可达`, durationMs: Date.now() - t4 })
4613+
4614+
// 5. HTTP /health
4615+
const t5 = Date.now()
4616+
let httpOk = false
4617+
let httpMsg = ''
4618+
try {
4619+
const resp = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(5000) })
4620+
httpOk = resp.ok
4621+
httpMsg = `HTTP /health 返回 ${resp.status}`
4622+
} catch (e) {
4623+
httpMsg = `HTTP /health 请求失败: ${e.message}`
4624+
}
4625+
steps.push({ name: 'http_health', ok: httpOk, message: httpMsg, durationMs: Date.now() - t5 })
4626+
4627+
// 6. 错误日志
4628+
const t6 = Date.now()
4629+
const errLogPath = path.join(ocDir, 'logs', 'gateway.err.log')
4630+
if (fs.existsSync(errLogPath)) {
4631+
const stat = fs.statSync(errLogPath)
4632+
if (stat.size === 0) {
4633+
steps.push({ name: 'err_log', ok: true, message: '错误日志为空(正常)', durationMs: Date.now() - t6 })
4634+
} else {
4635+
const buf = Buffer.alloc(Math.min(1024, stat.size))
4636+
const fd = fs.openSync(errLogPath, 'r')
4637+
fs.readSync(fd, buf, 0, buf.length, Math.max(0, stat.size - buf.length))
4638+
fs.closeSync(fd)
4639+
const tail = buf.toString('utf-8').toLowerCase()
4640+
const hasFatal = tail.includes('fatal') || tail.includes('eaddrinuse') || tail.includes('config invalid')
4641+
steps.push({ name: 'err_log', ok: !hasFatal, message: hasFatal ? `错误日志含关键错误 (${stat.size} bytes)` : `错误日志存在但无致命错误 (${stat.size} bytes)`, durationMs: Date.now() - t6 })
4642+
}
4643+
} else {
4644+
steps.push({ name: 'err_log', ok: true, message: '无错误日志(正常)', durationMs: Date.now() - t6 })
4645+
}
4646+
4647+
// env
4648+
let authMode = 'none'
4649+
try {
4650+
const val = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
4651+
const auth = val?.gateway?.auth
4652+
if (auth?.token) authMode = 'token'
4653+
else if (auth?.password) authMode = 'password'
4654+
} catch {}
4655+
let errLogExcerpt = ''
4656+
try {
4657+
const buf = fs.readFileSync(errLogPath)
4658+
errLogExcerpt = buf.slice(Math.max(0, buf.length - 2048)).toString('utf-8')
4659+
} catch {}
4660+
4661+
const overallOk = steps.every(s => s.ok)
4662+
const failed = steps.filter(s => !s.ok).map(s => s.name)
4663+
return {
4664+
steps,
4665+
env: {
4666+
openclawDir: ocDir,
4667+
configExists: fs.existsSync(configPath),
4668+
port,
4669+
authMode,
4670+
deviceKeyExists: keyExists,
4671+
gatewayOwner: null,
4672+
errLogExcerpt,
4673+
},
4674+
overallOk,
4675+
summary: overallOk ? '所有检查项通过' : `以下检查未通过: ${failed.join(', ')}`,
4676+
}
4677+
},
4678+
44834679
guardian_status() {
44844680
// Web 模式没有 Guardian 守护进程
44854681
return { enabled: false, giveUp: false }

src-tauri/src/commands/device.rs

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,14 @@ mod hex {
8888
}
8989

9090
/// 生成 Gateway connect 帧(含 Ed25519 签名)
91+
/// gateway_token: token 模式认证凭据(可为空)
92+
/// gateway_password: password 模式认证凭据(可为空,新增)
9193
#[tauri::command]
92-
pub fn create_connect_frame(nonce: String, gateway_token: String) -> Result<Value, String> {
94+
pub fn create_connect_frame(
95+
nonce: String,
96+
gateway_token: String,
97+
gateway_password: Option<String>,
98+
) -> Result<Value, String> {
9399
let (device_id, pub_b64, signing_key) = get_or_create_key()?;
94100
let signed_at = std::time::SystemTime::now()
95101
.duration_since(std::time::UNIX_EPOCH)
@@ -99,17 +105,34 @@ pub fn create_connect_frame(nonce: String, gateway_token: String) -> Result<Valu
99105
let platform = std::env::consts::OS; // "windows" | "macos" | "linux"
100106
let device_family = "desktop";
101107

108+
// v3 签名 payload 中 token 字段:优先 token,其次 password,最后空串
109+
let auth_secret = if !gateway_token.is_empty() {
110+
&gateway_token
111+
} else {
112+
gateway_password.as_deref().unwrap_or("")
113+
};
114+
102115
let scopes_str = SCOPES.join(",");
103116
// v3 格式:v3|deviceId|clientId|clientMode|role|scopes|signedAt|token|nonce|platform|deviceFamily
104117
// 使用 openclaw-control-ui + ui 模式,使 Gateway 识别为 Control UI 客户端,
105118
// 本地连接时触发静默自动配对(shouldAllowSilentLocalPairing = true)
106119
let payload_str = format!(
107-
"v3|{device_id}|openclaw-control-ui|ui|operator|{scopes_str}|{signed_at}|{gateway_token}|{nonce}|{platform}|{device_family}"
120+
"v3|{device_id}|openclaw-control-ui|ui|operator|{scopes_str}|{signed_at}|{auth_secret}|{nonce}|{platform}|{device_family}"
108121
);
109122

110123
let signature = signing_key.sign(payload_str.as_bytes());
111124
let sig_b64 = base64_url_encode(&signature.to_bytes());
112125

126+
// 构建 auth 对象:根据有无 token/password 选择填充字段
127+
let password = gateway_password.unwrap_or_default();
128+
let auth = if !gateway_token.is_empty() {
129+
serde_json::json!({ "token": gateway_token })
130+
} else if !password.is_empty() {
131+
serde_json::json!({ "password": password })
132+
} else {
133+
serde_json::json!({})
134+
};
135+
113136
let frame = serde_json::json!({
114137
"type": "req",
115138
"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
127150
"role": "operator",
128151
"scopes": SCOPES,
129152
"caps": ["tool-events"],
130-
"auth": { "token": gateway_token },
153+
"auth": auth,
131154
"device": {
132155
"id": device_id,
133156
"publicKey": pub_b64,

0 commit comments

Comments
 (0)