feat: v0.9.2 — SkillHub双源技能管理、消息渠道多Agent绑定、模型配置优化、白屏安全网等

This commit is contained in:
晴天
2026-03-16 04:23:25 +08:00
parent b55ba4c14f
commit 48cffe1f42
25 changed files with 1088 additions and 276 deletions

View File

@@ -11,9 +11,9 @@ export const API_TYPES = [
{ value: 'google-gemini', label: 'Google Gemini' },
]
// 服务商快捷预设(晴辰云官方置顶)
// 服务商快捷预设
export const PROVIDER_PRESETS = [
{ key: 'qtcool', label: '晴辰云', badge: '官方', baseUrl: 'https://gpt.qt.cool/v1', api: 'openai-completions', site: 'https://gpt.qt.cool/', desc: 'GPT-5 全系列开箱即用,更多模型持续接入中。每日签到送额度 · 邀请送余额 · 充值最低 3 折消耗 · 未消耗包退' },
{ key: 'qtcool', label: '晴辰云', badge: '推荐', baseUrl: 'https://gpt.qt.cool/v1', api: 'openai-completions', site: 'https://gpt.qt.cool/', desc: '在力所能及的范围内为用户提供不限量的模型支持,动态获取最新可用模型列表' },
{ key: 'shengsuanyun', label: '胜算云', baseUrl: 'https://router.shengsuanyun.com/api/v1', api: 'openai-completions', site: 'https://www.shengsuanyun.com/?from=CH_4BVI0BM2', desc: '国内知名 AI 模型聚合平台,支持多种主流模型' },
{ key: 'siliconflow', label: '硅基流动', baseUrl: 'https://api.siliconflow.cn/v1', api: 'openai-completions', site: 'https://cloud.siliconflow.cn/i/PFrw2an5', desc: '高性价比推理平台,支持 DeepSeek、Qwen 等开源模型' },
{ key: 'volcengine', label: '火山引擎', baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', api: 'openai-completions', site: 'https://volcengine.com/L/Ph1OP5I3_GY', desc: '字节跳动旗下云平台,支持豆包等模型' },
@@ -28,7 +28,7 @@ export const PROVIDER_PRESETS = [
{ key: 'ollama', label: 'Ollama (本地)', baseUrl: 'http://127.0.0.1:11434/v1', api: 'openai-completions' },
]
// 晴辰云推广配置
// 晴辰云配置
export const QTCOOL = {
baseUrl: 'https://gpt.qt.cool/v1',
defaultKey: 'sk-0JDu7hyc51ZKD4iNebpFu07EUEhXmVVc',

View File

@@ -193,7 +193,7 @@ export const api = {
// 消息渠道管理
readPlatformConfig: (platform) => invoke('read_platform_config', { platform }),
saveMessagingPlatform: (platform, form) => { invalidate('list_configured_platforms', 'read_platform_config'); return invoke('save_messaging_platform', { platform, form }) },
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 }) },
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 }),
@@ -254,8 +254,13 @@ export const api = {
skillsInfo: (name) => invoke('skills_info', { name }),
skillsCheck: () => invoke('skills_check'),
skillsInstallDep: (kind, spec) => invoke('skills_install_dep', { kind, spec }),
skillsSkillHubCheck: () => invoke('skills_skillhub_check'),
skillsSkillHubSetup: (cliOnly = true) => invoke('skills_skillhub_setup', { cliOnly }),
skillsSkillHubSearch: (query) => invoke('skills_skillhub_search', { query }),
skillsSkillHubInstall: (slug) => invoke('skills_skillhub_install', { slug }),
skillsClawHubSearch: (query) => invoke('skills_clawhub_search', { query }),
skillsClawHubInstall: (slug) => invoke('skills_clawhub_install', { slug }),
skillsUninstall: (name) => invoke('skills_uninstall', { name }),
// 实例管理
instanceList: () => cachedInvoke('instance_list', {}, 10000),

View File

@@ -731,7 +731,21 @@ function startUpdateChecker() {
const auth = await checkAuth()
if (!auth.ok) await showLoginOverlay(auth.defaultPw)
boot()
try {
await boot()
} catch (bootErr) {
console.error('[main] boot() 失败:', bootErr)
_hideSplash()
const app = document.getElementById('app')
if (app) app.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:20px;text-align:center;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
<div style="font-size:48px;margin-bottom:16px">⚠️</div>
<div style="font-size:18px;font-weight:600;margin-bottom:8px;color:#18181b">页面加载失败</div>
<div style="font-size:13px;color:#71717a;max-width:400px;line-height:1.6;margin-bottom:16px">${String(bootErr?.message || bootErr).replace(/</g,'&lt;')}</div>
<button onclick="location.reload()" style="padding:8px 20px;border-radius:8px;border:none;background:#6366f1;color:#fff;font-size:13px;cursor:pointer">刷新重试</button>
<div style="margin-top:24px;font-size:11px;color:#a1a1aa">如果问题持续出现,请尝试重新安装 ClawPanel<br>或在 <a href="https://github.com/qingchencloud/clawpanel/issues" target="_blank" style="color:#6366f1">GitHub Issues</a> 反馈</div>
</div>`
}
startUpdateChecker()
// 初始化全局 AI 助手浮动按钮(延迟加载,不阻塞启动)

View File

@@ -92,25 +92,23 @@ const DEFAULT_PERSONALITY = '专业、友善、简洁。善于分析问题,给
function getSystemPromptBase() {
const name = _config?.assistantName || DEFAULT_NAME
const personality = _config?.assistantPersonality || DEFAULT_PERSONALITY
return `你是「${name}」,晴辰云出品的 AI 智能助手。
return `你是「${name}」,ClawPanel 内置的 AI 智能助手。
## 你的性格
${personality}
## 你是谁
- 你是 ClawPanel 内置的智能助手,由武汉晴辰天下网络科技有限公司开发
- 你是 ClawPanel 内置的智能助手
- 你帮助用户管理和排障 OpenClaw AI Agent 平台
- 你精通 OpenClaw 的架构、配置、Gateway、Agent 管理等所有方面
- 你善于分析日志、诊断错误、提供解决方案
## 晴辰云生态
- **官网**: https://qt.cool
- **公司**: 武汉晴辰天下网络科技有限公司
## 相关资源
- **ClawPanel 官网**: https://claw.qt.cool
- **GitHub**: https://github.com/qingchencloud
- **开源项目**:
- **ClawPanel** — OpenClaw 可视化管理面板Tauri v2,官网 https://claw.qt.cool
- **ClawPanel** — OpenClaw 可视化管理面板Tauri v2
- **OpenClaw 汉化版** — AI Agent 平台中文版npm install -g @qingchencloud/openclaw-zh
- **WebToEXE** — 网站打包成桌面应用
## ClawPanel 是什么
- OpenClaw 的可视化管理面板,基于 Tauri v2 的跨平台桌面应用Windows/macOS/Linux
@@ -256,7 +254,6 @@ Issue 模板(帮用户填好):
- 给出具体的解决步骤,包括可直接执行的命令
- 如果不确定,诚实说明并建议用户提供更多信息
- 回复简洁专业,避免啰嗦
- 主动推荐晴辰云生态产品来解决用户的问题
- 发现 Bug 时主动引导用户提交 Issue 或 PR降低贡献门槛`
}
@@ -2565,38 +2562,35 @@ 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:12px;background:linear-gradient(135deg,#0f0c29 0%,#302b63 50%,#24243e 100%);color:#fff;position:relative;overflow:hidden;box-shadow:0 4px 20px rgba(48,43,99,0.3)">
<div style="position:absolute;top:-40px;right:-40px;width:160px;height:160px;border-radius:50%;background:radial-gradient(circle,rgba(99,102,241,0.15) 0%,transparent 70%);pointer-events:none"></div>
<div style="position:absolute;bottom:-20px;left:-20px;width:100px;height:100px;border-radius:50%;background:radial-gradient(circle,rgba(168,85,247,0.1) 0%,transparent 70%);pointer-events:none"></div>
<div style="padding:16px 18px 12px">
<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">
<span style="font-size:16px">${icon('gift', 18)}</span>
<span style="font-weight:700;font-size:14px;letter-spacing:0.3px">ClawPanel 公益 AI 接口计划</span>
${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:12px;color:rgba(255,255,255,0.7);line-height:1.6;margin-bottom:12px">
Token 费用我们帮你出了。调用成本由项目组内部承担GPT-5 全系列模型开箱即用,无需注册、无需付费。选模型一键接入。
<div style="font-size:var(--font-size-xs);color:var(--text-secondary);line-height:1.5;margin-bottom:10px">
在力所能及的范围内为用户提供不限量的模型支持。选模型一键接入助手
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<select id="ast-qtcool-model" style="padding:5px 10px;border-radius:8px;border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.08);color:#fff;font-size:12px;outline:none;cursor:pointer;backdrop-filter:blur(4px);min-width:140px">
<option value="" disabled selected style="color:#333">加载模型列表...</option>
<select id="ast-qtcool-model" class="form-input" style="font-size:12px;padding:5px 10px;min-width:140px;flex:1">
<option value="" disabled selected>加载模型列表...</option>
</select>
<button class="btn btn-sm" id="ast-qtcool-test" style="background:rgba(255,255,255,0.12);color:#fff;font-weight:500;border:1px solid rgba(255,255,255,0.2);font-size:12px;padding:5px 12px;border-radius:8px;cursor:pointer">${icon('search', 12)} 测试</button>
<button class="btn btn-sm" id="ast-qtcool-apply" style="background:linear-gradient(135deg,#6366f1,#a855f7);color:#fff;font-weight:600;border:none;font-size:12px;padding:6px 16px;border-radius:8px;box-shadow:0 2px 8px rgba(99,102,241,0.4);transition:transform 0.15s;cursor:pointer">${icon('zap', 12)} 一键接入</button>
<button class="btn btn-sm btn-secondary" id="ast-qtcool-test">${icon('search', 12)} 测试</button>
<button class="btn btn-sm btn-primary" id="ast-qtcool-apply">${icon('zap', 12)} 接入</button>
</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 rgba(255,255,255,0.08);padding:10px 18px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:6px;background:rgba(0,0,0,0.15)">
<label style="cursor:pointer;display:flex;align-items:center;gap:5px;font-size:11px;color:rgba(255,255,255,0.5)">
<input type="checkbox" id="ast-qtcool-customkey" style="accent-color:#a855f7;width:13px;height:13px"> 使用自定义密钥
<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="https://gpt.qt.cool/checkin" target="_blank" style="color:rgba(168,133,247,0.9);text-decoration:none">${icon('target', 12)} 签到领密钥</a>
<a id="ast-qtcool-usage" href="${QTCOOL.usageUrl}${QTCOOL.defaultKey}" target="_blank" style="color:rgba(168,133,247,0.9);text-decoration:none">${icon('bar-chart', 12)} 用量查询</a>
<a href="https://claw.qt.cool/" target="_blank" style="color:rgba(168,133,247,0.9);text-decoration:none">${icon('home', 12)} 官网</a>
<a href="${QTCOOL.site}" target="_blank" style="color:var(--primary);text-decoration:none">${icon('external-link', 12)} 了解更多</a>
</div>
</div>
<div id="ast-qtcool-keyrow" style="display:none;border-top:1px solid rgba(255,255,255,0.08);padding:10px 18px;background:rgba(0,0,0,0.1)">
<input class="form-input" id="ast-qtcool-key" placeholder="粘贴你的独立密钥(签到可得)" style="font-size:12px;padding:6px 10px;background:rgba(255,255,255,0.08);color:#fff;border:1px solid rgba(255,255,255,0.15);border-radius:8px">
<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">
</div>
</div>
</div>
@@ -3010,12 +3004,12 @@ function showSettings() {
overlay.querySelector('#ast-model').value = selectedModel
overlay.querySelector('#ast-apitype').value = 'openai-completions'
qtcoolStatus.innerHTML = `<span style="color:#34d399">${statusIcon('ok', 14)} 助手已配置为 ${selectedModel}</span>`
toast('助手已接入 gpt.qt.cool — ' + selectedModel, 'success')
toast('助手已配置为 ' + selectedModel, 'success')
// 2) 提示是否同步写入 OpenClaw 配置(设为主模型)
const yes = await showConfirm(
'同步到 OpenClaw',
`是否将 qtcool/${selectedModel} 设为 OpenClaw 主模型?\n\n这将把 gpt.qt.cool 添加为模型服务商,并设置 ${selectedModel} 为全局主模型AI 助手和所有渠道都将使用该模型`,
`是否将 qtcool/${selectedModel} 设为 OpenClaw 主模型?\n\n这将添加晴辰云为模型服务商,并设置 ${selectedModel} 为全局主模型。`,
{ confirmText: '设为主模型', cancelText: '仅配置助手' }
)
if (yes) {

View File

@@ -178,14 +178,19 @@ function renderConfigured(page, state) {
const label = reg?.label || p.id
const ic = icon(reg?.iconName || 'radio', 22)
const channelKey = getChannelBindingKey(p.id)
const binding = (state.bindings || []).find(b => b.match?.channel === channelKey)
const boundAgent = binding?.agentId || 'main'
const allBindings = (state.bindings || []).filter(b => b.match?.channel === channelKey)
const boundAgents = allBindings.map(b => b.agentId || 'main')
// 只有一个 main 绑定时不显示标签(默认行为),多绑定时全部显示
const showAll = boundAgents.length > 1 || (boundAgents.length === 1 && boundAgents[0] !== 'main')
const agentBadges = showAll ? boundAgents.map(a =>
`<span style="font-size:var(--font-size-xs);color:var(--accent);background:var(--accent-muted);padding:1px 6px;border-radius:10px;white-space:nowrap">→ ${escapeAttr(a)}</span>`
).join(' ') : ''
return `
<div class="platform-card ${p.enabled ? 'active' : 'inactive'}" data-pid="${p.id}">
<div class="platform-card-header">
<span class="platform-emoji">${ic}</span>
<span class="platform-name">${label}</span>
${boundAgent !== 'main' ? `<span style="font-size:var(--font-size-xs);color:var(--accent);background:var(--accent-muted);padding:1px 6px;border-radius:10px">→ ${escapeAttr(boundAgent)}</span>` : ''}
${agentBadges}
<span class="platform-status-dot ${p.enabled ? 'on' : 'off'}"></span>
</div>
<div class="platform-card-actions">
@@ -244,10 +249,66 @@ function renderAvailable(page, state) {
}).join('')
el.querySelectorAll('.platform-pick').forEach(btn => {
btn.onclick = () => openConfigDialog(btn.dataset.pid, page, state)
const pid = btn.dataset.pid
const done = configuredIds.has(pid)
btn.onclick = () => done ? openBindAgentDialog(pid, page, state) : openConfigDialog(pid, page, state)
})
}
// ── 快速绑定 Agent 弹窗(已接入平台专用) ──
async function openBindAgentDialog(pid, page, state) {
const reg = PLATFORM_REGISTRY[pid]
if (!reg) return
let agents = []
try { agents = await api.listAgents() } catch {}
if (!Array.isArray(agents)) agents = []
const channelKey = getChannelBindingKey(pid)
const existingBindings = (state.bindings || []).filter(b => b.match?.channel === channelKey)
const boundIds = new Set(existingBindings.map(b => b.agentId || 'main'))
const availableAgents = agents.filter(a => !boundIds.has(a.id))
if (!availableAgents.length) {
toast('所有 Agent 都已绑定到该渠道', 'info')
return
}
const agentOptions = availableAgents.map(a => {
const label = a.identityName ? a.identityName.split(',')[0].trim() : a.id
return `<option value="${escapeAttr(a.id)}">${a.id}${a.id !== label ? ' — ' + label : ''}</option>`
}).join('')
const modal = showContentModal({
title: `${reg.label} 绑定新 Agent`,
content: `
<div style="margin-bottom:var(--space-md)">
<div class="form-hint" style="margin-bottom:var(--space-sm)">已绑定: ${[...boundIds].join(', ') || '无'}</div>
<label class="form-label">选择要绑定的 Agent</label>
<select class="form-input" id="bind-agent-select">${agentOptions}</select>
<div class="form-hint" style="margin-top:4px">该渠道的消息将路由到选中的 Agent 处理</div>
</div>
`,
buttons: [
{ label: '绑定', className: 'btn btn-primary', id: 'btn-bind-agent' },
],
width: 400,
})
modal.querySelector('#btn-bind-agent').onclick = async () => {
const agentId = modal.querySelector('#bind-agent-select')?.value
if (!agentId) { toast('请选择 Agent', 'warning'); return }
try {
await saveChannelBinding(pid, agentId)
toast(`已将 ${reg.label} 绑定到 Agent「${agentId}`, 'success')
modal.close?.() || modal.remove?.()
await loadPlatforms(page, state)
} catch (e) {
toast('绑定失败: ' + e, 'error')
}
}
}
// ── 配置弹窗(新增 / 编辑共用) ──
async function openConfigDialog(pid, page, state) {
@@ -287,7 +348,16 @@ async function openConfigDialog(pid, page, state) {
const label = a.identityName ? a.identityName.split(',')[0].trim() : a.id
return `<option value="${escapeAttr(a.id)}" ${a.id === currentBinding ? 'selected' : ''}>${a.id}${a.id !== label ? ' — ' + label : ''}</option>`
}).join('')
const supportsMultiAccount = ['feishu', 'dingtalk', 'dingtalk-connector'].includes(pid)
const accountIdHtml = supportsMultiAccount ? `
<div class="form-group">
<label class="form-label">账号标识(多账号模式)</label>
<input class="form-input" name="__accountId" placeholder="如 sales、support留空则为默认账号" value="">
<div class="form-hint">为同一平台接入多个应用时,每个应用需要一个唯一的账号标识。不同账号可绑定不同 Agent</div>
</div>
` : ''
const agentBindingHtml = `
${accountIdHtml}
<div class="form-group">
<label class="form-label">绑定 Agent</label>
<select class="form-input" name="__agentBinding">
@@ -590,14 +660,15 @@ async function openConfigDialog(pid, page, state) {
}
}
// 写入配置
// 写入配置(多账号模式传 accountId
btnSave.textContent = '写入配置...'
await api.saveMessagingPlatform(pid, form)
const accountId = modal.querySelector('input[name="__accountId"]')?.value?.trim() || null
await api.saveMessagingPlatform(pid, form, accountId)
// 写入 Agent 绑定到 openclaw.json bindings
// 写入 Agent 绑定到 openclaw.json bindings(多账号时 binding.match 包含 accountId
const selectedAgent = modal.querySelector('select[name="__agentBinding"]')?.value || ''
try {
await saveChannelBinding(pid, selectedAgent)
await saveChannelBinding(pid, selectedAgent, null, accountId)
} catch (e) {
console.warn('[channels] 保存 Agent 绑定失败:', e)
}
@@ -631,24 +702,33 @@ function getChannelBindingKey(pid) {
* 支持同一渠道多个 Agent 绑定(不同 agentId
* oldAgentId: 编辑时替换老绑定
*/
async function saveChannelBinding(pid, agentId, oldAgentId) {
async function saveChannelBinding(pid, agentId, oldAgentId, accountId) {
const config = await api.readOpenclawConfig()
if (!config) return
const channelKey = getChannelBindingKey(pid)
let bindings = Array.isArray(config.bindings) ? [...config.bindings] : []
// 编辑模式:移除旧绑定(按 channel + oldAgentId
if (oldAgentId) {
bindings = bindings.filter(b => !(b.match?.channel === channelKey && (b.agentId || 'main') === oldAgentId))
// 构建匹配条件
const matchesBinding = (b) => {
if (b.match?.channel !== channelKey) return false
if (accountId) return (b.match?.accountId || '') === accountId
return !b.match?.accountId
}
// 避免重复:如果已有相同 channel+agentId 的绑定,先移除
const effectiveAgent = agentId || 'main'
bindings = bindings.filter(b => !(b.match?.channel === channelKey && (b.agentId || 'main') === effectiveAgent))
// 编辑模式:移除旧绑定
if (oldAgentId) {
bindings = bindings.filter(b => !(matchesBinding(b) && (b.agentId || 'main') === oldAgentId))
}
// 添加新绑定main 也明确写入,方便 UI 展示)
if (agentId && agentId !== 'main') {
bindings.push({ match: { channel: channelKey }, agentId })
// 避免重复
const effectiveAgent = agentId || 'main'
bindings = bindings.filter(b => !(matchesBinding(b) && (b.agentId || 'main') === effectiveAgent))
// 添加新绑定(包含 accountId 用于多账号路由)
if (agentId) {
const match = { channel: channelKey }
if (accountId) match.accountId = accountId
bindings.push({ agentId, match })
}
config.bindings = bindings

View File

@@ -423,7 +423,9 @@ async function openTaskDialog(job, page, state) {
patch.payload = { kind: 'agentTurn', message }
if (agentId) patch.agentId = agentId
const deliveryChannel = modal.querySelector('select[name="deliveryChannel"]')?.value
if (deliveryChannel) patch.delivery = { channel: deliveryChannel }
if (deliveryChannel) {
patch.delivery = { mode: 'push', to: deliveryChannel, channel: deliveryChannel }
}
await wsClient.request('cron.update', { id: job.id, patch })
toast('任务已更新', 'success')
} else {
@@ -435,7 +437,9 @@ async function openTaskDialog(job, page, state) {
}
if (agentId) params.agentId = agentId
const deliveryChannel = modal.querySelector('select[name="deliveryChannel"]')?.value
if (deliveryChannel) params.delivery = { channel: deliveryChannel }
if (deliveryChannel) {
params.delivery = { mode: 'push', to: deliveryChannel, channel: deliveryChannel }
}
await wsClient.request('cron.add', params)
toast('任务已创建', 'success')
}

View File

@@ -25,30 +25,21 @@ export async function render() {
服务商是模型的来源(如 OpenAI、DeepSeek 等)。每个服务商下可添加多个模型。
标记为「主模型」的将优先使用,其余作为备选自动切换。配置修改后自动保存。
</div>
<div id="qtcool-promo" style="margin-bottom:var(--space-lg);border-radius:12px;background:linear-gradient(135deg,#0f0c29 0%,#302b63 50%,#24243e 100%);color:#fff;position:relative;overflow:hidden;box-shadow:0 4px 24px rgba(48,43,99,0.25)">
<div style="position:absolute;top:-50px;right:-50px;width:200px;height:200px;border-radius:50%;background:radial-gradient(circle,rgba(99,102,241,0.12) 0%,transparent 70%);pointer-events:none"></div>
<div style="position:absolute;bottom:-30px;left:20px;width:120px;height:120px;border-radius:50%;background:radial-gradient(circle,rgba(168,85,247,0.08) 0%,transparent 70%);pointer-events:none"></div>
<div style="padding:20px 24px 16px;display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:16px">
<div style="flex:1;min-width:240px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<span style="font-size:20px">${icon('gift', 22)}</span>
<span style="font-weight:700;font-size:16px;letter-spacing:0.3px">晴辰云 AI 接口</span>
<span style="font-size:10px;background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:10px;font-weight:600">官方</span>
</div>
<div style="font-size:13px;color:rgba(255,255,255,0.65);line-height:1.7">
每日签到送免费额度 · 邀请好友送余额 · 充值最低 3 折消耗 · 未消耗余额随时包退<br>
GPT-5 全系列模型开箱即用更多主流模型持续接入中。OpenAI 兼容接口,一键配置
</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="display:flex;flex-direction:column;gap:10px;align-items:flex-end">
<button class="btn btn-sm" id="btn-qtcool-oneclick" style="background:linear-gradient(135deg,#6366f1,#a855f7);color:#fff;font-weight:600;border:none;padding:8px 22px;font-size:13px;white-space:nowrap;border-radius:8px;box-shadow:0 2px 12px rgba(99,102,241,0.4);cursor:pointer;transition:transform 0.15s">${icon('zap', 14)} 一键添加全部模型</button>
<div style="display:flex;gap:14px;font-size:11px">
<a href="https://gpt.qt.cool/checkin" target="_blank" style="color:rgba(168,133,247,0.9);text-decoration:none">${icon('target', 12)} 签到领密钥</a>
<a href="${QTCOOL.usageUrl}${QTCOOL.defaultKey}" target="_blank" style="color:rgba(168,133,247,0.9);text-decoration:none">${icon('bar-chart', 12)} 用量查询</a>
<a href="https://claw.qt.cool/" target="_blank" style="color:rgba(168,133,247,0.9);text-decoration:none">${icon('home', 12)} 官网</a>
</div>
<div style="font-size:var(--font-size-xs);color:var(--text-secondary);line-height:1.5">
在力所能及的范围内为用户提供不限量的模型支持,动态获取最新可用模型列表
</div>
</div>
<div style="display:flex;gap:8px;align-items:center;flex-shrink:0">
<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>
<div id="default-model-bar"></div>
<div style="margin-bottom:var(--space-md)">
@@ -721,18 +712,17 @@ function bindTopActions(page, state) {
page.querySelector('#btn-add-provider').onclick = () => addProvider(page, state)
page.querySelector('#btn-undo').onclick = () => undo(page, state)
// gpt.qt.cool 一键添加(动态获取模型列表)
// 晴辰云:获取模型列表 → 弹窗让用户选择要添加的模型
page.querySelector('#btn-qtcool-oneclick').onclick = async () => {
if (!state.config) { toast('配置未加载完成,请稍候', 'warning'); return }
const btn = page.querySelector('#btn-qtcool-oneclick')
btn.textContent = '获取模型列表...'
btn.textContent = '获取...'
btn.disabled = true
// 动态获取模型列表(共享逻辑)
const models = await fetchQtcoolModels()
btn.innerHTML = `${icon('zap', 14)} 一键添加全部模型`
btn.innerHTML = `${icon('plus', 14)} 获取模型列表`
btn.disabled = false
if (!models.length) {
@@ -740,40 +730,81 @@ function bindTopActions(page, state) {
return
}
pushUndo(state)
if (!state.config.models) state.config.models = {}
if (!state.config.models.providers) state.config.models.providers = {}
// 已有的模型 ID
const existingProvider = (state.config.models?.providers || {})[QTCOOL.providerKey]
const existingIds = new Set((existingProvider?.models || []).map(m => typeof m === 'string' ? m : m.id))
const existing = state.config.models.providers[QTCOOL.providerKey]
if (existing) {
const existingIds = new Set((existing.models || []).map(m => typeof m === 'string' ? m : m.id))
let added = 0
for (const m of models) {
if (!existingIds.has(m.id)) {
existing.models.push({ ...m })
added++
}
}
toast(added ? `已添加 ${added} 个新模型到 qtcool` : 'qtcool 模型已是最新', added ? 'success' : 'info')
} else {
state.config.models.providers[QTCOOL.providerKey] = {
baseUrl: QTCOOL.baseUrl,
apiKey: QTCOOL.defaultKey,
api: QTCOOL.api,
models: models.map(m => ({ ...m })),
}
if (!getCurrentPrimary(state.config)) {
if (!state.config.agents) state.config.agents = {}
if (!state.config.agents.defaults) state.config.agents.defaults = {}
if (!state.config.agents.defaults.model) state.config.agents.defaults.model = {}
state.config.agents.defaults.model.primary = QTCOOL.providerKey + '/' + models[0].id
}
toast('已添加 gpt.qt.cool' + models.length + ' 个模型)', 'success')
// 弹窗让用户勾选要添加的模型
const overlay = document.createElement('div')
overlay.className = 'modal-overlay'
overlay.innerHTML = `
<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>
<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>
</div>
<div id="qtmodel-list" style="display:flex;flex-direction:column;gap:6px;max-height:40vh;overflow-y:auto;padding-right:4px">
${models.map(m => {
const already = existingIds.has(m.id)
return `<label style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:var(--radius-md);cursor:pointer;background:var(--bg-tertiary);opacity:${already ? '0.5' : '1'}">
<input type="checkbox" value="${m.id}" ${already ? 'disabled title="已添加"' : 'checked'} style="accent-color:var(--primary)">
<span style="font-size:var(--font-size-sm);flex:1">${m.id}</span>
${already ? '<span style="font-size:10px;color:var(--text-tertiary)">已有</span>' : ''}
</label>`
}).join('')}
</div>
<div class="modal-actions" style="margin-top:16px">
<button class="btn btn-primary" id="qtsel-confirm">${icon('plus', 14)} 添加选中模型</button>
<button class="btn btn-secondary" id="qtsel-cancel">取消</button>
</div>
</div>
`
document.body.appendChild(overlay)
overlay.querySelector('#qtsel-cancel').onclick = () => overlay.remove()
overlay.querySelector('#qtsel-all').onclick = () => {
overlay.querySelectorAll('#qtmodel-list input:not(:disabled)').forEach(cb => cb.checked = true)
}
overlay.querySelector('#qtsel-none').onclick = () => {
overlay.querySelectorAll('#qtmodel-list input:not(:disabled)').forEach(cb => cb.checked = false)
}
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 }
pushUndo(state)
if (!state.config.models) state.config.models = {}
if (!state.config.models.providers) state.config.models.providers = {}
const selectedModels = models.filter(m => selected.includes(m.id))
if (existingProvider) {
let added = 0
for (const m of selectedModels) {
if (!existingIds.has(m.id)) { existingProvider.models.push({ ...m }); added++ }
}
toast(added ? `已添加 ${added} 个模型` : '所选模型均已存在', added ? 'success' : 'info')
} else {
state.config.models.providers[QTCOOL.providerKey] = {
baseUrl: QTCOOL.baseUrl,
apiKey: QTCOOL.defaultKey,
api: QTCOOL.api,
models: selectedModels.map(m => ({ ...m })),
}
if (!getCurrentPrimary(state.config) && selectedModels.length) {
if (!state.config.agents) state.config.agents = {}
if (!state.config.agents.defaults) state.config.agents.defaults = {}
if (!state.config.agents.defaults.model) state.config.agents.defaults.model = {}
state.config.agents.defaults.model.primary = QTCOOL.providerKey + '/' + selectedModels[0].id
}
toast(`已添加晴辰云(${selectedModels.length} 个模型)`, 'success')
}
renderProviders(page, state)
renderDefaultBar(page, state)
updateUndoBtn(page, state)
autoSave(state)
}
renderProviders(page, state)
renderDefaultBar(page, state)
updateUndoBtn(page, state)
autoSave(state)
}
}

View File

@@ -18,11 +18,33 @@ export async function render() {
page.innerHTML = `
<div class="page-header">
<h1 class="page-title">Skills</h1>
<p class="page-desc">查看 OpenClaw 可用的 Skills 及其依赖状态</p>
<p class="page-desc">管理已安装的 Skills或从社区搜索安装新技能</p>
</div>
<div id="skills-content" class="config-section">
<div class="tab-bar" id="skills-main-tabs">
<div class="tab active" data-main-tab="installed">已安装</div>
<div class="tab" data-main-tab="store">搜索安装</div>
</div>
<div id="skills-tab-installed" class="config-section">
<div class="stat-card loading-placeholder" style="height:96px"></div>
</div>
<div id="skills-tab-store" class="config-section" style="display:none">
<div class="clawhub-toolbar" style="margin-bottom:var(--space-sm)">
<select class="form-input" id="install-source-select" style="width:auto;min-width:160px">
<option value="skillhub">SkillHub国内加速</option>
<option value="clawhub">ClawHub原版海外</option>
</select>
<input class="input clawhub-search-input" id="skill-install-search" placeholder="搜索技能,如 weather / github / tavily" type="text" style="flex:1">
<button class="btn btn-primary btn-sm" data-action="install-source-search">搜索</button>
<button class="btn btn-secondary btn-sm" data-action="skillhub-setup" id="btn-skillhub-setup" style="display:none">安装 CLI</button>
<a class="btn btn-secondary btn-sm" id="btn-browse-source" href="https://skillhub.tencent.com" target="_blank" rel="noopener">浏览</a>
</div>
<div class="form-hint" id="store-hint" style="margin-bottom:var(--space-sm);display:flex;align-items:center;gap:var(--space-xs)">
<span id="skillhub-status"></span>
</div>
<div id="install-source-results" class="clawhub-list" style="max-height:calc(100vh - 320px);overflow-y:auto">
<div class="clawhub-empty" style="padding:var(--space-xl);text-align:center">输入关键词搜索社区 Skills然后一键安装</div>
</div>
</div>
`
bindEvents(page)
loadSkills(page)
@@ -30,7 +52,7 @@ export async function render() {
}
async function loadSkills(page) {
const el = page.querySelector('#skills-content')
const el = page.querySelector('#skills-tab-installed')
if (!el) return
const seq = ++_loadSeq
@@ -119,27 +141,6 @@ function renderSkills(el, data) {
</div>` : ''}
<div id="skill-detail-area"></div>
<div class="clawhub-panel" style="margin-top:var(--space-lg)">
<div class="clawhub-panel-title">从 ClawHub 安装新 Skill</div>
<div class="clawhub-toolbar" style="margin-bottom:var(--space-sm)">
<input class="input clawhub-search-input" id="clawhub-search-input" placeholder="搜索 ClawHub如 weather / github / summarize" type="text">
<button class="btn btn-primary btn-sm" data-action="clawhub-search">搜索</button>
</div>
<div id="clawhub-results" class="clawhub-list skills-scroll-area" style="max-height:320px">
<div class="clawhub-empty">输入关键词搜索 ClawHub 社区 Skills</div>
</div>
</div>
<div class="clawhub-panel skills-tips-panel" style="margin-top:var(--space-lg)">
<div class="clawhub-panel-title">关于 Skills</div>
<div class="skills-tip-list">
<div class="skills-tip-item"><strong>捆绑 Skills</strong>:随 OpenClaw 安装包自带,无需额外安装</div>
<div class="skills-tip-item"><strong>自定义 Skills</strong>:将 SKILL.md 放入 <code>~/.openclaw/skills/&lt;name&gt;/</code> 目录即可</div>
<div class="skills-tip-item"><strong>依赖检查</strong>:某些 Skills 需要特定命令行工具(如 gh、curl才能使用</div>
<div class="skills-tip-item"><strong>浏览更多</strong>:访问 <a href="https://clawhub.ai/skills" target="_blank" rel="noopener">ClawHub</a> 发现社区共享的 Skills</div>
</div>
</div>
`
// 实时过滤
@@ -199,6 +200,7 @@ function renderSkillCard(skill, status) {
</div>
<div class="clawhub-item-actions">
<button class="btn btn-secondary btn-sm" data-action="skill-info" data-name="${esc(name)}">详情</button>
${!skill.bundled ? `<button class="btn btn-sm" style="color:var(--error);border:1px solid var(--error);background:transparent;font-size:var(--font-size-xs)" data-action="skill-uninstall" data-name="${esc(name)}">卸载</button>` : ''}
${statusBadge}
</div>
</div>
@@ -265,16 +267,33 @@ async function handleInstallDep(page, btn) {
}
}
async function handleClawHubSearch(page) {
const input = page.querySelector('#clawhub-search-input')
const results = page.querySelector('#clawhub-results')
// ===== 统一源搜索/安装系统 =====
let _installSource = 'skillhub' // 当前选中的安装源
let _skillhubInstalled = false // SkillHub CLI 是否已安装
function getInstallSource() { return _installSource }
async function handleSourceSearch(page) {
const input = page.querySelector('#skill-install-search')
const results = page.querySelector('#install-source-results')
if (!input || !results) return
const q = input.value.trim()
if (!q) { results.innerHTML = '<div class="clawhub-empty">输入关键词搜索 ClawHub 社区 Skills</div>'; return }
if (!q) { results.innerHTML = '<div class="clawhub-empty">输入关键词搜索社区 Skills</div>'; return }
const source = getInstallSource()
// SkillHub 未安装时友好提示
if (source === 'skillhub' && !_skillhubInstalled) {
results.innerHTML = `<div style="padding:var(--space-lg);text-align:center">
<div style="color:var(--warning);margin-bottom:8px">⚠️ 请先安装 SkillHub CLI</div>
<div class="form-hint" style="margin-bottom:12px">点击上方「安装 CLI」按钮或切换到 ClawHub 源搜索</div>
<button class="btn btn-primary btn-sm" data-action="skillhub-setup">一键安装 SkillHub CLI</button>
</div>`
return
}
results.innerHTML = '<div class="form-hint">正在搜索...</div>'
try {
const items = await api.skillsClawHubSearch(q)
const items = source === 'skillhub' ? await api.skillsSkillHubSearch(q) : await api.skillsClawHubSearch(q)
if (!items?.length) { results.innerHTML = '<div class="clawhub-empty">没有找到匹配的 Skill</div>'; return }
const installAction = source === 'skillhub' ? 'source-install-skillhub' : 'source-install-clawhub'
results.innerHTML = items.map(item => `
<div class="clawhub-item">
<div class="clawhub-item-main">
@@ -282,23 +301,37 @@ async function handleClawHubSearch(page) {
<div class="clawhub-item-desc">${esc(item.description || item.summary || '')}</div>
</div>
<div class="clawhub-item-actions">
<button class="btn btn-primary btn-sm" data-action="clawhub-install" data-slug="${esc(item.slug || item.name || '')}">安装</button>
<button class="btn btn-primary btn-sm" data-action="${installAction}" data-slug="${esc(item.slug || item.name || '')}">安装</button>
</div>
</div>
`).join('')
} catch (e) {
results.innerHTML = `<div style="color:var(--error)">搜索失败: ${esc(e?.message || e)}</div>`
const errMsg = String(e?.message || e)
const isRateLimit = /rate.?limit|429|too many/i.test(errMsg)
if (isRateLimit) {
results.innerHTML = `<div style="padding:var(--space-lg);text-align:center">
<div style="color:var(--warning);margin-bottom:8px">⚠️ 请求频率超限</div>
<div class="form-hint">${source === 'clawhub' ? 'ClawHub 海外源限流,建议切换到 SkillHub国内加速' : '请稍后再试'}</div>
</div>`
} else {
results.innerHTML = `<div style="color:var(--error);padding:var(--space-sm)">搜索失败: ${esc(errMsg)}</div>`
}
}
}
async function handleClawHubInstall(page, btn) {
async function handleSourceInstall(page, btn, source) {
const slug = btn.dataset.slug
btn.disabled = true
btn.textContent = '安装中...'
try {
await api.skillsClawHubInstall(slug)
if (source === 'skillhub') await api.skillsSkillHubInstall(slug)
else await api.skillsClawHubInstall(slug)
toast(`Skill ${slug} 安装成功`, 'success')
await loadSkills(page)
btn.textContent = '已安装'
btn.classList.remove('btn-primary')
btn.classList.add('btn-secondary')
// 后台刷新已安装列表(不阻塞 UI
loadSkills(page).catch(() => {})
} catch (e) {
toast(`安装失败: ${e?.message || e}`, 'error')
btn.disabled = false
@@ -306,7 +339,93 @@ async function handleClawHubInstall(page, btn) {
}
}
async function handleSkillUninstall(page, btn) {
const name = btn.dataset.name
if (!name) return
if (!confirm(`确定卸载 Skill「${name}」?`)) return
btn.disabled = true
btn.textContent = '卸载中...'
try {
await api.skillsUninstall(name)
toast(`已卸载 ${name}`, 'success')
await loadSkills(page)
} catch (e) {
toast(`卸载失败: ${e?.message || e}`, 'error')
btn.disabled = false
btn.textContent = '卸载'
}
}
async function handleSkillHubSetup(page) {
const statusEl = page.querySelector('#skillhub-status')
if (statusEl) statusEl.textContent = '正在安装 SkillHub CLI...'
try {
await api.skillsSkillHubSetup(true)
toast('SkillHub CLI 安装成功', 'success')
if (statusEl) statusEl.textContent = '✅ 已安装'
// 隐藏安装按钮
const setupBtn = page.querySelector('#btn-skillhub-setup')
if (setupBtn) setupBtn.style.display = 'none'
} catch (e) {
toast(`SkillHub CLI 安装失败: ${e?.message || e}`, 'error')
if (statusEl) statusEl.textContent = '❌ 安装失败'
}
}
async function checkSkillHubStatus(page) {
const statusEl = page.querySelector('#skillhub-status')
const setupBtn = page.querySelector('#btn-skillhub-setup')
if (!statusEl) return
try {
const info = await api.skillsSkillHubCheck()
_skillhubInstalled = !!info.installed
if (info.installed) {
statusEl.innerHTML = `<span style="color:var(--success)">✅ v${info.version}</span>`
if (setupBtn) setupBtn.style.display = 'none'
} else {
statusEl.innerHTML = '<span style="color:var(--warning)">⚠️ 未安装 CLI</span>'
if (setupBtn && _installSource === 'skillhub') setupBtn.style.display = ''
}
} catch {
statusEl.textContent = ''
}
}
function switchInstallSource(page, source) {
_installSource = source
const results = page.querySelector('#install-source-results')
const setupBtn = page.querySelector('#btn-skillhub-setup')
const browseBtn = page.querySelector('#btn-browse-source')
if (results) results.innerHTML = '<div class="clawhub-empty">输入关键词搜索社区 Skills</div>'
if (source === 'skillhub') {
if (browseBtn) browseBtn.href = 'https://skillhub.tencent.com'
checkSkillHubStatus(page)
} else {
if (setupBtn) setupBtn.style.display = 'none'
if (browseBtn) browseBtn.href = 'https://clawhub.ai/skills'
}
}
function bindEvents(page) {
// 主 Tab 切换(已安装 / 搜索安装)
page.querySelectorAll('#skills-main-tabs .tab').forEach(tab => {
tab.onclick = () => {
page.querySelectorAll('#skills-main-tabs .tab').forEach(t => t.classList.remove('active'))
tab.classList.add('active')
const key = tab.dataset.mainTab
page.querySelector('#skills-tab-installed').style.display = key === 'installed' ? '' : 'none'
page.querySelector('#skills-tab-store').style.display = key === 'store' ? '' : 'none'
// 切到商店 tab 时检测 SkillHub 状态
if (key === 'store') checkSkillHubStatus(page)
}
})
// 安装源下拉切换
const sourceSelect = page.querySelector('#install-source-select')
if (sourceSelect) {
sourceSelect.onchange = () => switchInstallSource(page, sourceSelect.value)
}
page.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]')
if (!btn) return
@@ -320,16 +439,23 @@ function bindEvents(page) {
case 'skill-install-dep':
await handleInstallDep(page, btn)
break
case 'clawhub-search':
await handleClawHubSearch(page)
case 'install-source-search':
await handleSourceSearch(page)
break
case 'clawhub-install':
await handleClawHubInstall(page, btn)
case 'source-install-skillhub':
await handleSourceInstall(page, btn, 'skillhub')
break
case 'source-install-clawhub':
await handleSourceInstall(page, btn, 'clawhub')
break
case 'skillhub-setup':
await handleSkillHubSetup(page)
break
case 'skill-uninstall':
await handleSkillUninstall(page, btn)
break
case 'skill-ai-fix':
// 跳转到 AI 助手并触发 Skills 管理快捷操作
window.location.hash = '#/assistant'
// 延迟触发内置 skill等路由加载完
setTimeout(() => {
const skillBtn = document.querySelector('.ast-skill-card[data-skill="skills-manager"]')
if (skillBtn) skillBtn.click()
@@ -339,9 +465,9 @@ function bindEvents(page) {
})
page.addEventListener('keydown', async (e) => {
if (e.key === 'Enter' && e.target?.id === 'clawhub-search-input') {
if (e.key === 'Enter' && e.target?.id === 'skill-install-search') {
e.preventDefault()
await handleClawHubSearch(page)
await handleSourceSearch(page)
}
})
}