feat: 飞书官方插件迁移 + 配对审批 + Gateway防卡死 + 微信升级修复 + 更新检测修复

- 飞书渠道从 @openclaw/feishu 迁移到 @larksuite/openclaw-lark 官方插件
- 保存飞书配置时自动禁用旧 feishu 插件,防止新旧插件冲突
- 所有主要渠道(飞书/Telegram/Discord/Slack)启用配对审批UI
- gateway_command 增加20s超时,超时后force-kill+fresh start
- 全平台启动前端口占用检查,防止Guardian无限拉起
- Linux gateway_command 补齐 Duration 导入和 cleanup_zombie 实现
- Guardian自动守护在Tauri桌面端也启用,轮询间隔30s→15s
- 微信渠道:升级操作不再弹出扫码二维码,按钮文案区分安装/升级
- 版本更新检测:CI不再将minAppVersion写死为当前版本
- 部署脚本增强OpenClaw检测,支持已安装的官方版
- 日间/夜间模式圆形扩散切换动画(View Transitions API)
- API错误信息完整展示(429限流等),URL自动转可点击链接
- 第三方API接入引导优化:移除内置密钥,引导式流程
- 修复全平台 Clippy 警告(strip_prefix/dead_code/unnecessary_unwrap等)
- Rust代码格式化修复(cargo fmt)
- toast组件支持HTML内容渲染
- Rust后端test_model返回详细错误信息
This commit is contained in:
晴天
2026-03-23 20:37:48 +08:00
parent dccb4b4dbf
commit 3687e26d5d
50 changed files with 8055 additions and 2715 deletions

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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