mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-10 17:42:49 +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:
@@ -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
|
||||
}
|
||||
// 实例切换器
|
||||
|
||||
@@ -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 中
|
||||
|
||||
@@ -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
|
||||
|
||||
37
src/main.js
37
src/main.js
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
// === 访问密码保护(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="关闭提示">×</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) => {
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>') + '</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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
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>
|
||||
`
|
||||
|
||||
@@ -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
@@ -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, '<').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, '<')}<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
|
||||
|
||||
@@ -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>` : ''}
|
||||
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
/* 连接引导遮罩 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* === 移动端顶栏 + 侧边栏 === */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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%));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user