mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-30 04:40:18 +08:00
feat: new pages + dashboard enhancements + backend improvements
New pages: - Plugin Hub: grid cards, search, install/toggle/enable plugins - Route Map: SVG visualization of channels→agents bindings with legends - Diagnose: gateway connectivity diagnosis with step-by-step checks Dashboard enhancements: - WebSocket status indicator (connected/handshaking/reconnecting/disconnected) - Connected channels overview with platform icons - Colored log level badges (ERROR/WARN/INFO/DEBUG) with timestamps - Channels data loading in dashboard secondary fetch Splash screen: - Multi-stage boot detection (JS not loaded vs boot slow vs timeout) - 15s: WebView2/resource load failure - 20s: "initializing..." hint with elapsed counter - 90s: true timeout error Backend (Rust): - diagnose.rs: gateway connectivity diagnosis command - messaging.rs: plugin management commands - service.rs: improvements - lib.rs: register new commands Frontend libs: - feature-gates.js: feature flag system - ws-client.js: reconnect state tracking - tauri-api.js: new API bindings - model-presets.js: provider fixes - Remove gateway-guardian-policy.js (unused) Dev API (scripts/dev-api.js): - list_all_plugins, toggle_plugin, install_plugin handlers - probe_gateway_port, diagnose_gateway_connection handlers i18n: dashboard, sidebar, diagnose, extensions, routeMap locale modules CSS: plugin-hub cards, route-map SVG styles
This commit is contained in:
@@ -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
135
src/lib/feature-gates.js
Normal 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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user