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:
晴天
2026-03-23 20:37:48 +08:00
parent dccb4b4dbf
commit 3687e26d5d
50 changed files with 8055 additions and 2715 deletions

View File

@@ -222,8 +222,7 @@ export function renderSidebar(el) {
// 主题切换
const themeBtn = e.target.closest('#btn-theme-toggle')
if (themeBtn) {
toggleTheme()
renderSidebar(el)
toggleTheme(() => renderSidebar(el))
return
}
// 实例切换器

View File

@@ -21,7 +21,11 @@ export function toast(message, type = 'info', options = {}) {
el.className = `toast ${type}`
const textSpan = document.createElement('span')
textSpan.textContent = message
if (options.html) {
textSpan.innerHTML = message
} else {
textSpan.textContent = message
}
el.appendChild(textSpan)
// 如果有操作按钮,添加到 toast 中

View File

@@ -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
View 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: '微信',
}

View File

@@ -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"/>',
// 军事主题图标

View File

@@ -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, '&#x5c;')

View File

@@ -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) {

View File

@@ -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/responsesOpenAI 兼容)
- **支持模型**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()

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,10 @@
/**
* ClawPanel 入口
*/
// 模块已加载,取消 splash 超时回退(防止假阳性的 "页面加载失败" 提示)
if (window._splashTimer) { clearTimeout(window._splashTimer); window._splashTimer = null }
import { registerRoute, initRouter, navigate, setDefaultRoute } from './router.js'
import { renderSidebar, openMobileSidebar } from './components/sidebar.js'
import { initTheme } from './lib/theme.js'
@@ -26,6 +30,11 @@ import './style/ai-drawer.css'
// 初始化主题
initTheme()
/** HTML 转义,防止 XSS 注入 */
function escapeHtml(str) {
return (str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
// === 访问密码保护Web + 桌面端通用) ===
const isTauri = !!window.__TAURI_INTERNALS__
@@ -511,10 +520,10 @@ function setupGatewayBanner() {
banner.classList.remove('gw-banner-hidden')
banner.innerHTML = `
<div class="gw-banner-content">
<span class="gw-banner-icon">${statusIcon('warn', 16)}</span>
<span class="gw-banner-icon">${statusIcon('info', 16)}</span>
<span>Gateway 未运行</span>
<button class="btn btn-sm btn-primary" id="btn-gw-start" style="margin-left:auto">启动</button>
<a class="btn btn-sm btn-ghost" href="#/services" style="color:inherit;font-size:12px">服务管理</a>
<button class="btn btn-sm btn-secondary" id="btn-gw-start" style="margin-left:auto">启动</button>
<a class="btn btn-sm btn-ghost" href="#/services">服务管理</a>
<button class="gw-banner-close" id="btn-gw-dismiss" title="关闭提示">&times;</button>
</div>
`
@@ -533,13 +542,13 @@ function setupGatewayBanner() {
const errMsg = (err.message || String(err)).slice(0, 120)
banner.innerHTML = `
<div class="gw-banner-content" style="flex-wrap:wrap">
<span class="gw-banner-icon">${statusIcon('warn', 16)}</span>
<span class="gw-banner-icon">${statusIcon('info', 16)}</span>
<span>启动失败</span>
<button class="btn btn-sm btn-primary" id="btn-gw-start" style="margin-left:auto">重试</button>
<a class="btn btn-sm btn-ghost" href="#/services" style="color:inherit;font-size:12px">服务管理</a>
<a class="btn btn-sm btn-ghost" href="#/logs" style="color:inherit;font-size:12px">查看日志</a>
<button class="btn btn-sm btn-secondary" id="btn-gw-start" style="margin-left:auto">重试</button>
<a class="btn btn-sm btn-ghost" href="#/services">服务管理</a>
<a class="btn btn-sm btn-ghost" href="#/logs">查看日志</a>
</div>
<div style="font-size:11px;opacity:0.7;margin-top:4px;font-family:monospace;word-break:break-all">${errMsg}</div>
<div style="font-size:11px;opacity:0.7;margin-top:4px;font-family:monospace;word-break:break-all">${escapeHtml(errMsg)}</div>
`
update(false)
return
@@ -564,10 +573,10 @@ function setupGatewayBanner() {
} catch {}
banner.innerHTML = `
<div class="gw-banner-content">
<span class="gw-banner-icon">${statusIcon('warn', 16)}</span>
<span class="gw-banner-icon">${statusIcon('info', 16)}</span>
<span>启动超时Gateway 可能仍在启动中</span>
<button class="btn btn-sm btn-primary" id="btn-gw-start">重试</button>
<a class="btn btn-sm btn-ghost" href="#/logs" style="color:inherit;text-decoration:underline">查看日志</a>
<button class="btn btn-sm btn-secondary" id="btn-gw-start" style="margin-left:auto">重试</button>
<a class="btn btn-sm btn-ghost" href="#/logs">查看日志</a>
</div>
${logHint}
`
@@ -588,10 +597,10 @@ function showGuardianRecovery() {
<div class="gw-banner-content" style="flex-wrap:wrap;gap:8px">
<span class="gw-banner-icon">${statusIcon('warn', 16)}</span>
<span>Gateway 反复启动失败,可能配置有误</span>
<button class="btn btn-sm btn-primary" id="btn-gw-recover-restart">重试启动</button>
<button class="btn btn-sm btn-secondary" id="btn-gw-recover-restart" style="margin-left:auto">重试启动</button>
<button class="btn btn-sm btn-secondary" id="btn-gw-recover-backup">从备份恢复</button>
<a class="btn btn-sm btn-ghost" href="#/services" style="color:inherit;text-decoration:underline">服务管理</a>
<a class="btn btn-sm btn-ghost" href="#/logs" style="color:inherit;text-decoration:underline">查看日志</a>
<a class="btn btn-sm btn-ghost" href="#/services">服务管理</a>
<a class="btn btn-sm btn-ghost" href="#/logs">查看日志</a>
</div>
`
banner.querySelector('#btn-gw-recover-restart')?.addEventListener('click', async (e) => {

View File

@@ -5,6 +5,7 @@
import { api, invalidate } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { showModal, showConfirm } from '../components/modal.js'
import { CHANNEL_LABELS } from '../lib/channel-labels.js'
export async function render() {
const page = document.createElement('div')
@@ -25,7 +26,7 @@ export async function render() {
</div>
`
const state = { agents: [] }
const state = { agents: [], bindings: [] }
// 非阻塞:先返回 DOM后台加载数据
loadAgents(page, state)
@@ -52,7 +53,12 @@ async function loadAgents(page, state) {
const container = page.querySelector('#agents-list')
renderSkeleton(container)
try {
state.agents = await api.listAgents()
const [agents, config] = await Promise.all([
api.listAgents(),
api.readOpenclawConfig().catch(() => null),
])
state.agents = agents
state.bindings = Array.isArray(config?.bindings) ? config.bindings : []
renderAgents(page, state)
// 只在第一次加载时绑定事件(避免重复绑定)
@@ -61,11 +67,27 @@ async function loadAgents(page, state) {
state.eventsAttached = true
}
} catch (e) {
container.innerHTML = '<div style="color:var(--error);padding:20px">加载失败: ' + e + '</div>'
container.innerHTML = '<div style="color:var(--error);padding:20px">加载失败: ' + String(e).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</div>'
toast('加载 Agent 列表失败: ' + e, 'error')
}
}
/** 为指定 agent 生成绑定渠道的 badge HTML */
function renderBindingBadges(agentId, bindings) {
const matched = (bindings || []).filter(b => (b.agentId || 'main') === agentId)
if (!matched.length) {
return '<span style="color:var(--text-tertiary)">未绑定渠道</span>'
}
return matched.map(b => {
const channel = b.match?.channel || ''
const label = CHANNEL_LABELS[channel] || channel
const accountId = b.match?.accountId
const text = accountId ? `${label} · ${accountId}` : label
const escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
return `<span style="font-size:var(--font-size-xs);color:var(--accent);background:var(--accent-muted);padding:1px 6px;border-radius:10px;white-space:nowrap">${escaped}</span>`
}).join(' ')
}
function renderAgents(page, state) {
const container = page.querySelector('#agents-list')
if (!state.agents.length) {
@@ -102,6 +124,10 @@ function renderAgents(page, state) {
<span class="agent-info-label">工作区:</span>
<span class="agent-info-value" style="font-family:var(--font-mono);font-size:var(--font-size-xs)">${a.workspace || '未设置'}</span>
</div>
<div class="agent-info-row">
<span class="agent-info-label">绑定渠道:</span>
<span class="agent-info-value">${renderBindingBadges(a.id, state.bindings)}</span>
</div>
</div>
</div>
`

View File

@@ -149,7 +149,7 @@ ${personality}
- openclaw skills check — 检查所有 Skills 的依赖是否满足
- Skill 依赖安装: 根据 install spec 执行 brew/npm/go/uv 安装缺少的命令行工具
- ClawHub (clawhub.com): 社区 Skill 市场,可搜索和安装新 Skill
- Skills 目录: 捆绑 Skills 在 openclaw 安装包内,自定义 Skills 放在 ~/.openclaw/skills/<name>/
- Skills 目录: 捆绑 Skills 在 openclaw 安装包内,自定义 Skills 通常位于 ~/.openclaw/skills/<name>/ 或 ~/.claude/skills/<name>/
### 聊天与调试
- openclaw chat — 进入交互式聊天
@@ -442,7 +442,7 @@ const TOOL_DEFS = {
type: 'function',
function: {
name: 'skills_clawhub_install',
description: '从 ClawHub 社区市场安装一个 Skill 到本地 ~/.openclaw/skills/ 目录。',
description: '从 ClawHub 社区市场安装一个 Skill 到本地自定义 Skills 目录(通常为 ~/.openclaw/skills/ 或 ~/.claude/skills/。',
parameters: {
type: 'object',
properties: {
@@ -2468,8 +2468,18 @@ function renderMessages() {
})
}
function _linkify(str) { return str.replace(/(https?:\/\/[^\s,,。;))'"]+)/g, '<a href="$1" target="_blank" style="color:var(--primary)">$1</a>') }
function buildTestResult({ success, elapsed, usedApi, reqUrl, reqBody, respStatus, respBody, reply, error }) {
let html = ''
// 尝试解析 API 返回的错误信息
let apiErrMsg = ''
if (!success && respBody) {
try {
const errJson = JSON.parse(respBody)
apiErrMsg = errJson.error?.message || errJson.message || ''
} catch {}
}
// 状态行
if (error) {
html += `<span style="color:var(--error)">✗ 请求失败: ${escHtml(error)}</span>`
@@ -2478,6 +2488,10 @@ function buildTestResult({ success, elapsed, usedApi, reqUrl, reqBody, respStatu
} else {
html += `<span style="color:var(--warning)">${statusIcon('warn', 14)} HTTP ${respStatus} — 请求完成但未解析到回复内容</span>`
}
// API 错误信息完整展示URL 可点击)
if (apiErrMsg) {
html += `<div style="margin-top:6px;padding:8px 10px;background:var(--bg-tertiary);border-left:3px solid var(--warning);border-radius:4px;font-size:12px;color:var(--text-secondary);line-height:1.6;word-break:break-all">${_linkify(escHtml(apiErrMsg))}</div>`
}
// 回复预览
if (reply) {
const short = reply.length > 80 ? reply.slice(0, 80) + '...' : reply
@@ -2562,18 +2576,29 @@ function showSettings() {
</div>
<div class="form-hint" id="ast-api-hint" style="margin-top:-4px">${apiHintText(c.apiType)}</div>
<div id="ast-qtcool-promo" style="margin-top:14px;border-radius:var(--radius-lg);background:var(--bg-tertiary);border:1px solid var(--border-primary);overflow:hidden">
<div style="padding:14px 16px 10px">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">
${icon('zap', 16)}
<span style="font-weight:600;font-size:var(--font-size-sm)">晴辰云快捷接入</span>
<span style="font-size:10px;background:var(--primary);color:#fff;padding:1px 6px;border-radius:8px">推荐</span>
<div id="ast-qtcool-promo" style="margin-top:14px;border-radius:var(--radius-lg);border:1px solid var(--border-primary);border-left:3px solid var(--primary);background:var(--bg-secondary);overflow:hidden">
<div style="padding:14px 16px 12px">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;margin-bottom:10px">
<div>
<div style="display:flex;align-items:center;gap:6px;margin-bottom:3px">
<span style="font-weight:700;font-size:var(--font-size-sm)">${icon('zap', 14)} 晴辰云</span>
<span style="font-size:10px;background:var(--primary);color:#fff;padding:1px 7px;border-radius:8px">推荐</span>
</div>
<div style="font-size:11px;color:var(--text-tertiary);line-height:1.4">
GPT-5 / Codex 全系列,低至官方价 2-3 折,不满意随时可退
</div>
</div>
<a href="${QTCOOL.checkinUrl}" target="_blank" class="btn btn-primary btn-xs" style="flex-shrink:0">${icon('gift', 11)} 签到领额度</a>
</div>
<div style="font-size:var(--font-size-xs);color:var(--text-secondary);line-height:1.5;margin-bottom:10px">
无需自行申请 API Key选择模型即可一键接入。基础模型免费体验,高级模型低至官方价 2-3 折。
<div style="font-size:var(--font-size-xs);color:var(--text-secondary);margin-bottom:8px">
填入 API Key选择模型即可接入。没有密钥?<a href="${QTCOOL.checkinUrl}" target="_blank" style="color:var(--primary)">每日签到</a> 领取,在 <a href="${QTCOOL.usageUrl}" target="_blank" style="color:var(--primary)">用户后台</a> 复制
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:8px">
<input class="form-input" id="ast-qtcool-key" placeholder="粘贴 API Key" style="font-size:12px;padding:5px 10px;flex:1;min-width:120px">
<input type="checkbox" id="ast-qtcool-customkey" style="display:none">
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<select id="ast-qtcool-model" class="form-input" style="font-size:12px;padding:5px 10px;min-width:140px;flex:1">
<select id="ast-qtcool-model" class="form-input" style="font-size:12px;padding:5px 10px;min-width:130px;flex:1">
<option value="" disabled selected>加载模型列表...</option>
</select>
<button class="btn btn-sm btn-secondary" id="ast-qtcool-test">${icon('search', 12)} 测试</button>
@@ -2581,16 +2606,12 @@ function showSettings() {
</div>
<div id="ast-qtcool-status" style="margin-top:8px;font-size:11px;min-height:16px;line-height:1.5"></div>
</div>
<div style="border-top:1px solid var(--border-primary);padding:8px 16px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:6px;background:var(--bg-secondary)">
<label style="cursor:pointer;display:flex;align-items:center;gap:5px;font-size:11px;color:var(--text-tertiary)">
<input type="checkbox" id="ast-qtcool-customkey" style="accent-color:var(--primary);width:13px;height:13px"> 使用自定义密钥
</label>
<div style="display:flex;gap:12px;font-size:11px">
<a href="${QTCOOL.site}" target="_blank" style="color:var(--primary);text-decoration:none">${icon('external-link', 12)} 了解更多</a>
<div style="border-top:1px solid var(--border-primary);padding:6px 16px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:6px;background:var(--bg-tertiary)">
<div style="display:flex;gap:8px;align-items:center">
<button class="btn btn-xs btn-secondary" id="ast-qtcool-sync-to" title="将助手配置同步到 OpenClaw 全局">${icon('upload', 11)} 同步到 OpenClaw</button>
<button class="btn btn-xs btn-secondary" id="ast-qtcool-sync-from" title="从 OpenClaw 全局配置读取">${icon('download', 11)} 从 OpenClaw 读取</button>
</div>
</div>
<div id="ast-qtcool-keyrow" style="display:none;border-top:1px solid var(--border-primary);padding:8px 16px;background:var(--bg-tertiary)">
<input class="form-input" id="ast-qtcool-key" placeholder="粘贴你的密钥" style="font-size:12px;padding:6px 10px">
<a href="${QTCOOL.site}" target="_blank" style="color:var(--primary);text-decoration:none;font-size:11px">${icon('external-link', 11)} 了解更多</a>
</div>
</div>
</div>
@@ -2934,9 +2955,7 @@ function showSettings() {
// ── gpt.qt.cool 一键配置 ──
const qtcoolModelSelect = overlay.querySelector('#ast-qtcool-model')
const qtcoolCustomKeyCheckbox = overlay.querySelector('#ast-qtcool-customkey')
const qtcoolKeyRow = overlay.querySelector('#ast-qtcool-keyrow')
const qtcoolKeyInput = overlay.querySelector('#ast-qtcool-key')
const qtcoolUsageLink = overlay.querySelector('#ast-qtcool-usage')
// 动态获取模型列表(共享逻辑)
;(async () => {
@@ -2946,14 +2965,7 @@ function showSettings() {
).join('')
})()
qtcoolCustomKeyCheckbox.onchange = () => {
qtcoolKeyRow.style.display = qtcoolCustomKeyCheckbox.checked ? '' : 'none'
if (qtcoolCustomKeyCheckbox.checked) qtcoolKeyInput.focus()
}
qtcoolKeyInput.oninput = () => {
const key = qtcoolKeyInput.value.trim()
qtcoolUsageLink.href = QTCOOL.usageUrl + (key || QTCOOL.defaultKey)
}
// key input is always visible now (no more built-in key)
const qtcoolStatus = overlay.querySelector('#ast-qtcool-status')
// 测试按钮:快速验证接口可用性
@@ -2961,8 +2973,8 @@ function showSettings() {
const btn = e.target
const selectedModel = qtcoolModelSelect.value
if (!selectedModel) { qtcoolStatus.innerHTML = `<span style="color:#fbbf24">${statusIcon('warn', 14)} 请先选择模型</span>`; return }
const customKey = qtcoolCustomKeyCheckbox.checked ? qtcoolKeyInput.value.trim() : ''
const key = customKey || QTCOOL.defaultKey
const key = qtcoolKeyInput.value.trim()
if (!key) { qtcoolStatus.innerHTML = `<span style="color:#fbbf24">${statusIcon('warn', 14)} 请先输入 API Key<a href="${QTCOOL.checkinUrl}" target="_blank" style="color:var(--primary)">签到领取</a></span>`; return }
btn.disabled = true
btn.textContent = '测试中...'
@@ -2982,10 +2994,17 @@ function showSettings() {
qtcoolStatus.innerHTML = `<span style="color:#34d399">${statusIcon('ok', 14)} 测试通过(${(ms/1000).toFixed(1)}s</span><span style="color:rgba(255,255,255,0.4);margin-left:6px">${selectedModel} 响应正常</span>`
} else {
const errText = await resp.text().catch(() => '')
qtcoolStatus.innerHTML = `<span style="color:#f87171">${statusIcon('err', 14)} 测试失败HTTP ${resp.status}</span><span style="color:rgba(255,255,255,0.4);margin-left:6px">${errText.slice(0, 80)}</span>`
let errMsg = `HTTP ${resp.status}`
try {
const errJson = JSON.parse(errText)
if (errJson.error?.message) errMsg = errJson.error.message
} catch { if (errText) errMsg += ' — ' + errText.slice(0, 200) }
// 将 URL 转为可点击链接
const errHtml = errMsg.replace(/(https?:\/\/[^\s,,。))]+)/g, '<a href="$1" target="_blank" style="color:var(--primary)">$1</a>')
qtcoolStatus.innerHTML = `<div style="color:#f87171;line-height:1.5">${statusIcon('err', 14)} <strong>测试失败</strong></div><div style="color:var(--text-secondary);font-size:11px;line-height:1.5;margin-top:4px;word-break:break-all">${errHtml}</div>`
}
} catch (err) {
qtcoolStatus.innerHTML = `<span style="color:#f87171">${statusIcon('err', 14)} 连接失败:${err.message}</span>`
qtcoolStatus.innerHTML = `<div style="color:#f87171">${statusIcon('err', 14)} 连接失败:${err.message}</div>`
}
btn.disabled = false
btn.innerHTML = `${icon('search', 12)} 测试`
@@ -2995,8 +3014,8 @@ function showSettings() {
overlay.querySelector('#ast-qtcool-apply').onclick = async () => {
const selectedModel = qtcoolModelSelect.value
if (!selectedModel) { qtcoolStatus.innerHTML = `<span style="color:#fbbf24">${statusIcon('warn', 14)} 请先选择模型</span>`; return }
const customKey = qtcoolCustomKeyCheckbox.checked ? qtcoolKeyInput.value.trim() : ''
const key = customKey || QTCOOL.defaultKey
const key = qtcoolKeyInput.value.trim()
if (!key) { qtcoolStatus.innerHTML = `<span style="color:#fbbf24">${statusIcon('warn', 14)} 请先输入 API Key<a href="${QTCOOL.checkinUrl}" target="_blank" style="color:var(--primary)">签到领取</a></span>`; return }
// 1) 填充助手配置
overlay.querySelector('#ast-baseurl').value = QTCOOL.baseUrl
@@ -3052,6 +3071,76 @@ function showSettings() {
}
}
// 同步到 OpenClaw将助手的 baseUrl/apiKey/model 写入 openclaw.json
overlay.querySelector('#ast-qtcool-sync-to')?.addEventListener('click', async () => {
const baseUrl = overlay.querySelector('#ast-baseurl').value.trim()
const apiKey = overlay.querySelector('#ast-apikey').value.trim()
const model = overlay.querySelector('#ast-model').value.trim()
if (!baseUrl || !apiKey || !model) {
toast('请先在上方配置好 Base URL、API Key 和模型', 'warning')
return
}
const yes = await showConfirm(
'同步到 OpenClaw',
`将当前助手的模型配置写入 OpenClaw 全局:\n\n• 服务商晴辰云qtcool\n• 模型:${model}\n• 设为全局主模型\n\n此操作会覆盖已有的晴辰云服务商配置并重启 Gateway。`,
{ confirmText: '确认同步', cancelText: '取消' }
)
if (!yes) return
try {
let config = {}
try { config = await api.readOpenclawConfig() } catch {}
if (!config.models) config.models = {}
if (!config.models.providers) config.models.providers = {}
config.models.providers.qtcool = {
baseUrl,
apiKey,
api: 'openai-completions',
models: [{ id: model, name: model, contextWindow: 128000, reasoning: model.includes('codex') }]
}
if (!config.agents) config.agents = {}
if (!config.agents.defaults) config.agents.defaults = {}
if (!config.agents.defaults.model) config.agents.defaults.model = {}
config.agents.defaults.model.primary = 'qtcool/' + model
await api.writeOpenclawConfig(config)
toast('已同步到 OpenClaw 全局配置,主模型: qtcool/' + model, 'success')
try { await api.restartGateway() } catch {}
} catch (e) {
toast('同步失败: ' + e, 'error')
}
})
// 从 OpenClaw 读取:将 openclaw.json 的 qtcool provider 配置填入助手
overlay.querySelector('#ast-qtcool-sync-from')?.addEventListener('click', async () => {
try {
const config = await api.readOpenclawConfig()
const qtProvider = config?.models?.providers?.qtcool
if (!qtProvider?.baseUrl) {
toast('OpenClaw 中尚未配置晴辰云服务商,请先在模型配置页添加', 'info')
return
}
const primary = config?.agents?.defaults?.model?.primary || ''
const primaryModel = primary.startsWith('qtcool/') ? primary.slice(7) : ''
const firstModel = (qtProvider.models || [])[0]
const modelId = primaryModel || (typeof firstModel === 'string' ? firstModel : firstModel?.id) || ''
const yes = await showConfirm(
'从 OpenClaw 读取',
`将 OpenClaw 全局配置填入助手:\n\n• Base URL${qtProvider.baseUrl}\n• API Key${qtProvider.apiKey ? '****' + qtProvider.apiKey.slice(-6) : '(空)'}\n${modelId ? '• 模型:' + modelId : ''}\n\n这会覆盖当前助手的模型配置。`,
{ confirmText: '确认读取', cancelText: '取消' }
)
if (!yes) return
overlay.querySelector('#ast-baseurl').value = qtProvider.baseUrl
if (qtProvider.apiKey) {
overlay.querySelector('#ast-apikey').value = qtProvider.apiKey
qtcoolKeyInput.value = qtProvider.apiKey
}
overlay.querySelector('#ast-apitype').value = qtProvider.api || 'openai-completions'
if (modelId) overlay.querySelector('#ast-model').value = modelId
toast('已从 OpenClaw 读取配置', 'success')
} catch (e) {
toast('读取 OpenClaw 配置失败: ' + e, 'error')
}
})
const resultEl = overlay.querySelector('#ast-test-result')
const modelInput = overlay.querySelector('#ast-model')
const dropdown = overlay.querySelector('#ast-model-dropdown')

File diff suppressed because it is too large Load Diff

View File

@@ -25,20 +25,26 @@ export async function render() {
服务商是模型的来源(如 OpenAI、DeepSeek 等)。每个服务商下可添加多个模型。
标记为「主模型」的将优先使用,其余作为备选自动切换。配置修改后自动保存。
</div>
<div id="qtcool-promo" style="margin-bottom:var(--space-md);border-radius:var(--radius-lg);background:var(--bg-secondary);border:1px solid var(--border-primary);padding:14px 18px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px">
<div style="flex:1;min-width:200px">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">
${icon('zap', 16)}
<span style="font-weight:600;font-size:var(--font-size-sm)">晴辰云</span>
<span style="font-size:10px;background:var(--primary);color:#fff;padding:1px 6px;border-radius:8px">推荐</span>
</div>
<div style="font-size:var(--font-size-xs);color:var(--text-secondary);line-height:1.5">
无需自行注册 API一键添加即可使用。基础模型免费高级模型低至官方价 2-3 折
<div id="qtcool-promo" style="margin-bottom:var(--space-md);border-radius:var(--radius-lg);border:1px solid var(--border-primary);border-left:3px solid var(--primary);background:var(--bg-secondary);padding:16px 20px">
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:12px;margin-bottom:12px">
<div style="flex:1;min-width:200px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
<span style="font-weight:700;font-size:var(--font-size-base);color:var(--text-primary)">${icon('zap', 15)} 晴辰云</span>
<span style="font-size:10px;background:var(--primary);color:#fff;padding:1px 7px;border-radius:8px">推荐</span>
</div>
<div style="font-size:var(--font-size-xs);color:var(--text-secondary);line-height:1.5">
GPT-5 / Codex 全系列,低至官方价 2-3 折,不满意随时可退。
<a href="${QTCOOL.site}" target="_blank" style="color:var(--primary);text-decoration:none">了解更多 →</a>
</div>
</div>
<a href="${QTCOOL.checkinUrl}" target="_blank" class="btn btn-primary btn-sm">${icon('gift', 12)} 每日签到领额度</a>
</div>
<div style="display:flex;gap:8px;align-items:center;flex-shrink:0">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input class="form-input" id="qtcool-apikey" placeholder="粘贴 API Key签到后在用户后台获取" style="font-size:12px;padding:6px 10px;flex:1;min-width:180px">
<button class="btn btn-primary btn-sm" id="btn-qtcool-oneclick">${icon('plus', 14)} 获取模型列表</button>
<a href="${QTCOOL.site}" target="_blank" class="btn btn-secondary btn-sm">${icon('external-link', 12)} 了解更多</a>
</div>
<div style="font-size:11px;color:var(--text-tertiary);margin-top:6px">
没有密钥?前往 <a href="${QTCOOL.checkinUrl}" target="_blank" style="color:var(--primary)">签到页</a> 每日签到即可领取免费额度,在 <a href="${QTCOOL.usageUrl}" target="_blank" style="color:var(--primary)">用户后台</a> 复制你的 Key
</div>
</div>
<div id="default-model-bar"></div>
@@ -755,11 +761,14 @@ function bindTopActions(page, state) {
page.querySelector('#btn-qtcool-oneclick').onclick = async () => {
if (!state.config) { toast('配置未加载完成,请稍候', 'warning'); return }
const bannerKeyInput = page.querySelector('#qtcool-apikey')
const bannerKey = bannerKeyInput ? bannerKeyInput.value.trim() : ''
const btn = page.querySelector('#btn-qtcool-oneclick')
btn.textContent = '获取中...'
btn.disabled = true
const models = await fetchQtcoolModels()
const models = await fetchQtcoolModels(bannerKey || undefined)
btn.innerHTML = `${icon('plus', 14)} 获取模型列表`
btn.disabled = false
@@ -780,6 +789,10 @@ function bindTopActions(page, state) {
<div class="modal" style="max-height:80vh;overflow-y:auto">
<div class="modal-title">选择要添加的模型</div>
<div class="form-hint" style="margin-bottom:12px">从晴辰云获取到 ${models.length} 个可用模型,勾选需要的模型后点击添加。</div>
${!existingProvider ? `<div style="margin-bottom:12px">
<label class="form-label" style="font-size:var(--font-size-xs)">API Key <a href="${QTCOOL.checkinUrl}" target="_blank" style="color:var(--primary);font-weight:400">每日签到领免费额度 →</a></label>
<input class="form-input" id="qtsel-apikey" placeholder="粘贴你的 API Key" style="font-size:12px">
</div>` : ''}
<div style="margin-bottom:12px;display:flex;gap:8px">
<button class="btn btn-sm btn-secondary" id="qtsel-all">全选</button>
<button class="btn btn-sm btn-secondary" id="qtsel-none">全不选</button>
@@ -801,6 +814,9 @@ function bindTopActions(page, state) {
</div>
`
document.body.appendChild(overlay)
// 从横幅预填充 key
const dialogKeyInput = overlay.querySelector('#qtsel-apikey')
if (dialogKeyInput && bannerKey) dialogKeyInput.value = bannerKey
overlay.querySelector('#qtsel-cancel').onclick = () => overlay.remove()
overlay.querySelector('#qtsel-all').onclick = () => {
overlay.querySelectorAll('#qtmodel-list input:not(:disabled)').forEach(cb => cb.checked = true)
@@ -810,9 +826,18 @@ function bindTopActions(page, state) {
}
overlay.querySelector('#qtsel-confirm').onclick = () => {
const selected = [...overlay.querySelectorAll('#qtmodel-list input:checked:not(:disabled)')].map(cb => cb.value)
overlay.remove()
if (!selected.length) { toast('未选择任何模型', 'info'); return }
// 新建服务商时需要 API Key
const keyInput = overlay.querySelector('#qtsel-apikey')
const apiKey = keyInput ? keyInput.value.trim() : ''
if (!existingProvider && !apiKey) {
toast('请输入 API Key可通过每日签到免费获取', 'warning')
keyInput?.focus()
return
}
overlay.remove()
pushUndo(state)
if (!state.config.models) state.config.models = {}
if (!state.config.models.providers) state.config.models.providers = {}
@@ -827,7 +852,7 @@ function bindTopActions(page, state) {
} else {
state.config.models.providers[QTCOOL.providerKey] = {
baseUrl: QTCOOL.baseUrl,
apiKey: QTCOOL.defaultKey,
apiKey: apiKey,
api: QTCOOL.api,
models: selectedModels.map(m => ({ ...m })),
}
@@ -1377,16 +1402,29 @@ async function testModel(btn, state, providerKey, idx) {
model.testStatus = 'ok'
delete model.testError
}
toast(`${modelId} 连通正常 (${(elapsed / 1000).toFixed(1)}s): "${reply.slice(0, 50)}"`, 'success')
// 包含 ⚠ 的是非致命错误429 等),拆分显示
if (reply.startsWith('⚠')) {
const lines = reply.split('\n')
const summary = lines[0]
const detail = lines.slice(1).join('\n').trim()
if (detail) {
const detailHtml = detail.replace(/</g, '&lt;').replace(/(https?:\/\/[^\s,,。;))'"&]+)/g, '<a href="$1" target="_blank" style="color:var(--primary);text-decoration:underline">$1</a>')
toast(`<strong>${modelId}</strong> ${summary.replace(/</g, '&lt;')}<br><span style="font-size:11px;line-height:1.5;word-break:break-all">${detailHtml}</span>`, 'warning', { duration: 10000, html: true })
} else {
toast(`${modelId} ${summary}`, 'warning', { duration: 6000 })
}
} else {
toast(`${modelId} 连通正常 (${(elapsed / 1000).toFixed(1)}s): "${reply.slice(0, 50)}"`, 'success')
}
} catch (e) {
const elapsed = Date.now() - start
if (typeof model === 'object') {
model.latency = null
model.lastTestAt = Date.now()
model.testStatus = 'fail'
model.testError = String(e).slice(0, 100)
model.testError = String(e).slice(0, 200)
}
toast(`${modelId} 不可用 (${(elapsed / 1000).toFixed(1)}s): ${e}`, 'error')
toast(`${modelId} 不可用 (${(elapsed / 1000).toFixed(1)}s): ${e}`, 'error', { duration: 8000 })
} finally {
btn.disabled = false
btn.textContent = origText

View File

@@ -78,19 +78,30 @@ async function loadSkills(page) {
function renderSkills(el, data) {
const skills = data?.skills || []
const cliAvailable = data?.cliAvailable !== false
const source = data?.source || ''
const cliDiag = data?.diagnostic?.cli || null
const eligible = skills.filter(s => s.eligible && !s.disabled)
const missing = skills.filter(s => !s.eligible && !s.disabled && !s.blockedByAllowlist)
const disabled = skills.filter(s => s.disabled)
const blocked = skills.filter(s => s.blockedByAllowlist && !s.disabled)
const summary = `${eligible.length} 可用 / ${missing.length} 缺依赖 / ${disabled.length} 已禁用`
let sourceHint = ''
if (source === 'local-scan') {
if (cliDiag?.status === 'timeout') sourceHint = 'CLI 可用,但本次调用超时,当前显示本地扫描结果'
else if (cliDiag?.status === 'parse-failed') sourceHint = 'CLI 可用,但返回结果解析失败,当前显示本地扫描结果'
else if (cliDiag?.status === 'exec-failed') sourceHint = 'CLI 调用失败,当前显示本地扫描结果'
else sourceHint = cliAvailable ? '当前显示本地扫描结果' : 'CLI 不可用,当前显示本地扫描结果'
} else if (cliAvailable) {
sourceHint = '当前已使用 OpenClaw CLI 结果'
}
el.innerHTML = `
<div class="clawhub-toolbar">
<input class="input clawhub-search-input" id="skill-filter-input" placeholder="过滤 Skills..." type="text">
<button class="btn btn-secondary btn-sm" data-action="skill-retry">刷新</button>
<a class="btn btn-secondary btn-sm" href="https://clawhub.ai/skills" target="_blank" rel="noopener">ClawHub</a>
${!cliAvailable ? '<span class="form-hint" style="margin-left:auto;color:var(--warning)">CLI 不可用,仅显示本地扫描结果</span>' : ''}
${sourceHint ? `<span class="form-hint" style="margin-left:auto;color:${source === 'local-scan' ? 'var(--warning)' : 'var(--text-tertiary)'}">${esc(sourceHint)}</span>` : ''}
</div>
<div class="skills-summary" style="margin-bottom:var(--space-lg);color:var(--text-secondary);font-size:var(--font-size-sm)">
@@ -136,7 +147,7 @@ function renderSkills(el, data) {
<div class="clawhub-panel">
<div class="clawhub-empty" style="text-align:center;padding:var(--space-xl)">
<div style="margin-bottom:var(--space-sm)">未检测到任何 Skills</div>
<div class="form-hint">请确认 OpenClaw 已正确安装。Skills 随 OpenClaw 捆绑提供,也可自定义放置在 <code>~/.openclaw/skills/</code> 目录下。</div>
<div class="form-hint">请确认 OpenClaw 已正确安装。Skills 随 OpenClaw 捆绑提供;自定义 Skills 可能位于 <code>~/.openclaw/skills/</code> 或 <code>~/.claude/skills/</code>。</div>
</div>
</div>` : ''}

View File

@@ -480,17 +480,33 @@
background: var(--bg-hover);
}
/* 断连横幅 */
/* 断连提示:细条 + 中性色,与聊天区融合,不抢视觉焦点 */
.chat-disconnect-bar {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
background: #f59e0b;
color: #000;
font-size: 12px;
font-weight: 500;
gap: 6px;
padding: 4px 10px;
background: var(--bg-tertiary);
color: var(--text-tertiary);
font-size: 11px;
font-weight: 400;
flex-shrink: 0;
border-top: 1px solid var(--border-primary);
letter-spacing: 0.01em;
}
.chat-disconnect-bar::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-tertiary);
opacity: 0.65;
animation: chat-disconnect-pulse 1.4s ease-in-out infinite;
}
@keyframes chat-disconnect-pulse {
0%, 100% { opacity: 0.35; transform: scale(0.92); }
50% { opacity: 0.85; transform: scale(1); }
}
/* 连接引导遮罩 */

View File

@@ -432,48 +432,83 @@
color: #fff;
}
/* Gateway 未启动引导横幅 */
/* Gateway 状态条:低调信息提示,避免高对比琥珀色造成焦虑 */
.gw-banner {
background: linear-gradient(90deg, #fbbf24 0%, #f59e0b 100%);
color: #78350f;
padding: 8px 16px;
font-size: var(--font-size-sm);
background: var(--bg-tertiary);
color: var(--text-secondary);
border-bottom: 1px solid var(--border-primary);
padding: 5px 12px;
font-size: var(--font-size-xs, 12px);
font-weight: 400;
line-height: 1.35;
z-index: 100;
transition: all 300ms ease;
transition: max-height 280ms ease, padding 280ms ease, opacity 200ms ease, border-color 200ms ease;
overflow: hidden;
max-height: 80px;
max-height: 72px;
box-shadow: none;
}
.gw-banner-hidden {
max-height: 0;
padding: 0 16px;
padding: 0 12px;
opacity: 0;
border-bottom-color: transparent;
pointer-events: none;
}
.gw-banner-content {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
flex-wrap: wrap;
}
.gw-banner-icon {
font-size: 16px;
font-size: 14px;
opacity: 0.85;
flex-shrink: 0;
}
.gw-banner-icon svg {
stroke: var(--text-tertiary) !important;
}
.gw-banner-close {
background: none;
border: none;
color: rgba(0,0,0,.5);
font-size: 20px;
color: var(--text-tertiary);
font-size: 18px;
line-height: 1;
cursor: pointer;
padding: 0 4px;
margin-left: 8px;
transition: color .15s;
padding: 0 2px;
margin-left: 4px;
border-radius: 4px;
transition: color .15s, background .15s;
}
.gw-banner-close:hover {
color: var(--text-secondary);
background: var(--bg-glass-hover);
}
.gw-banner-close:hover { color: rgba(0,0,0,.8); }
.gw-banner .btn {
margin-left: auto;
background: rgba(0,0,0,0.15);
border: none;
color: #000;
font-weight: 600;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
color: var(--text-primary);
font-weight: 500;
font-size: var(--font-size-xs, 12px);
padding: 3px 10px;
box-shadow: var(--shadow-sm);
}
.gw-banner .btn:hover {
background: var(--bg-glass-hover);
border-color: var(--border-secondary);
}
.gw-banner .btn-ghost {
border: none !important;
background: transparent !important;
box-shadow: none !important;
color: var(--text-secondary) !important;
font-weight: 400;
text-decoration: underline;
text-underline-offset: 2px;
}
.gw-banner .btn-ghost:hover {
color: var(--text-primary) !important;
background: var(--bg-glass) !important;
}
/* === 移动端顶栏 + 侧边栏 === */

View File

@@ -1147,6 +1147,62 @@
color: var(--text-tertiary);
line-height: 1.4;
}
/* Agent 对接 — 渠道绑定卡片 */
.agent-binding-card {
background: var(--bg-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: var(--space-lg);
margin-bottom: var(--space-md);
}
.agent-binding-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
flex-wrap: wrap;
margin-bottom: var(--space-md);
}
.agent-binding-title {
display: flex;
align-items: center;
gap: var(--space-sm);
font-weight: 600;
font-size: var(--font-size-md);
}
.agent-binding-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.agent-binding-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
flex-wrap: wrap;
padding: var(--space-sm) var(--space-md);
background: var(--bg-tertiary);
border-radius: var(--radius-md);
border: 1px solid var(--border-primary);
}
.agent-binding-row-main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.agent-binding-channel {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--text-primary);
}
.agent-binding-row-actions {
display: flex;
gap: var(--space-xs);
flex-shrink: 0;
}
.platform-pick-badge {
font-size: var(--font-size-xs);
font-weight: 600;

View File

@@ -106,3 +106,28 @@
--sidebar-collapsed: 60px;
--header-height: 52px;
}
/* 主题切换圆形扩散动画 (View Transitions API) */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-new(root) {
z-index: 1;
animation: themeCircleReveal 0.7s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
::view-transition-old(root) {
z-index: 0;
}
@keyframes themeCircleReveal {
from {
clip-path: circle(0% at var(--theme-reveal-x, 0%) var(--theme-reveal-y, 100%));
}
to {
clip-path: circle(150% at var(--theme-reveal-x, 0%) var(--theme-reveal-y, 100%));
}
}