mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-30 04:40:18 +08:00
feat: 飞书官方插件迁移 + 配对审批 + Gateway防卡死 + 微信升级修复 + 更新检测修复
- 飞书渠道从 @openclaw/feishu 迁移到 @larksuite/openclaw-lark 官方插件 - 保存飞书配置时自动禁用旧 feishu 插件,防止新旧插件冲突 - 所有主要渠道(飞书/Telegram/Discord/Slack)启用配对审批UI - gateway_command 增加20s超时,超时后force-kill+fresh start - 全平台启动前端口占用检查,防止Guardian无限拉起 - Linux gateway_command 补齐 Duration 导入和 cleanup_zombie 实现 - Guardian自动守护在Tauri桌面端也启用,轮询间隔30s→15s - 微信渠道:升级操作不再弹出扫码二维码,按钮文案区分安装/升级 - 版本更新检测:CI不再将minAppVersion写死为当前版本 - 部署脚本增强OpenClaw检测,支持已安装的官方版 - 日间/夜间模式圆形扩散切换动画(View Transitions API) - API错误信息完整展示(429限流等),URL自动转可点击链接 - 第三方API接入引导优化:移除内置密钥,引导式流程 - 修复全平台 Clippy 警告(strip_prefix/dead_code/unnecessary_unwrap等) - Rust代码格式化修复(cargo fmt) - toast组件支持HTML内容渲染 - Rust后端test_model返回详细错误信息
This commit is contained in:
@@ -146,7 +146,7 @@ function _setGatewayRunning(val) {
|
||||
if (val) {
|
||||
// 仅记录恢复运行时间,避免短暂存活就把重启计数清零
|
||||
_gatewayRunningSince = Date.now()
|
||||
} else if (!isTauri && wasRunning && !_userStopped && !_isUpgrading && _openclawReady) {
|
||||
} else if (wasRunning && !_userStopped && !_isUpgrading && _openclawReady) {
|
||||
_gatewayRunningSince = 0
|
||||
// Gateway 意外停止,尝试自动重启
|
||||
_tryAutoRestart()
|
||||
@@ -173,6 +173,20 @@ async function _tryAutoRestart() {
|
||||
return
|
||||
}
|
||||
|
||||
// 重启前再次确认端口确实空闲,防止端口被其他程序占用时无限拉起
|
||||
try {
|
||||
const services = await api.getServicesStatus()
|
||||
const gw = services?.[0]
|
||||
if (gw?.running) {
|
||||
console.log('[guardian] 端口仍在使用中,跳过自动重启')
|
||||
_gwStopCount = 0
|
||||
_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)...`)
|
||||
@@ -217,10 +231,10 @@ export async function refreshGatewayStatus() {
|
||||
}
|
||||
|
||||
let _pollTimer = null
|
||||
/** 启动 Gateway 状态轮询(每 15 秒,避免过于频繁) */
|
||||
/** 启动 Gateway 状态轮询(每 15 秒检测一次) */
|
||||
export function startGatewayPoll() {
|
||||
if (_pollTimer) return
|
||||
_pollTimer = setInterval(() => refreshGatewayStatus(), 30000)
|
||||
_pollTimer = setInterval(() => refreshGatewayStatus(), 15000)
|
||||
}
|
||||
export function stopGatewayPoll() {
|
||||
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null }
|
||||
|
||||
22
src/lib/channel-labels.js
Normal file
22
src/lib/channel-labels.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/** 渠道 key → 中文显示名(供多页面复用) */
|
||||
export const CHANNEL_LABELS = {
|
||||
qqbot: 'QQ 机器人',
|
||||
telegram: 'Telegram',
|
||||
feishu: '飞书',
|
||||
dingtalk: '钉钉',
|
||||
'dingtalk-connector': '钉钉',
|
||||
discord: 'Discord',
|
||||
slack: 'Slack',
|
||||
whatsapp: 'WhatsApp',
|
||||
msteams: 'Microsoft Teams',
|
||||
signal: 'Signal',
|
||||
matrix: 'Matrix',
|
||||
irc: 'IRC',
|
||||
googlechat: 'Google Chat',
|
||||
imessage: 'iMessage',
|
||||
line: 'LINE',
|
||||
nostr: 'Nostr',
|
||||
mattermost: 'Mattermost',
|
||||
'openclaw-weixin': '微信',
|
||||
weixin: '微信',
|
||||
}
|
||||
@@ -48,6 +48,9 @@ const PATHS = {
|
||||
'lightbulb': '<line x1="9" y1="18" x2="15" y2="18"/><line x1="10" y1="22" x2="14" y2="22"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0018 8 6 6 0 006 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 018.91 14"/>',
|
||||
'globe': '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/>',
|
||||
'shield': '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
|
||||
'hash': '<line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/>',
|
||||
'phone': '<path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6 19.79 19.79 0 01-3.07-8.67A2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"/>',
|
||||
'users': '<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/>',
|
||||
'list': '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
|
||||
|
||||
// 军事主题图标
|
||||
|
||||
@@ -240,7 +240,8 @@ function inlineFormat(text) {
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/__(.+?)__/g, '<strong>$1</strong>')
|
||||
.replace(/(?<!\w)_(.+?)_(?!\w)/g, '<em>$1</em>')
|
||||
// 避免 (?<!\w) 负向后查找:旧版 Safari / 部分 WebView 会报 invalid group specifier name
|
||||
.replace(/(^|[^A-Za-z0-9_])_(.+?)_(?![A-Za-z0-9_])/g, '$1<em>$2</em>')
|
||||
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => {
|
||||
const safeSrc = resolveImageSrc(src.trim())
|
||||
const escapedSrc = escapeHtml(src).replace(/\\/g, '\')
|
||||
|
||||
@@ -31,7 +31,7 @@ export const PROVIDER_PRESETS = [
|
||||
// 晴辰云配置
|
||||
export const QTCOOL = {
|
||||
baseUrl: 'https://gpt.qt.cool/v1',
|
||||
defaultKey: 'sk-0JDu7hyc51ZKD4iNebpFu07EUEhXmVVc',
|
||||
defaultKey: '',
|
||||
site: 'https://gpt.qt.cool/',
|
||||
checkinUrl: 'https://gpt.qt.cool/checkin',
|
||||
usageUrl: 'https://gpt.qt.cool/user?key=',
|
||||
@@ -78,14 +78,23 @@ export const MODEL_PRESETS = {
|
||||
|
||||
/**
|
||||
* 动态获取 QTCOOL 模型列表
|
||||
* @param {string} [apiKey] - 自定义密钥,不传则用默认密钥
|
||||
* @param {string} [apiKey] - 自定义密钥;未传时尝试从已有配置读取
|
||||
* @returns {Promise<Array<{id:string, name:string, contextWindow:number, reasoning?:boolean}>>}
|
||||
*/
|
||||
export async function fetchQtcoolModels(apiKey) {
|
||||
const key = apiKey || QTCOOL.defaultKey
|
||||
let key = apiKey || QTCOOL.defaultKey
|
||||
// 没有 key 时尝试从已有的 qtcool provider 配置读取
|
||||
if (!key) {
|
||||
try {
|
||||
const { api } = await import('../lib/tauri-api.js')
|
||||
const cfg = await api.readOpenclawConfig()
|
||||
key = cfg?.models?.providers?.qtcool?.apiKey || ''
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
try {
|
||||
const headers = key ? { 'Authorization': 'Bearer ' + key } : {}
|
||||
const resp = await fetch(QTCOOL.baseUrl + '/models', {
|
||||
headers: { 'Authorization': 'Bearer ' + key },
|
||||
headers,
|
||||
signal: AbortSignal.timeout(8000)
|
||||
})
|
||||
if (resp.ok) {
|
||||
|
||||
@@ -219,36 +219,36 @@ npm install -g openclaw@latest
|
||||
7. WhatsApp 不回消息 → 检查配对是否已 approve
|
||||
8. 认证错误 → 检查 auth-profiles.json 或重新 \`openclaw onboard\`
|
||||
|
||||
## 十二、ClawPanel 公益 AI 接口计划
|
||||
ClawPanel 项目组正式开放公益 AI 接口测试计划,降低用户接入 AI 能力的门槛。
|
||||
## 十二、晴辰云 AI 模型服务
|
||||
晴辰云为 ClawPanel 用户提供 AI 模型调用服务,通过每日签到和邀请好友即可获取免费额度。
|
||||
|
||||
### 核心信息
|
||||
- **GPT-AI 网关地址**:https://gpt.qt.cool/v1
|
||||
- **支持接口**:/v1/chat/completions、/v1/responses(OpenAI 兼容)
|
||||
- **支持模型**:OpenAI 全系列(GPT-5、GPT-5.1、GPT-5.2 及其 Codex 变体)
|
||||
- **费用**:测试期间所有调用成本由项目组内部承担,用户无需付费
|
||||
- **限制**:无请求限制
|
||||
- **用户后台**:https://gpt.qt.cool/user(查看用量、提交工单)
|
||||
- **免费额度**:每日签到领取免费额度,邀请好友可获得额外奖励
|
||||
- **高级模型**:低至官方价 2-3 折
|
||||
- **用户后台**:https://gpt.qt.cool/user(查看用量、管理密钥)
|
||||
|
||||
### 官方入口
|
||||
- **ClawPanel 官网**:https://claw.qt.cool/
|
||||
- **GPT-AI 网关官网**:https://gpt.qt.cool/
|
||||
- **每日签到页**:https://gpt.qt.cool/checkin
|
||||
- **用户后台**:https://gpt.qt.cool/user
|
||||
- **晴辰导航站**:https://qt.cool/
|
||||
|
||||
### 测试密钥
|
||||
- ClawPanel 已内置公共体验密钥,开箱即用
|
||||
- 用户也可前往 gpt.qt.cool 签到获取独立密钥
|
||||
- 独立密钥可在用户后台管理和查询用量
|
||||
### 获取密钥
|
||||
1. 前往 https://gpt.qt.cool/checkin 每日签到领取免费额度
|
||||
2. 邀请好友可获得额外奖励额度
|
||||
3. 在用户后台获取 API Key
|
||||
|
||||
### 接入方式
|
||||
已兼容 OpenAI API 的项目,只需替换:
|
||||
1. Base URL → https://gpt.qt.cool/v1
|
||||
2. API Key → 测试密钥
|
||||
2. API Key → 签到获取的密钥
|
||||
即可完成接入。
|
||||
|
||||
### 在 ClawPanel 中配置
|
||||
- **助手设置**:打开 AI 助手设置 → 模型配置 → 使用「一键接入」按钮
|
||||
- **模型配置页**:进入模型配置 → 使用「一键添加全部模型」按钮
|
||||
- 两处均自动填入网关地址和内置密钥
|
||||
- **助手设置**:打开 AI 助手设置 → 模型配置 → 输入密钥后点击「接入」
|
||||
- **模型配置页**:进入模型配置 → 输入密钥后点击「获取模型列表」添加模型
|
||||
`.trim()
|
||||
|
||||
@@ -213,15 +213,29 @@ export const api = {
|
||||
exportMemoryZip: (category, agentId) => invoke('export_memory_zip', { category, agentId: agentId || null }),
|
||||
|
||||
// 消息渠道管理
|
||||
readPlatformConfig: (platform) => invoke('read_platform_config', { platform }),
|
||||
saveMessagingPlatform: (platform, form, accountId) => { invalidate('list_configured_platforms', 'read_platform_config'); return invoke('save_messaging_platform', { platform, form, accountId: accountId || null }) },
|
||||
removeMessagingPlatform: (platform) => { invalidate('list_configured_platforms', 'read_platform_config'); return invoke('remove_messaging_platform', { platform }) },
|
||||
readPlatformConfig: (platform, accountId) => invoke('read_platform_config', { platform, accountId: accountId || null }),
|
||||
saveMessagingPlatform: (platform, form, accountId, agentId) => { invalidate('list_configured_platforms', 'read_openclaw_config', 'read_platform_config'); return invoke('save_messaging_platform', { platform, form, accountId: accountId || null, agentId: agentId || null }) },
|
||||
removeMessagingPlatform: (platform, accountId) => { invalidate('list_configured_platforms', 'read_openclaw_config', 'read_platform_config'); return invoke('remove_messaging_platform', { platform, accountId: accountId || null }) },
|
||||
toggleMessagingPlatform: (platform, enabled) => { invalidate('list_configured_platforms', 'read_openclaw_config', 'read_platform_config'); return invoke('toggle_messaging_platform', { platform, enabled }) },
|
||||
verifyBotToken: (platform, form) => invoke('verify_bot_token', { platform, form }),
|
||||
diagnoseChannel: (platform, accountId) => invoke('diagnose_channel', { platform, accountId: accountId || null }),
|
||||
repairQqbotChannelSetup: () => {
|
||||
invalidate('list_configured_platforms', 'read_openclaw_config', 'read_platform_config')
|
||||
return invoke('repair_qqbot_channel_setup')
|
||||
},
|
||||
listConfiguredPlatforms: () => cachedInvoke('list_configured_platforms', {}, 5000),
|
||||
getChannelPluginStatus: (pluginId) => invoke('get_channel_plugin_status', { pluginId }),
|
||||
installQqbotPlugin: () => invoke('install_qqbot_plugin'),
|
||||
installChannelPlugin: (packageName, pluginId) => invoke('install_channel_plugin', { packageName, pluginId }),
|
||||
runChannelAction: (platform, action) => invoke('run_channel_action', { platform, action }),
|
||||
checkWeixinPluginStatus: () => invoke('check_weixin_plugin_status'),
|
||||
|
||||
// Agent 渠道绑定管理
|
||||
getAgentBindings: (agentId) => invoke('get_agent_bindings', { agentId }),
|
||||
listAllBindings: () => invoke('list_all_bindings'),
|
||||
saveAgentBinding: (agentId, channel, accountId, bindingConfig) => { invalidate('read_openclaw_config', 'list_configured_platforms'); return invoke('save_agent_binding', { agentId, channel, accountId: accountId || null, bindingConfig: bindingConfig || {} }) },
|
||||
deleteAgentBinding: (agentId, channel, accountId) => { invalidate('read_openclaw_config', 'list_configured_platforms'); return invoke('delete_agent_binding', { agentId, channel, accountId: accountId || null }) },
|
||||
deleteAgentAllBindings: (agentId) => { invalidate('read_openclaw_config', 'list_configured_platforms'); return invoke('delete_agent_all_bindings', { agentId }) },
|
||||
|
||||
// 面板配置 (clawpanel.json)
|
||||
getOpenclawDir: () => invoke('get_openclaw_dir'),
|
||||
|
||||
@@ -9,10 +9,26 @@ export function initTheme() {
|
||||
applyTheme(theme)
|
||||
}
|
||||
|
||||
export function toggleTheme() {
|
||||
const current = document.documentElement.dataset.theme || 'light'
|
||||
export function toggleTheme(onApply) {
|
||||
const html = document.documentElement
|
||||
const current = html.dataset.theme || 'light'
|
||||
const next = current === 'dark' ? 'light' : 'dark'
|
||||
applyTheme(next)
|
||||
|
||||
// 设置扩散起点:白切黑从左下角,黑切白从右上角
|
||||
const toDark = next === 'dark'
|
||||
html.style.setProperty('--theme-reveal-x', toDark ? '0%' : '100%')
|
||||
html.style.setProperty('--theme-reveal-y', toDark ? '100%' : '0%')
|
||||
|
||||
const doApply = () => {
|
||||
applyTheme(next)
|
||||
if (onApply) onApply(next)
|
||||
}
|
||||
|
||||
if (document.startViewTransition) {
|
||||
document.startViewTransition(doApply)
|
||||
} else {
|
||||
doApply()
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
|
||||
@@ -20,9 +20,14 @@ export function uuid() {
|
||||
}
|
||||
|
||||
const REQUEST_TIMEOUT = 30000
|
||||
const MAX_RECONNECT_DELAY = 30000
|
||||
const PING_INTERVAL = 25000
|
||||
const CHALLENGE_TIMEOUT = 5000
|
||||
const MAX_RECONNECT_DELAY = 60000
|
||||
const PING_INTERVAL = 30000
|
||||
const CHALLENGE_TIMEOUT = 15000
|
||||
const MAX_RECONNECT_ATTEMPTS = 20
|
||||
const HEARTBEAT_TIMEOUT = 90000
|
||||
const MESSAGE_CACHE_SIZE = 100
|
||||
// Gateway 启动前的初始重连延迟(更长,给 Gateway 充足的重启/初始化时间)
|
||||
const INITIAL_RECONNECT_DELAY = 10000
|
||||
|
||||
export class WsClient {
|
||||
constructor() {
|
||||
@@ -48,6 +53,19 @@ export class WsClient {
|
||||
this._wsId = 0
|
||||
this._autoPairAttempts = 0
|
||||
this._serverVersion = null
|
||||
|
||||
// 增强状态追踪
|
||||
this._lastConnectedAt = null
|
||||
this._lastMessageAt = null
|
||||
this._pendingReconnect = false
|
||||
this._missedHeartbeats = 0
|
||||
this._heartbeatTimer = null
|
||||
this._reconnectState = 'idle' // idle | attempting | scheduled
|
||||
|
||||
// 消息缓存
|
||||
this._messageCache = new Map()
|
||||
this._cacheSize = MESSAGE_CACHE_SIZE
|
||||
this._seenMessageIds = new Set()
|
||||
}
|
||||
|
||||
get connected() { return this._connected }
|
||||
@@ -57,6 +75,27 @@ export class WsClient {
|
||||
get hello() { return this._hello }
|
||||
get sessionKey() { return this._sessionKey }
|
||||
get serverVersion() { return this._serverVersion }
|
||||
get reconnectState() { return this._reconnectState }
|
||||
get reconnectAttempts() { return this._reconnectAttempts }
|
||||
get lastConnectedAt() { return this._lastConnectedAt }
|
||||
get lastMessageAt() { return this._lastMessageAt }
|
||||
|
||||
/**
|
||||
* 获取连接详细信息,供前端使用
|
||||
*/
|
||||
getConnectionInfo() {
|
||||
return {
|
||||
connected: this._connected,
|
||||
gatewayReady: this._gatewayReady,
|
||||
lastConnectedAt: this._lastConnectedAt,
|
||||
lastMessageAt: this._lastMessageAt,
|
||||
reconnectAttempts: this._reconnectAttempts,
|
||||
reconnectState: this._reconnectState,
|
||||
serverVersion: this._serverVersion,
|
||||
missedHeartbeats: this._missedHeartbeats,
|
||||
pendingReconnect: this._pendingReconnect,
|
||||
}
|
||||
}
|
||||
|
||||
onStatusChange(fn) {
|
||||
this._statusListeners.push(fn)
|
||||
@@ -80,12 +119,14 @@ export class WsClient {
|
||||
}
|
||||
if (this._ws && (this._ws.readyState === WebSocket.OPEN || this._ws.readyState === WebSocket.CONNECTING)) return
|
||||
this._url = nextUrl
|
||||
this._lastConnectedAt = Date.now()
|
||||
this._doConnect()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this._intentionalClose = true
|
||||
this._stopPing()
|
||||
this._stopHeartbeat()
|
||||
this._clearReconnectTimer()
|
||||
this._clearChallengeTimer()
|
||||
this._flushPending()
|
||||
@@ -93,6 +134,8 @@ export class WsClient {
|
||||
this._setConnected(false)
|
||||
this._gatewayReady = false
|
||||
this._handshaking = false
|
||||
this._reconnectState = 'idle'
|
||||
this._pendingReconnect = false
|
||||
}
|
||||
|
||||
reconnect() {
|
||||
@@ -100,7 +143,9 @@ export class WsClient {
|
||||
this._intentionalClose = false
|
||||
this._reconnectAttempts = 0
|
||||
this._autoPairAttempts = 0
|
||||
this._missedHeartbeats = 0
|
||||
this._stopPing()
|
||||
this._stopHeartbeat()
|
||||
this._clearReconnectTimer()
|
||||
this._clearChallengeTimer()
|
||||
this._flushPending()
|
||||
@@ -113,6 +158,7 @@ export class WsClient {
|
||||
this._closeWs()
|
||||
this._gatewayReady = false
|
||||
this._handshaking = false
|
||||
this._reconnectState = 'attempting'
|
||||
this._setConnected(false, 'connecting')
|
||||
const wsId = ++this._wsId
|
||||
let ws
|
||||
@@ -123,6 +169,10 @@ export class WsClient {
|
||||
if (wsId !== this._wsId) return
|
||||
this._connecting = false
|
||||
this._reconnectAttempts = 0
|
||||
this._missedHeartbeats = 0
|
||||
this._lastConnectedAt = Date.now()
|
||||
this._lastMessageAt = Date.now()
|
||||
this._startHeartbeat()
|
||||
this._setConnected(true)
|
||||
this._startPing()
|
||||
// 等 Gateway 发 connect.challenge,超时则主动发
|
||||
@@ -171,10 +221,16 @@ export class WsClient {
|
||||
if (!this._intentionalClose) this._scheduleReconnect()
|
||||
}
|
||||
|
||||
ws.onerror = () => {}
|
||||
ws.onerror = (err) => {
|
||||
console.error('[ws] WebSocket 错误:', err)
|
||||
}
|
||||
}
|
||||
|
||||
_handleMessage(msg) {
|
||||
// 更新最后消息时间(用于心跳检测)
|
||||
this._lastMessageAt = Date.now()
|
||||
this._missedHeartbeats = 0
|
||||
|
||||
// 握手阶段:connect.challenge
|
||||
if (msg.type === 'event' && msg.event === 'connect.challenge') {
|
||||
console.log('[ws] 收到 connect.challenge')
|
||||
@@ -228,6 +284,25 @@ export class WsClient {
|
||||
|
||||
// 事件转发
|
||||
if (msg.type === 'event') {
|
||||
// 消息去重检查
|
||||
if (msg.id && this._seenMessageIds.has(msg.id)) {
|
||||
console.log('[ws] 跳过重复消息:', msg.id)
|
||||
return
|
||||
}
|
||||
if (msg.id) {
|
||||
this._seenMessageIds.add(msg.id)
|
||||
// 保持 Set 大小,防止内存泄漏
|
||||
if (this._seenMessageIds.size > 1000) {
|
||||
const arr = Array.from(this._seenMessageIds)
|
||||
this._seenMessageIds = new Set(arr.slice(-500))
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存聊天消息
|
||||
if (msg.event === 'chat.message' && msg.payload?.sessionKey) {
|
||||
this._cacheMessage(msg.payload.sessionKey, msg.payload)
|
||||
}
|
||||
|
||||
this._eventListeners.forEach(fn => {
|
||||
try { fn(msg) } catch (e) { console.error('[ws] handler error:', e) }
|
||||
})
|
||||
@@ -288,6 +363,8 @@ export class WsClient {
|
||||
this._sessionKey = `agent:${agentId}:main`
|
||||
}
|
||||
this._gatewayReady = true
|
||||
this._reconnectState = 'idle'
|
||||
this._pendingReconnect = false
|
||||
console.log('[ws] Gateway 就绪, sessionKey:', this._sessionKey)
|
||||
this._setConnected(true, 'ready')
|
||||
this._readyCallbacks.forEach(fn => {
|
||||
@@ -337,13 +414,36 @@ export class WsClient {
|
||||
}
|
||||
|
||||
_scheduleReconnect() {
|
||||
// 超过最大重连次数,停止重连
|
||||
if (this._reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
console.warn('[ws] 已达到最大重连次数 (', MAX_RECONNECT_ATTEMPTS, '),停止自动重连')
|
||||
this._reconnectState = 'idle'
|
||||
this._pendingReconnect = false
|
||||
this._setConnected(false, 'error', `连接失败,已停止重连。请手动刷新页面重试。`)
|
||||
return
|
||||
}
|
||||
|
||||
this._clearReconnectTimer()
|
||||
const delay = this._reconnectAttempts < 3
|
||||
? 1000
|
||||
: Math.min(1000 * Math.pow(2, this._reconnectAttempts - 2), MAX_RECONNECT_DELAY)
|
||||
// 指数退避:1s, 2s, 4s, 8s, 16s, 32s, 60s (最多 60s)
|
||||
const baseDelay = 2000
|
||||
const maxDelay = MAX_RECONNECT_DELAY
|
||||
const exponentialDelay = Math.min(baseDelay * Math.pow(2, this._reconnectAttempts), maxDelay)
|
||||
// 首次连接(Gateway 可能还未启动):使用更长的初始延迟
|
||||
const delay = this._reconnectAttempts === 0
|
||||
? INITIAL_RECONNECT_DELAY
|
||||
: Math.round(exponentialDelay * (0.5 + Math.random())) // 50%~150% 抖动,防止同步风暴
|
||||
|
||||
this._reconnectAttempts++
|
||||
this._setConnected(false, 'reconnecting')
|
||||
this._reconnectTimer = setTimeout(() => this._doConnect(), delay)
|
||||
this._reconnectState = 'scheduled'
|
||||
this._pendingReconnect = true
|
||||
this._setConnected(false, 'reconnecting', `重连中 (${this._reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}),${Math.round(delay/1000)}秒后...`)
|
||||
console.log(`[ws] 计划重连 (${this._reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}),延迟 ${Math.round(delay/1000)}秒`)
|
||||
this._reconnectTimer = setTimeout(() => {
|
||||
if (!this._intentionalClose) {
|
||||
this._reconnectState = 'attempting'
|
||||
this._doConnect()
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
|
||||
_startPing() {
|
||||
@@ -362,6 +462,45 @@ export class WsClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 心跳检测:如果超过 HEARTBEAT_TIMEOUT 没有收到任何消息,触发重连
|
||||
* 这用于检测 Gateway 端崩溃或网络中断
|
||||
*/
|
||||
_startHeartbeat() {
|
||||
this._stopHeartbeat()
|
||||
this._missedHeartbeats = 0
|
||||
this._heartbeatTimer = setInterval(() => {
|
||||
if (!this._connected || !this._gatewayReady) return
|
||||
|
||||
const now = Date.now()
|
||||
const timeSinceLastMessage = this._lastMessageAt ? now - this._lastMessageAt : 0
|
||||
|
||||
if (timeSinceLastMessage > HEARTBEAT_TIMEOUT) {
|
||||
this._missedHeartbeats++
|
||||
console.warn(`[ws] 心跳超时 (${Math.round(timeSinceLastMessage/1000)}秒),missedHeartbeats: ${this._missedHeartbeats}`)
|
||||
// 增加容忍度:连续 3 次超时(检查间隔 30s × 3 = 约 90s)才强制重连
|
||||
if (this._missedHeartbeats >= 3) {
|
||||
console.error('[ws] 心跳检测失败超过3次,强制重连')
|
||||
this._stopHeartbeat()
|
||||
this.reconnect()
|
||||
} else if (this._missedHeartbeats >= 2) {
|
||||
// 2 次超时:先尝试发 ping 探测,不行再重连
|
||||
console.warn('[ws] 心跳超时 2 次,发送探测 ping...')
|
||||
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
||||
try { this._ws.send('{"type":"ping"}') } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, HEARTBEAT_TIMEOUT / 3) // 每 30 秒检查一次
|
||||
}
|
||||
|
||||
_stopHeartbeat() {
|
||||
if (this._heartbeatTimer) {
|
||||
clearInterval(this._heartbeatTimer)
|
||||
this._heartbeatTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
request(method, params = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this._ws || this._ws.readyState !== WebSocket.OPEN || !this._gatewayReady) {
|
||||
@@ -419,6 +558,104 @@ export class WsClient {
|
||||
this._eventListeners.push(callback)
|
||||
return () => { this._eventListeners = this._eventListeners.filter(fn => fn !== callback) }
|
||||
}
|
||||
|
||||
// ==================== 消息缓存管理 ====================
|
||||
|
||||
/**
|
||||
* 缓存消息
|
||||
* @param {string} sessionKey - 会话 key
|
||||
* @param {object} message - 消息对象
|
||||
*/
|
||||
_cacheMessage(sessionKey, message) {
|
||||
if (!this._messageCache.has(sessionKey)) {
|
||||
this._messageCache.set(sessionKey, [])
|
||||
}
|
||||
const messages = this._messageCache.get(sessionKey)
|
||||
|
||||
// 去重检查(基于消息 ID 或内容哈希)
|
||||
const msgId = message.id || message.messageId
|
||||
if (msgId && messages.some(m => (m.id || m.messageId) === msgId)) {
|
||||
return
|
||||
}
|
||||
|
||||
messages.push({
|
||||
...message,
|
||||
_cachedAt: Date.now(),
|
||||
})
|
||||
|
||||
// 限制缓存大小
|
||||
if (messages.length > this._cacheSize) {
|
||||
messages.splice(0, messages.length - this._cacheSize)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的消息
|
||||
* @param {string} sessionKey - 会话 key
|
||||
* @returns {array} 缓存的消息数组
|
||||
*/
|
||||
_getCachedMessages(sessionKey) {
|
||||
return this._messageCache.get(sessionKey) || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定会话的缓存
|
||||
* @param {string} sessionKey - 会话 key
|
||||
*/
|
||||
_clearCache(sessionKey) {
|
||||
if (sessionKey) {
|
||||
this._messageCache.delete(sessionKey)
|
||||
} else {
|
||||
this._messageCache.clear()
|
||||
}
|
||||
console.log('[ws] 消息缓存已清除:', sessionKey || '全部')
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除消息去重记录
|
||||
*/
|
||||
_clearSeenMessageIds() {
|
||||
this._seenMessageIds.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存状态信息
|
||||
*/
|
||||
getCacheInfo() {
|
||||
const info = {}
|
||||
for (const [key, messages] of this._messageCache) {
|
||||
info[key] = {
|
||||
count: messages.length,
|
||||
oldest: messages[0]?._cachedAt,
|
||||
newest: messages[messages.length - 1]?._cachedAt,
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接成功后自动拉取历史消息(供前端调用)
|
||||
* @param {string} sessionKey - 会话 key
|
||||
* @param {number} limit - 消息数量限制
|
||||
*/
|
||||
async fetchHistoryOnReconnect(sessionKey, limit = 200) {
|
||||
if (!sessionKey || !this._gatewayReady) {
|
||||
return { error: 'not ready' }
|
||||
}
|
||||
try {
|
||||
const history = await this.chatHistory(sessionKey, limit)
|
||||
// 将历史消息缓存起来
|
||||
if (history?.messages) {
|
||||
for (const msg of history.messages) {
|
||||
this._cacheMessage(sessionKey, msg)
|
||||
}
|
||||
}
|
||||
return { history }
|
||||
} catch (e) {
|
||||
console.error('[ws] 拉取历史消息失败:', e)
|
||||
return { error: e.message }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const _g = typeof window !== 'undefined' ? window : globalThis
|
||||
|
||||
Reference in New Issue
Block a user