mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-11 10:00:04 +08:00
feat: 版本管理 + macOS提示优化 + 部署文档更新
- OpenClaw 版本管理: 安装/升级/降级/切换版本, 汉化版/原版选择 - 新增 list_openclaw_versions API (Rust + Web) - upgrade_openclaw 支持指定版本号 - 版本选择器弹窗 (about.js) - macOS Gatekeeper 提示优化: 强调拖入应用程序, No such file 备选 - 部署文档统一使用 npm run serve 替代 npx vite - showUpgradeModal 支持自定义标题 + onClose 回调 - serve.js 路径分隔符跨平台修复 - 扩展工具页面优化 + AI助手危险工具确认
This commit is contained in:
@@ -141,12 +141,12 @@ export function showModal({ title, fields, onConfirm }) {
|
||||
* 升级进度弹窗 — 带进度条和实时日志
|
||||
* @returns {{ appendLog, setProgress, setDone, setError, destroy }}
|
||||
*/
|
||||
export function showUpgradeModal() {
|
||||
export function showUpgradeModal(title) {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = 'modal-overlay'
|
||||
overlay.innerHTML = `
|
||||
<div class="modal" style="max-width:520px">
|
||||
<div class="modal-title">升级 OpenClaw</div>
|
||||
<div class="modal-title">${title || '升级 OpenClaw'}</div>
|
||||
<div class="upgrade-progress-wrap">
|
||||
<div class="upgrade-progress-bar"><div class="upgrade-progress-fill" style="width:0%"></div></div>
|
||||
<div class="upgrade-progress-text">准备中...</div>
|
||||
@@ -165,9 +165,10 @@ export function showUpgradeModal() {
|
||||
const closeBtn = overlay.querySelector('[data-action="close"]')
|
||||
const _logLines = []
|
||||
|
||||
closeBtn.onclick = () => overlay.remove()
|
||||
let _onClose = null
|
||||
closeBtn.onclick = () => { overlay.remove(); _onClose?.() }
|
||||
overlay.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !closeBtn.disabled) overlay.remove()
|
||||
if (e.key === 'Escape' && !closeBtn.disabled) { overlay.remove(); _onClose?.() }
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -206,6 +207,7 @@ export function showUpgradeModal() {
|
||||
closeBtn.disabled = false
|
||||
closeBtn.focus()
|
||||
},
|
||||
destroy() { overlay.remove() },
|
||||
onClose(fn) { _onClose = fn },
|
||||
destroy() { overlay.remove(); _onClose?.() },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ const NAV_ITEMS_FULL = [
|
||||
section: '扩展',
|
||||
items: [
|
||||
{ route: '/extensions', label: '扩展工具', icon: 'extensions' },
|
||||
{ route: '/skills', label: 'Skills', icon: 'skills' },
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -59,6 +60,7 @@ const NAV_ITEMS_SETUP = [
|
||||
section: '扩展',
|
||||
items: [
|
||||
{ route: '/extensions', label: '扩展工具', icon: 'extensions' },
|
||||
{ route: '/skills', label: 'Skills', icon: 'skills' },
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -84,6 +86,7 @@ const ICONS = {
|
||||
about: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
|
||||
assistant: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/><path d="M18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"/></svg>',
|
||||
security: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>',
|
||||
skills: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/></svg>',
|
||||
debug: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/><circle cx="12" cy="12" r="3"/></svg>',
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ const PATHS = {
|
||||
'file-text': '<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
|
||||
'file-plain': '<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/>',
|
||||
'package': '<line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>',
|
||||
'box': '<line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>',
|
||||
'trash': '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>',
|
||||
'terminal': '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
|
||||
'edit': '<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>',
|
||||
'folder': '<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>',
|
||||
|
||||
@@ -277,7 +277,9 @@ export const api = {
|
||||
writeMcpConfig: (config) => { invalidate('read_mcp_config'); return invoke('write_mcp_config', { config }) },
|
||||
reloadGateway: () => invoke('reload_gateway'),
|
||||
restartGateway: () => invoke('restart_gateway'),
|
||||
upgradeOpenclaw: (source = 'chinese') => invoke('upgrade_openclaw', { source }),
|
||||
listOpenclawVersions: (source = 'chinese') => invoke('list_openclaw_versions', { source }),
|
||||
upgradeOpenclaw: (source = 'chinese', version = null) => invoke('upgrade_openclaw', { source, version }),
|
||||
uninstallOpenclaw: (cleanConfig = false) => invoke('uninstall_openclaw', { cleanConfig }),
|
||||
installGateway: () => invoke('install_gateway'),
|
||||
uninstallGateway: () => invoke('uninstall_gateway'),
|
||||
getNpmRegistry: () => cachedInvoke('get_npm_registry', {}, 30000),
|
||||
@@ -352,6 +354,20 @@ export const api = {
|
||||
assistantWebSearch: (query, maxResults) => invoke('assistant_web_search', { query, max_results: maxResults || 5 }),
|
||||
assistantFetchUrl: (url) => invoke('assistant_fetch_url', { url }),
|
||||
|
||||
// Skills 管理(openclaw skills CLI)
|
||||
skillsList: () => invoke('skills_list'),
|
||||
skillsInfo: (name) => invoke('skills_info', { name }),
|
||||
skillsCheck: () => invoke('skills_check'),
|
||||
skillsInstallDep: (kind, spec) => invoke('skills_install_dep', { kind, spec }),
|
||||
skillsClawHubSearch: (query) => invoke('skills_clawhub_search', { query }),
|
||||
skillsClawHubInstall: (slug) => invoke('skills_clawhub_install', { slug }),
|
||||
|
||||
// 前端热更新
|
||||
checkFrontendUpdate: () => invoke('check_frontend_update'),
|
||||
downloadFrontendUpdate: (url, expectedHash) => invoke('download_frontend_update', { url, expectedHash: expectedHash || '' }),
|
||||
rollbackFrontendUpdate: () => invoke('rollback_frontend_update'),
|
||||
getUpdateStatus: () => invoke('get_update_status'),
|
||||
|
||||
// 数据目录 & 图片存储
|
||||
ensureDataDir: () => invoke('assistant_ensure_data_dir'),
|
||||
saveImage: (id, data) => invoke('assistant_save_image', { id, data }),
|
||||
|
||||
@@ -164,6 +164,7 @@ async function boot() {
|
||||
registerRoute('/gateway', () => import('./pages/gateway.js'))
|
||||
registerRoute('/memory', () => import('./pages/memory.js'))
|
||||
registerRoute('/extensions', () => import('./pages/extensions.js'))
|
||||
registerRoute('/skills', () => import('./pages/skills.js'))
|
||||
registerRoute('/security', () => import('./pages/security.js'))
|
||||
registerRoute('/about', () => import('./pages/about.js'))
|
||||
registerRoute('/assistant', () => import('./pages/assistant.js'))
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { showUpgradeModal } from '../components/modal.js'
|
||||
import { showUpgradeModal, showConfirm } from '../components/modal.js'
|
||||
import { setUpgrading } from '../lib/app-state.js'
|
||||
import { icon, statusIcon } from '../lib/icons.js'
|
||||
|
||||
@@ -72,26 +72,13 @@ async function loadData(page) {
|
||||
// 非 Tauri 环境或 API 不可用,使用 fallback
|
||||
}
|
||||
|
||||
// 异步检查 ClawPanel 自身更新
|
||||
// 异步检查前端热更新
|
||||
let panelUpdateHtml = '<span style="color:var(--text-tertiary)">检查更新中...</span>'
|
||||
api.checkPanelUpdate().then(info => {
|
||||
const panelCard = cards.querySelector('#panel-update-meta')
|
||||
if (!panelCard) return
|
||||
if (info.latest && info.latest !== panelVersion && compareVersions(info.latest, panelVersion) > 0) {
|
||||
panelCard.innerHTML = `<span style="color:var(--accent)">新版本: ${info.latest}</span> <a class="btn btn-primary btn-sm" href="${info.url}" target="_blank" rel="noopener" style="padding:2px 8px;font-size:var(--font-size-xs)">下载更新</a>`
|
||||
} else {
|
||||
panelCard.innerHTML = '<span style="color:var(--success)">已是最新</span>'
|
||||
}
|
||||
}).catch((err) => {
|
||||
const panelCard = cards.querySelector('#panel-update-meta')
|
||||
if (!panelCard) return
|
||||
const msg = String(err?.message || err || '')
|
||||
if (msg.includes('403') || msg.includes('404') || msg.includes('rate limit')) {
|
||||
panelCard.innerHTML = '<span style="color:var(--text-tertiary)">仓库未公开,发布后可自动检测</span>'
|
||||
} else {
|
||||
panelCard.innerHTML = '<span style="color:var(--text-tertiary)">检查更新失败</span>'
|
||||
}
|
||||
})
|
||||
checkHotUpdate(cards, panelVersion)
|
||||
|
||||
const isInstalled = !!version.current
|
||||
const sourceLabel = version.source === 'official' ? '官方版' : '汉化版'
|
||||
const btnSm = 'padding:2px 8px;font-size:var(--font-size-xs)'
|
||||
|
||||
cards.innerHTML = `
|
||||
<div class="stat-card">
|
||||
@@ -100,12 +87,17 @@ async function loadData(page) {
|
||||
<div class="stat-card-meta" id="panel-update-meta" style="display:flex;align-items:center;gap:8px">${panelUpdateHtml}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header"><span class="stat-card-label">OpenClaw · ${version.source === 'official' ? '官方版' : '汉化版'}</span></div>
|
||||
<div class="stat-card-header"><span class="stat-card-label">OpenClaw · ${sourceLabel}</span></div>
|
||||
<div class="stat-card-value">${version.current || '未安装'}</div>
|
||||
<div class="stat-card-meta" style="display:flex;align-items:center;gap:8px">
|
||||
${version.update_available
|
||||
? `<span style="color:var(--accent)">新版本: ${version.latest}</span><button class="btn btn-primary btn-sm" id="btn-upgrade" style="padding:2px 8px;font-size:var(--font-size-xs)">升级</button>`
|
||||
: version.current ? '<span style="color:var(--success)">已是最新</span>' : '<span style="color:var(--error)">未检测到</span>'}
|
||||
<div class="stat-card-meta" style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||
${isInstalled ? (version.update_available
|
||||
? `<span style="color:var(--accent)">新版本: ${version.latest}</span>
|
||||
<button class="btn btn-primary btn-sm" id="btn-upgrade-latest" style="${btnSm}">升级到最新</button>`
|
||||
: '<span style="color:var(--success)">已是最新</span>') : ''}
|
||||
<button class="btn btn-${isInstalled ? 'secondary' : 'primary'} btn-sm" id="btn-version-mgmt" style="${btnSm}">
|
||||
${isInstalled ? '切换版本' : '安装 OpenClaw'}
|
||||
</button>
|
||||
${isInstalled ? `<button class="btn btn-secondary btn-sm" id="btn-uninstall" style="${btnSm};color:var(--error)">卸载</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
@@ -115,46 +107,41 @@ async function loadData(page) {
|
||||
</div>
|
||||
`
|
||||
|
||||
// 绑定升级按钮
|
||||
const upgradeBtn = cards.querySelector('#btn-upgrade')
|
||||
if (upgradeBtn) {
|
||||
upgradeBtn.onclick = async () => {
|
||||
const modal = showUpgradeModal()
|
||||
// 升级到最新
|
||||
const upgLatestBtn = cards.querySelector('#btn-upgrade-latest')
|
||||
if (upgLatestBtn) {
|
||||
upgLatestBtn.onclick = () => doInstall(page, '升级 OpenClaw', version.source, null)
|
||||
}
|
||||
|
||||
// 版本管理 / 安装
|
||||
const versionMgmtBtn = cards.querySelector('#btn-version-mgmt')
|
||||
if (versionMgmtBtn) {
|
||||
versionMgmtBtn.onclick = () => showVersionPicker(page, version)
|
||||
}
|
||||
|
||||
// 卸载
|
||||
const uninstallBtn = cards.querySelector('#btn-uninstall')
|
||||
if (uninstallBtn) {
|
||||
uninstallBtn.onclick = async () => {
|
||||
const confirmed = await showConfirm('确定要卸载 OpenClaw 吗?\n\n这将停止 Gateway 服务并卸载 npm 全局包。\n配置文件(~/.openclaw/)默认保留,可稍后手动删除。')
|
||||
if (!confirmed) return
|
||||
const modal = showUpgradeModal('卸载 OpenClaw')
|
||||
modal.onClose(() => loadData(page))
|
||||
modal.appendLog('开始卸载 OpenClaw...')
|
||||
let unlistenLog, unlistenProgress
|
||||
setUpgrading(true)
|
||||
try {
|
||||
if (window.__TAURI_INTERNALS__) {
|
||||
try {
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
|
||||
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
|
||||
} catch { /* Web 模式无 Tauri event */ }
|
||||
} else {
|
||||
modal.appendLog('Web 模式:升级过程日志不可用,请等待完成...')
|
||||
} catch {}
|
||||
}
|
||||
const msg = await api.upgradeOpenclaw()
|
||||
modal.setDone(typeof msg === 'string' ? msg : (msg?.message || '升级完成'))
|
||||
loadData(page)
|
||||
const msg = await api.uninstallOpenclaw(false)
|
||||
modal.setDone(typeof msg === 'string' ? msg : '卸载完成')
|
||||
} catch (e) {
|
||||
const errStr = String(e)
|
||||
modal.appendLog(errStr)
|
||||
const { diagnoseInstallError } = await import('../lib/error-diagnosis.js')
|
||||
const fullLog = modal.getLogText() + '\n' + errStr
|
||||
const diagnosis = diagnoseInstallError(fullLog)
|
||||
modal.setError(diagnosis.title)
|
||||
if (diagnosis.hint) modal.appendLog('')
|
||||
if (diagnosis.hint) modal.appendHtmlLog(`${statusIcon('info', 14)} ${diagnosis.hint}`)
|
||||
if (diagnosis.command) modal.appendHtmlLog(`${icon('clipboard', 14)} ${diagnosis.command}`)
|
||||
if (window.__openAIDrawerWithError) {
|
||||
window.__openAIDrawerWithError({
|
||||
title: diagnosis.title,
|
||||
error: fullLog,
|
||||
scene: '升级 OpenClaw',
|
||||
hint: diagnosis.hint,
|
||||
})
|
||||
}
|
||||
modal.setError('卸载失败: ' + (e?.message || e))
|
||||
} finally {
|
||||
setUpgrading(false)
|
||||
unlistenLog?.()
|
||||
unlistenProgress?.()
|
||||
}
|
||||
@@ -165,6 +152,272 @@ async function loadData(page) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 版本选择器弹窗 — 选择版本(汉化版/原版)+ 版本号
|
||||
*/
|
||||
async function showVersionPicker(page, currentVersion) {
|
||||
const isInstalled = !!currentVersion.current
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = 'modal-overlay'
|
||||
overlay.innerHTML = `
|
||||
<div class="modal" style="max-width:460px">
|
||||
<div class="modal-title">${isInstalled ? '切换版本' : '安装 OpenClaw'}</div>
|
||||
<div style="display:flex;flex-direction:column;gap:16px;margin:16px 0">
|
||||
<div>
|
||||
<label style="font-size:var(--font-size-sm);color:var(--text-secondary);display:block;margin-bottom:8px">版本</label>
|
||||
<div style="display:flex;gap:8px">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;padding:6px 12px;border-radius:8px;border:1px solid var(--border);font-size:var(--font-size-sm);flex:1;justify-content:center;transition:all .15s" id="lbl-chinese">
|
||||
<input type="radio" name="oc-source" value="chinese" ${currentVersion.source !== 'official' ? 'checked' : ''} style="accent-color:var(--primary)">
|
||||
汉化版
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;padding:6px 12px;border-radius:8px;border:1px solid var(--border);font-size:var(--font-size-sm);flex:1;justify-content:center;transition:all .15s" id="lbl-official">
|
||||
<input type="radio" name="oc-source" value="official" ${currentVersion.source === 'official' ? 'checked' : ''} style="accent-color:var(--primary)">
|
||||
原版
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:var(--font-size-sm);color:var(--text-secondary);display:block;margin-bottom:8px">选择版本号</label>
|
||||
<select id="oc-version-select" class="input" style="width:100%;padding:8px 12px;font-size:var(--font-size-sm)">
|
||||
<option value="">加载中...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="oc-action-hint" style="font-size:var(--font-size-xs);color:var(--text-tertiary);min-height:18px"></div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-action="cancel">取消</button>
|
||||
<button class="btn btn-primary btn-sm" data-action="confirm" disabled id="oc-confirm-btn">${isInstalled ? '切换' : '安装'}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
const select = overlay.querySelector('#oc-version-select')
|
||||
const confirmBtn = overlay.querySelector('#oc-confirm-btn')
|
||||
const hintEl = overlay.querySelector('#oc-action-hint')
|
||||
const radios = overlay.querySelectorAll('input[name="oc-source"]')
|
||||
const lblChinese = overlay.querySelector('#lbl-chinese')
|
||||
const lblOfficial = overlay.querySelector('#lbl-official')
|
||||
|
||||
const close = () => overlay.remove()
|
||||
overlay.querySelector('[data-action="cancel"]').onclick = close
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close() })
|
||||
overlay.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
|
||||
|
||||
let versionsCache = {}
|
||||
let currentSelect = currentVersion.source === 'official' ? 'official' : 'chinese'
|
||||
|
||||
function updateRadioStyle() {
|
||||
const sel = currentSelect
|
||||
lblChinese.style.borderColor = sel !== 'official' ? 'var(--primary)' : 'var(--border)'
|
||||
lblChinese.style.background = sel !== 'official' ? 'var(--primary-bg, rgba(99,102,241,0.06))' : ''
|
||||
lblOfficial.style.borderColor = sel === 'official' ? 'var(--primary)' : 'var(--border)'
|
||||
lblOfficial.style.background = sel === 'official' ? 'var(--primary-bg, rgba(99,102,241,0.06))' : ''
|
||||
}
|
||||
|
||||
function updateHint() {
|
||||
const targetSource = currentSelect
|
||||
const targetVer = select.value
|
||||
if (!targetVer || targetVer === '') { hintEl.textContent = ''; confirmBtn.disabled = true; return }
|
||||
|
||||
const sameSource = targetSource === (currentVersion.source === 'official' ? 'official' : 'chinese')
|
||||
|
||||
if (!isInstalled) {
|
||||
confirmBtn.textContent = '安装'
|
||||
hintEl.textContent = `将安装 ${targetSource === 'official' ? '原版' : '汉化版'} ${targetVer}`
|
||||
confirmBtn.disabled = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!sameSource) {
|
||||
confirmBtn.textContent = '切换'
|
||||
hintEl.innerHTML = `当前: <strong>${currentVersion.source === 'official' ? '原版' : '汉化版'} ${currentVersion.current}</strong> → <strong>${targetSource === 'official' ? '原版' : '汉化版'} ${targetVer}</strong>`
|
||||
confirmBtn.disabled = false
|
||||
return
|
||||
}
|
||||
|
||||
// 同源,比较版本
|
||||
const parseVer = v => v.split(/[^0-9]/).filter(Boolean).map(Number)
|
||||
const cur = parseVer(currentVersion.current)
|
||||
const tgt = parseVer(targetVer)
|
||||
let cmp = 0
|
||||
for (let i = 0; i < Math.max(cur.length, tgt.length); i++) {
|
||||
if ((tgt[i] || 0) > (cur[i] || 0)) { cmp = 1; break }
|
||||
if ((tgt[i] || 0) < (cur[i] || 0)) { cmp = -1; break }
|
||||
}
|
||||
|
||||
if (cmp === 0) {
|
||||
confirmBtn.textContent = '重新安装'
|
||||
hintEl.textContent = `当前已是 ${targetVer}`
|
||||
confirmBtn.disabled = false
|
||||
} else if (cmp > 0) {
|
||||
confirmBtn.textContent = '升级'
|
||||
hintEl.innerHTML = `<span style="color:var(--accent)">${currentVersion.current} → ${targetVer}</span>`
|
||||
confirmBtn.disabled = false
|
||||
} else {
|
||||
confirmBtn.textContent = '降级'
|
||||
hintEl.innerHTML = `<span style="color:var(--warning,#f59e0b)">${currentVersion.current} → ${targetVer}</span>`
|
||||
confirmBtn.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVersions(source) {
|
||||
select.innerHTML = '<option value="">加载中...</option>'
|
||||
confirmBtn.disabled = true
|
||||
hintEl.textContent = ''
|
||||
try {
|
||||
if (!versionsCache[source]) {
|
||||
versionsCache[source] = await api.listOpenclawVersions(source)
|
||||
}
|
||||
const versions = versionsCache[source]
|
||||
if (!versions.length) {
|
||||
select.innerHTML = '<option value="">未找到可用版本</option>'
|
||||
return
|
||||
}
|
||||
select.innerHTML = versions.map(v => {
|
||||
const isCurrent = isInstalled && v === currentVersion.current && source === (currentVersion.source === 'official' ? 'official' : 'chinese')
|
||||
return `<option value="${v}">${v}${isCurrent ? ' (当前)' : ''}</option>`
|
||||
}).join('')
|
||||
updateHint()
|
||||
} catch (e) {
|
||||
select.innerHTML = `<option value="">加载失败: ${e.message || e}</option>`
|
||||
}
|
||||
}
|
||||
|
||||
radios.forEach(radio => {
|
||||
radio.addEventListener('change', () => {
|
||||
currentSelect = radio.value
|
||||
updateRadioStyle()
|
||||
loadVersions(currentSelect)
|
||||
})
|
||||
})
|
||||
|
||||
select.addEventListener('change', updateHint)
|
||||
|
||||
confirmBtn.onclick = () => {
|
||||
const source = currentSelect
|
||||
const ver = select.value
|
||||
const action = confirmBtn.textContent
|
||||
close()
|
||||
doInstall(page, `${action} OpenClaw`, source, ver)
|
||||
}
|
||||
|
||||
updateRadioStyle()
|
||||
loadVersions(currentSelect)
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行安装/升级/降级/切换操作(带进度弹窗)
|
||||
*/
|
||||
async function doInstall(page, title, source, version) {
|
||||
const modal = showUpgradeModal(title)
|
||||
modal.onClose(() => loadData(page))
|
||||
let unlistenLog, unlistenProgress
|
||||
setUpgrading(true)
|
||||
try {
|
||||
if (window.__TAURI_INTERNALS__) {
|
||||
try {
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
|
||||
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
|
||||
} catch {}
|
||||
} else {
|
||||
modal.appendLog('Web 模式:安装过程日志不可用,请等待完成...')
|
||||
}
|
||||
const msg = await api.upgradeOpenclaw(source, version)
|
||||
modal.setDone(typeof msg === 'string' ? msg : (msg?.message || '操作完成'))
|
||||
} catch (e) {
|
||||
const errStr = String(e)
|
||||
modal.appendLog(errStr)
|
||||
const { diagnoseInstallError } = await import('../lib/error-diagnosis.js')
|
||||
const fullLog = modal.getLogText() + '\n' + errStr
|
||||
const diagnosis = diagnoseInstallError(fullLog)
|
||||
modal.setError(diagnosis.title)
|
||||
if (diagnosis.hint) modal.appendLog('')
|
||||
if (diagnosis.hint) modal.appendHtmlLog(`${statusIcon('info', 14)} ${diagnosis.hint}`)
|
||||
if (diagnosis.command) modal.appendHtmlLog(`${icon('clipboard', 14)} ${diagnosis.command}`)
|
||||
if (window.__openAIDrawerWithError) {
|
||||
window.__openAIDrawerWithError({
|
||||
title: diagnosis.title,
|
||||
error: fullLog,
|
||||
scene: title,
|
||||
hint: diagnosis.hint,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setUpgrading(false)
|
||||
unlistenLog?.()
|
||||
unlistenProgress?.()
|
||||
}
|
||||
}
|
||||
|
||||
async function checkHotUpdate(cards, panelVersion) {
|
||||
const el = () => cards.querySelector('#panel-update-meta')
|
||||
try {
|
||||
const info = await api.checkFrontendUpdate()
|
||||
const meta = el()
|
||||
if (!meta) return
|
||||
|
||||
if (info.updateReady) {
|
||||
// 已下载更新,等待重载
|
||||
const ver = info.manifest?.version || info.latestVersion || ''
|
||||
meta.innerHTML = `
|
||||
<span style="color:var(--accent)">v${ver} 已就绪</span>
|
||||
<button class="btn btn-primary btn-sm" id="btn-hot-reload" style="padding:2px 8px;font-size:var(--font-size-xs)">重载应用</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-hot-rollback" style="padding:2px 8px;font-size:var(--font-size-xs)">回退</button>
|
||||
`
|
||||
meta.querySelector('#btn-hot-reload')?.addEventListener('click', () => {
|
||||
window.location.reload()
|
||||
})
|
||||
meta.querySelector('#btn-hot-rollback')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await api.rollbackFrontendUpdate()
|
||||
toast('已回退到内嵌版本,重载中...', 'success')
|
||||
setTimeout(() => window.location.reload(), 800)
|
||||
} catch (e) {
|
||||
toast('回退失败: ' + (e.message || e), 'error')
|
||||
}
|
||||
})
|
||||
} else if (info.hasUpdate) {
|
||||
// 有新版本可下载
|
||||
const ver = info.latestVersion
|
||||
const manifest = info.manifest || {}
|
||||
const changelog = manifest.changelog || ''
|
||||
meta.innerHTML = `
|
||||
<span style="color:var(--accent)">新版本: v${ver}</span>
|
||||
${changelog ? `<span style="color:var(--text-tertiary);font-size:var(--font-size-xs)">${changelog}</span>` : ''}
|
||||
<button class="btn btn-primary btn-sm" id="btn-hot-download" style="padding:2px 8px;font-size:var(--font-size-xs)">热更新</button>
|
||||
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawpanel/releases" target="_blank" rel="noopener" style="padding:2px 8px;font-size:var(--font-size-xs)">完整安装包</a>
|
||||
`
|
||||
meta.querySelector('#btn-hot-download')?.addEventListener('click', async () => {
|
||||
const btn = meta.querySelector('#btn-hot-download')
|
||||
if (btn) { btn.disabled = true; btn.textContent = '下载中...' }
|
||||
try {
|
||||
await api.downloadFrontendUpdate(manifest.url, manifest.hash || '')
|
||||
toast('更新下载完成,点击「重载应用」生效', 'success')
|
||||
checkHotUpdate(cards, panelVersion)
|
||||
} catch (e) {
|
||||
toast('下载失败: ' + (e.message || e), 'error')
|
||||
if (btn) { btn.disabled = false; btn.textContent = '重试' }
|
||||
}
|
||||
})
|
||||
} else if (!info.compatible) {
|
||||
meta.innerHTML = '<span style="color:var(--text-tertiary)">需要更新完整安装包</span> <a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawpanel/releases" target="_blank" rel="noopener" style="padding:2px 8px;font-size:var(--font-size-xs)">下载</a>'
|
||||
} else {
|
||||
meta.innerHTML = '<span style="color:var(--success)">已是最新</span>'
|
||||
}
|
||||
} catch (err) {
|
||||
const meta = el()
|
||||
if (!meta) return
|
||||
const msg = String(err?.message || err || '')
|
||||
if (msg.includes('403') || msg.includes('404') || msg.includes('rate limit')) {
|
||||
meta.innerHTML = '<span style="color:var(--text-tertiary)">暂无法检查更新</span>'
|
||||
} else {
|
||||
meta.innerHTML = '<span style="color:var(--text-tertiary)">检查更新失败</span>'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function compareVersions(a, b) {
|
||||
const pa = a.split('.').map(Number)
|
||||
const pb = b.split('.').map(Number)
|
||||
|
||||
@@ -134,6 +134,14 @@ ${personality}
|
||||
- openclaw gateway install — 安装 Gateway 为系统服务
|
||||
- openclaw gateway uninstall — 卸载 Gateway 系统服务
|
||||
|
||||
### Skills 管理
|
||||
- openclaw skills list — 列出所有 Skills 及其状态
|
||||
- openclaw skills info <name> — 查看某个 Skill 详情
|
||||
- openclaw skills check — 检查所有 Skills 的依赖是否满足
|
||||
- Skill 依赖安装: 根据 install spec 执行 brew/npm/go/uv 安装缺少的命令行工具
|
||||
- ClawHub (clawhub.com): 社区 Skill 市场,可搜索和安装新 Skill
|
||||
- Skills 目录: 捆绑 Skills 在 openclaw 安装包内,自定义 Skills 放在 ~/.openclaw/skills/<name>/
|
||||
|
||||
### 聊天与调试
|
||||
- openclaw chat — 进入交互式聊天
|
||||
- openclaw chat -m "消息" — 发送单条消息
|
||||
@@ -362,6 +370,89 @@ const TOOL_DEFS = {
|
||||
},
|
||||
},
|
||||
],
|
||||
skills: [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'skills_list',
|
||||
description: '列出所有 OpenClaw Skills 及其状态(可用/缺依赖/已禁用)。返回每个 Skill 的名称、描述、来源、依赖状态、缺少的依赖项、可用的安装选项等信息。',
|
||||
parameters: { type: 'object', properties: {}, required: [] },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'skills_info',
|
||||
description: '查看指定 Skill 的详细信息,包括描述、来源、依赖要求、缺少的依赖、安装选项等。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Skill 名称,如 github、weather、coding-agent' },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'skills_check',
|
||||
description: '检查所有 Skills 的依赖状态,返回哪些可用、哪些缺少依赖、哪些已禁用的汇总信息。',
|
||||
parameters: { type: 'object', properties: {}, required: [] },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'skills_install_dep',
|
||||
description: '安装 Skill 缺少的依赖。根据 Skill 的 install spec 执行对应的包管理器命令(brew/npm/go/uv)。安装完成后会自动生效。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
kind: { type: 'string', enum: ['brew', 'node', 'go', 'uv'], description: '安装类型' },
|
||||
spec: {
|
||||
type: 'object',
|
||||
description: '安装参数。brew 需要 formula,node 需要 package,go 需要 module,uv 需要 package。',
|
||||
properties: {
|
||||
formula: { type: 'string', description: 'Homebrew formula 名称' },
|
||||
package: { type: 'string', description: 'npm 或 uv 包名' },
|
||||
module: { type: 'string', description: 'Go module 路径' },
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['kind', 'spec'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'skills_clawhub_search',
|
||||
description: '在 ClawHub 社区市场中搜索 Skills。返回匹配的 Skill 列表(slug 和描述)。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: '搜索关键词' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'skills_clawhub_install',
|
||||
description: '从 ClawHub 社区市场安装一个 Skill 到本地 ~/.openclaw/skills/ 目录。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slug: { type: 'string', description: 'ClawHub 上的 Skill slug(名称标识)' },
|
||||
},
|
||||
required: ['slug'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
fileOps: [
|
||||
{
|
||||
type: 'function',
|
||||
@@ -411,7 +502,7 @@ const TOOL_DEFS = {
|
||||
|
||||
// 危险工具(需要用户确认)
|
||||
const INTERACTIVE_TOOLS = new Set(['ask_user']) // 交互式工具,不走 confirmToolCall
|
||||
const DANGEROUS_TOOLS = new Set(['run_command', 'write_file'])
|
||||
const DANGEROUS_TOOLS = new Set(['run_command', 'write_file', 'skills_install_dep', 'skills_clawhub_install'])
|
||||
|
||||
// 安全围栏:极端危险命令模式(任何模式都必须确认,包括无限模式)
|
||||
const CRITICAL_PATTERNS = [
|
||||
@@ -596,6 +687,27 @@ const BUILTIN_SKILLS = [
|
||||
- 创建 PR 的链接
|
||||
7. 如果用户不熟悉 Git,给出每一步的详细命令`,
|
||||
},
|
||||
{
|
||||
id: 'skills-manager',
|
||||
icon: icon('box', 16),
|
||||
name: 'Skills 管理',
|
||||
desc: '查看、检查依赖、安装 Skills',
|
||||
tools: ['skills'],
|
||||
prompt: `请帮我管理 OpenClaw 的 Skills。
|
||||
|
||||
具体操作:
|
||||
1. 调用 skills_list 获取所有 Skills 及其状态
|
||||
2. 汇总展示:多少个可用、多少个缺依赖、多少个已禁用
|
||||
3. 对于缺依赖的 Skills,列出每个缺少的依赖和对应的安装方法
|
||||
4. 询问用户是否要安装某些缺少的依赖(用 ask_user 列出选项)
|
||||
5. 如果用户选择安装,调用 skills_install_dep 执行安装
|
||||
6. 安装完成后再次调用 skills_list 确认状态变化
|
||||
|
||||
注意:
|
||||
- 安装依赖可能需要特定的包管理器(brew 仅限 macOS,Windows 用 npm/go 等)
|
||||
- 先调用 get_system_info 判断操作系统,过滤出适合当前平台的安装选项
|
||||
- 如果用户想从 ClawHub 搜索安装新 Skill,使用 skills_clawhub_search 和 skills_clawhub_install`,
|
||||
},
|
||||
]
|
||||
|
||||
function currentMode() {
|
||||
@@ -624,6 +736,13 @@ function getEnabledTools() {
|
||||
}
|
||||
}
|
||||
|
||||
// Skills 管理工具:始终启用(规划模式下排除安装操作)
|
||||
if (mode.readOnly) {
|
||||
tools.push(...TOOL_DEFS.skills.filter(td => !['skills_install_dep', 'skills_clawhub_install'].includes(td.function.name)))
|
||||
} else {
|
||||
tools.push(...TOOL_DEFS.skills)
|
||||
}
|
||||
|
||||
return tools
|
||||
}
|
||||
|
||||
@@ -1724,6 +1843,40 @@ async function executeTool(name, args) {
|
||||
return await api.assistantWebSearch(args.query, args.max_results)
|
||||
case 'fetch_url':
|
||||
return await api.assistantFetchUrl(args.url)
|
||||
case 'skills_list': {
|
||||
const data = await api.skillsList()
|
||||
const skills = data?.skills || []
|
||||
const eligible = skills.filter(s => s.eligible && !s.disabled)
|
||||
const missing = skills.filter(s => !s.eligible && !s.disabled)
|
||||
const disabled = skills.filter(s => s.disabled)
|
||||
let summary = `共 ${skills.length} 个 Skills: ${eligible.length} 可用, ${missing.length} 缺依赖, ${disabled.length} 已禁用\n\n`
|
||||
if (eligible.length) summary += `## 可用 (${eligible.length})\n` + eligible.map(s => `- ${s.emoji || '📦'} **${s.name}**: ${s.description || ''}${s.bundled ? ' [捆绑]' : ''}`).join('\n') + '\n\n'
|
||||
if (missing.length) summary += `## 缺依赖 (${missing.length})\n` + missing.map(s => {
|
||||
const m = s.missing || {}
|
||||
const deps = [...(m.bins||[]), ...(m.env||[]).map(e=>'$'+e), ...(m.config||[])].join(', ')
|
||||
const installs = (s.install||[]).map(i => i.label).join(' / ')
|
||||
return `- ${s.emoji || '📦'} **${s.name}**: 缺少 ${deps}${installs ? ' → 可通过: ' + installs : ''}`
|
||||
}).join('\n') + '\n\n'
|
||||
if (disabled.length) summary += `## 已禁用 (${disabled.length})\n` + disabled.map(s => `- ${s.emoji || '📦'} **${s.name}**: ${s.description || ''}`).join('\n') + '\n'
|
||||
return summary
|
||||
}
|
||||
case 'skills_info':
|
||||
return JSON.stringify(await api.skillsInfo(args.name), null, 2)
|
||||
case 'skills_check':
|
||||
return JSON.stringify(await api.skillsCheck(), null, 2)
|
||||
case 'skills_install_dep': {
|
||||
const result = await api.skillsInstallDep(args.kind, args.spec)
|
||||
return result?.success ? `安装成功\n${result.output || ''}` : '安装失败'
|
||||
}
|
||||
case 'skills_clawhub_search': {
|
||||
const items = await api.skillsClawHubSearch(args.query)
|
||||
if (!items?.length) return '未找到匹配的 Skill'
|
||||
return items.map(i => `- **${i.slug}**: ${i.description || '无描述'}`).join('\n')
|
||||
}
|
||||
case 'skills_clawhub_install': {
|
||||
const result = await api.skillsClawHubInstall(args.slug)
|
||||
return result?.success ? `Skill "${args.slug}" 安装成功\n${result.output || ''}` : '安装失败'
|
||||
}
|
||||
default:
|
||||
return `未知工具: ${name}`
|
||||
}
|
||||
@@ -2104,8 +2257,8 @@ function renderToolBlocks(toolHistory) {
|
||||
// ask_user 工具不显示在工具块中(它有自己的交互卡片)
|
||||
if (tc.name === 'ask_user') return ''
|
||||
|
||||
const tcIcon = { run_command: icon('terminal', 14), write_file: icon('edit', 14), read_file: icon('file', 14), list_directory: icon('folder', 14), get_system_info: icon('monitor', 14), list_processes: icon('list', 14), check_port: icon('plug', 14) }[tc.name] || icon('wrench', 14)
|
||||
const label = { run_command: '执行命令', read_file: '读取文件', write_file: '写入文件', list_directory: '列出目录', get_system_info: '系统信息', list_processes: '进程列表', check_port: '端口检测' }[tc.name] || tc.name
|
||||
const tcIcon = { run_command: icon('terminal', 14), write_file: icon('edit', 14), read_file: icon('file', 14), list_directory: icon('folder', 14), get_system_info: icon('monitor', 14), list_processes: icon('list', 14), check_port: icon('plug', 14), skills_list: icon('box', 14), skills_info: icon('box', 14), skills_check: icon('box', 14), skills_install_dep: icon('download', 14), skills_clawhub_search: icon('search', 14), skills_clawhub_install: icon('download', 14) }[tc.name] || icon('wrench', 14)
|
||||
const label = { run_command: '执行命令', read_file: '读取文件', write_file: '写入文件', list_directory: '列出目录', get_system_info: '系统信息', list_processes: '进程列表', check_port: '端口检测', skills_list: 'Skills 列表', skills_info: 'Skill 详情', skills_check: 'Skills 检查', skills_install_dep: '安装依赖', skills_clawhub_search: '搜索 ClawHub', skills_clawhub_install: '安装 Skill' }[tc.name] || tc.name
|
||||
const argsStr = tc.name === 'run_command' ? escHtml(tc.args.command || '')
|
||||
: tc.name === 'read_file' ? escHtml(tc.args.path || '')
|
||||
: tc.name === 'write_file' ? escHtml(tc.args.path || '')
|
||||
@@ -2113,11 +2266,16 @@ function renderToolBlocks(toolHistory) {
|
||||
: tc.name === 'get_system_info' ? ''
|
||||
: tc.name === 'list_processes' ? escHtml(tc.args.filter || '全部')
|
||||
: tc.name === 'check_port' ? escHtml(String(tc.args.port || ''))
|
||||
: tc.name === 'skills_info' ? escHtml(tc.args.name || '')
|
||||
: tc.name === 'skills_install_dep' ? escHtml(`${tc.args.kind}: ${tc.args.spec?.formula || tc.args.spec?.package || tc.args.spec?.module || ''}`)
|
||||
: tc.name === 'skills_clawhub_search' ? escHtml(tc.args.query || '')
|
||||
: tc.name === 'skills_clawhub_install' ? escHtml(tc.args.slug || '')
|
||||
: ['skills_list', 'skills_check'].includes(tc.name) ? ''
|
||||
: escHtml(JSON.stringify(tc.args))
|
||||
|
||||
if (tc.pending) {
|
||||
return `<div class="ast-tool-block pending">
|
||||
<div class="ast-tool-summary">${icon} <strong>${label}</strong> <code>${argsStr}</code> <span class="ast-tool-status"><span class="ast-typing">执行中...</span></span></div>
|
||||
<div class="ast-tool-summary">${tcIcon} <strong>${label}</strong> <code>${argsStr}</code> <span class="ast-tool-status"><span class="ast-typing">执行中...</span></span></div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
@@ -2125,7 +2283,7 @@ function renderToolBlocks(toolHistory) {
|
||||
const statusLabel = tc.approved === false ? '已拒绝' : '已执行'
|
||||
const resultPreview = (tc.result || '').length > 500 ? tc.result.slice(0, 500) + '...' : (tc.result || '')
|
||||
return `<details class="ast-tool-block ${statusClass}">
|
||||
<summary class="ast-tool-summary">${icon} <strong>${label}</strong> <code>${argsStr}</code> <span class="ast-tool-status">${statusLabel}</span></summary>
|
||||
<summary class="ast-tool-summary">${tcIcon} <strong>${label}</strong> <code>${argsStr}</code> <span class="ast-tool-status">${statusLabel}</span></summary>
|
||||
<pre class="ast-tool-result">${escHtml(resultPreview)}</pre>
|
||||
</details>`
|
||||
}).join('')
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
/**
|
||||
* Skills 页面
|
||||
* 默认展示 ClawHub 热门推荐 + 已安装 + 搜索结果 + 详情 + 安装
|
||||
* 基于 openclaw skills CLI,按状态分组展示所有 Skills
|
||||
*/
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
|
||||
const SKILLS_LOAD_TIMEOUT_MS = 10000
|
||||
const SKILLS_AUTO_RETRY_DELAY_MS = 1200
|
||||
const SKILLS_MAX_AUTO_RETRY = 1
|
||||
let skillsLoadSeq = 0
|
||||
let _loadSeq = 0
|
||||
|
||||
function escapeHtml(str) {
|
||||
function esc(str) {
|
||||
if (!str) return ''
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
export async function render() {
|
||||
@@ -25,275 +18,329 @@ export async function render() {
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Skills</h1>
|
||||
<p class="page-desc">从 ClawHub 浏览热门推荐、搜索 Skill、查看详情并一键安装</p>
|
||||
<p class="page-desc">查看 OpenClaw 可用的 Skills 及其依赖状态</p>
|
||||
</div>
|
||||
<div id="skills-content" class="config-section">
|
||||
<div class="stat-card loading-placeholder" style="height:96px"></div>
|
||||
</div>
|
||||
`
|
||||
|
||||
bindEvents(page)
|
||||
loadSkills(page)
|
||||
return page
|
||||
}
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function withTimeout(promise, ms, label = '请求') {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error(`${label}超时(>${Math.round(ms / 1000)}s)`)), ms)
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
function setLoadingHint(page, text = '正在加载 Skills...') {
|
||||
async function loadSkills(page) {
|
||||
const el = page.querySelector('#skills-content')
|
||||
if (!el) return
|
||||
el.innerHTML = `
|
||||
<div class="skills-loading-panel">
|
||||
<div class="stat-card loading-placeholder" style="height:96px"></div>
|
||||
<div class="form-hint" style="margin-top:8px">${escapeHtml(text)}</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
const seq = ++_loadSeq
|
||||
|
||||
function renderLoadError(el, message, canAutoRetry) {
|
||||
el.innerHTML = `
|
||||
<div class="skills-load-error">
|
||||
<div style="color:var(--error);margin-bottom:8px">加载失败:${escapeHtml(message)}</div>
|
||||
<div class="form-hint" style="margin-bottom:10px">${canAutoRetry ? '正在自动重试...' : '你可以手动重试'}</div>
|
||||
<div class="clawhub-toolbar" style="margin-bottom:0">
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-retry">立即重试</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
async function loadSkills(page, query = '', options = {}) {
|
||||
const el = page.querySelector('#skills-content')
|
||||
if (!el) return
|
||||
|
||||
const silent = !!options.silent
|
||||
const retryCount = options.retryCount || 0
|
||||
const requestId = ++skillsLoadSeq
|
||||
|
||||
if (!silent) {
|
||||
setLoadingHint(page, retryCount > 0 ? `正在重试加载(第 ${retryCount + 1} 次)...` : '正在加载 Skills...')
|
||||
}
|
||||
el.innerHTML = `<div class="skills-loading-panel">
|
||||
<div class="stat-card loading-placeholder" style="height:96px"></div>
|
||||
<div class="form-hint" style="margin-top:8px">正在加载 Skills...</div>
|
||||
</div>`
|
||||
|
||||
try {
|
||||
const [installed, trending, results] = await withTimeout(
|
||||
Promise.all([
|
||||
api.clawhubListInstalled(),
|
||||
api.clawhubTrending(),
|
||||
query ? api.clawhubSearch(query) : Promise.resolve([]),
|
||||
]),
|
||||
SKILLS_LOAD_TIMEOUT_MS,
|
||||
'Skills 数据加载'
|
||||
)
|
||||
|
||||
if (requestId !== skillsLoadSeq) return
|
||||
renderSkills(el, { installed, trending, results, query })
|
||||
const data = await api.skillsList()
|
||||
if (seq !== _loadSeq) return
|
||||
renderSkills(el, data)
|
||||
} catch (e) {
|
||||
if (requestId !== skillsLoadSeq) return
|
||||
|
||||
const message = (e?.message || String(e || '')).trim() || '未知错误'
|
||||
const canAutoRetry = retryCount < SKILLS_MAX_AUTO_RETRY
|
||||
|
||||
renderLoadError(el, message, canAutoRetry)
|
||||
|
||||
if (canAutoRetry) {
|
||||
await wait(SKILLS_AUTO_RETRY_DELAY_MS)
|
||||
if (requestId !== skillsLoadSeq) return
|
||||
await loadSkills(page, query, { silent: false, retryCount: retryCount + 1 })
|
||||
}
|
||||
if (seq !== _loadSeq) return
|
||||
el.innerHTML = `<div class="skills-load-error">
|
||||
<div style="color:var(--error);margin-bottom:8px">加载失败: ${esc(e?.message || e)}</div>
|
||||
<div class="form-hint" style="margin-bottom:10px">请确认 OpenClaw 已安装并可用</div>
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-retry">重试</button>
|
||||
</div>`
|
||||
}
|
||||
}
|
||||
|
||||
function renderSkillItems(items, installedSet) {
|
||||
if (!items.length) return '<div class="clawhub-empty">暂无内容</div>'
|
||||
return items.map(item => `
|
||||
<div class="clawhub-item">
|
||||
<div class="clawhub-item-main">
|
||||
<div class="clawhub-item-title">${escapeHtml(item.displayName || item.slug)}</div>
|
||||
<div class="clawhub-item-meta">${escapeHtml(item.slug)}${item.author ? ` · @${escapeHtml(item.author)}` : ''}${item.downloadsText ? ` · ${escapeHtml(item.downloadsText)}` : ''}</div>
|
||||
<div class="clawhub-item-desc">${escapeHtml(item.summary || '暂无摘要,可点击查看详情')}</div>
|
||||
</div>
|
||||
<div class="clawhub-item-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-inspect" data-slug="${escapeHtml(item.slug)}">详情</button>
|
||||
${installedSet.has(item.slug)
|
||||
? '<span class="clawhub-badge installed">已安装</span>'
|
||||
: `<button class="btn btn-primary btn-sm" data-action="skill-install" data-slug="${escapeHtml(item.slug)}">安装</button>`}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')
|
||||
}
|
||||
function renderSkills(el, data) {
|
||||
const skills = data?.skills || []
|
||||
const cliAvailable = data?.cliAvailable !== false
|
||||
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)
|
||||
|
||||
function renderTrendingCards(items, installedSet) {
|
||||
if (!items.length) return '<div class="clawhub-empty">暂无推荐内容</div>'
|
||||
return `
|
||||
<div class="skills-hero-grid">
|
||||
${items.map(item => `
|
||||
<div class="skill-hero-card">
|
||||
<div class="skill-hero-top">
|
||||
<div>
|
||||
<div class="skill-hero-title">${escapeHtml(item.displayName || item.slug)}</div>
|
||||
<div class="skill-hero-meta">${escapeHtml(item.slug)}${item.author ? ` · @${escapeHtml(item.author)}` : ''}</div>
|
||||
</div>
|
||||
<div class="skill-hero-badges">
|
||||
${item.downloadsText ? `<span class="clawhub-badge hot">${escapeHtml(item.downloadsText)}</span>` : ''}
|
||||
${installedSet.has(item.slug) ? '<span class="clawhub-badge installed">已安装</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="skill-hero-desc">${escapeHtml(item.summary || '暂无摘要')}</div>
|
||||
<div class="skill-hero-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-inspect" data-slug="${escapeHtml(item.slug)}">查看详情</button>
|
||||
${installedSet.has(item.slug)
|
||||
? '<span class="skill-hero-installed">已在本地可用</span>'
|
||||
: `<button class="btn btn-primary btn-sm" data-action="skill-install" data-slug="${escapeHtml(item.slug)}">一键安装</button>`}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderSkills(el, state) {
|
||||
const installed = state.installed || []
|
||||
const trending = state.trending || []
|
||||
const results = state.results || []
|
||||
const installedSet = new Set(installed.map(x => x.slug))
|
||||
const summary = `${eligible.length} 可用 / ${missing.length} 缺依赖 / ${disabled.length} 已禁用`
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="clawhub-toolbar">
|
||||
<input class="input clawhub-search-input" id="skill-search-input" placeholder="搜索 Skill,比如 weather / github / summarize" value="${escapeHtml(state.query || '')}">
|
||||
<button class="btn btn-primary btn-sm" data-action="skill-search">搜索</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-refresh">刷新</button>
|
||||
<a class="btn btn-secondary btn-sm" href="https://clawhub.ai/skills?sort=downloads" target="_blank" rel="noopener">打开 ClawHub</a>
|
||||
<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>' : ''}
|
||||
</div>
|
||||
|
||||
<div class="clawhub-panel skills-hero-panel">
|
||||
<div class="clawhub-panel-title">热门推荐</div>
|
||||
<div class="skills-scroll-area skills-trending-scroll">
|
||||
${renderTrendingCards(trending, installedSet)}
|
||||
</div>
|
||||
<div class="skills-summary" style="margin-bottom:var(--space-lg);color:var(--text-secondary);font-size:var(--font-size-sm)">
|
||||
共 ${skills.length} 个 Skills: ${summary}
|
||||
</div>
|
||||
|
||||
<div class="clawhub-grid" style="margin-top:var(--space-lg)">
|
||||
<div class="clawhub-panel">
|
||||
<div class="clawhub-panel-title">已安装 Skills</div>
|
||||
<div class="clawhub-list skills-scroll-area skills-installed-scroll">
|
||||
${installed.length ? installed.map(item => `
|
||||
<div class="clawhub-item">
|
||||
<div>
|
||||
<div class="clawhub-item-title">${escapeHtml(item.slug)}</div>
|
||||
<div class="clawhub-item-desc">已安装到本地 Skills 目录</div>
|
||||
</div>
|
||||
<span class="clawhub-badge installed">已安装</span>
|
||||
</div>
|
||||
`).join('') : '<div class="clawhub-empty">还没有已安装的 Skill</div>'}
|
||||
</div>
|
||||
${eligible.length ? `
|
||||
<div class="clawhub-panel" style="margin-bottom:var(--space-lg)">
|
||||
<div class="clawhub-panel-title" style="color:var(--success)">✓ 可用 (${eligible.length})</div>
|
||||
<div class="clawhub-list skills-scroll-area skills-trending-scroll" id="skills-eligible">
|
||||
${eligible.map(s => renderSkillCard(s, 'eligible')).join('')}
|
||||
</div>
|
||||
<div class="clawhub-panel skills-tips-panel">
|
||||
<div class="clawhub-panel-title">使用提示</div>
|
||||
<div class="skills-tip-list">
|
||||
<div class="skills-tip-item"><strong>默认推荐</strong>:首屏展示 ClawHub 热门技能,方便直接浏览</div>
|
||||
<div class="skills-tip-item"><strong>搜索</strong>:输入关键词后会调用 ClawHub CLI 实时搜索</div>
|
||||
<div class="skills-tip-item"><strong>安装</strong>:安装受外部服务限流影响,失败时可稍后重试</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<div class="clawhub-panel" style="margin-top:var(--space-lg)">
|
||||
<div class="clawhub-panel-title">搜索结果</div>
|
||||
<div class="clawhub-list skills-scroll-area skills-search-scroll">
|
||||
${state.query ? renderSkillItems(results, installedSet) : '<div class="clawhub-empty">输入关键词开始搜索</div>'}
|
||||
${missing.length ? `
|
||||
<div class="clawhub-panel" style="margin-bottom:var(--space-lg)">
|
||||
<div class="clawhub-panel-title" style="color:var(--warning);display:flex;align-items:center;gap:var(--space-sm)">
|
||||
<span>✗ 缺少依赖 (${missing.length})</span>
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-ai-fix" style="font-size:var(--font-size-xs);padding:2px 8px">让 AI 助手帮我安装</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clawhub-list skills-scroll-area skills-installed-scroll" id="skills-missing">
|
||||
${missing.map(s => renderSkillCard(s, 'missing')).join('')}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
${disabled.length ? `
|
||||
<div class="clawhub-panel" style="margin-bottom:var(--space-lg)">
|
||||
<div class="clawhub-panel-title" style="color:var(--text-tertiary)">⏸ 已禁用 (${disabled.length})</div>
|
||||
<div class="clawhub-list skills-scroll-area skills-search-scroll" id="skills-disabled">
|
||||
${disabled.map(s => renderSkillCard(s, 'disabled')).join('')}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
${blocked.length ? `
|
||||
<div class="clawhub-panel" style="margin-bottom:var(--space-lg)">
|
||||
<div class="clawhub-panel-title" style="color:var(--text-tertiary)">🚫 白名单阻止 (${blocked.length})</div>
|
||||
<div class="clawhub-list">
|
||||
${blocked.map(s => renderSkillCard(s, 'blocked')).join('')}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
${!skills.length ? `
|
||||
<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>
|
||||
</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/<name>/</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>
|
||||
`
|
||||
|
||||
// 实时过滤
|
||||
const input = el.querySelector('#skill-filter-input')
|
||||
if (input) {
|
||||
input.addEventListener('input', () => {
|
||||
const q = input.value.trim().toLowerCase()
|
||||
el.querySelectorAll('.skill-card-item').forEach(card => {
|
||||
const name = (card.dataset.name || '').toLowerCase()
|
||||
const desc = (card.dataset.desc || '').toLowerCase()
|
||||
card.style.display = (!q || name.includes(q) || desc.includes(q)) ? '' : 'none'
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function renderSkillCard(skill, status) {
|
||||
const emoji = skill.emoji || '📦'
|
||||
const name = skill.name || ''
|
||||
const desc = skill.description || ''
|
||||
const source = skill.bundled ? '捆绑' : (skill.source || '自定义')
|
||||
const missingBins = skill.missing?.bins || []
|
||||
const missingEnv = skill.missing?.env || []
|
||||
const missingConfig = skill.missing?.config || []
|
||||
const installOpts = skill.install || []
|
||||
|
||||
let statusBadge = ''
|
||||
if (status === 'eligible') statusBadge = '<span class="clawhub-badge installed">可用</span>'
|
||||
else if (status === 'missing') statusBadge = '<span class="clawhub-badge" style="background:rgba(245,158,11,0.14);color:#d97706">缺依赖</span>'
|
||||
else if (status === 'disabled') statusBadge = '<span class="clawhub-badge" style="background:rgba(107,114,128,0.14);color:#6b7280">已禁用</span>'
|
||||
else if (status === 'blocked') statusBadge = '<span class="clawhub-badge" style="background:rgba(239,68,68,0.14);color:#ef4444">已阻止</span>'
|
||||
|
||||
let missingHtml = ''
|
||||
if (missingBins.length) missingHtml += `<div class="form-hint" style="margin-top:4px">缺少命令: ${missingBins.map(b => `<code>${esc(b)}</code>`).join(', ')}</div>`
|
||||
if (missingEnv.length) missingHtml += `<div class="form-hint" style="margin-top:4px">缺少环境变量: ${missingEnv.map(e => `<code>${esc(e)}</code>`).join(', ')} <span style="color:var(--text-tertiary);font-size:var(--font-size-xs)">— 需在系统环境变量中配置</span></div>`
|
||||
if (missingConfig.length) missingHtml += `<div class="form-hint" style="margin-top:4px">缺少配置: ${missingConfig.map(c => `<code>${esc(c)}</code>`).join(', ')} <span style="color:var(--text-tertiary);font-size:var(--font-size-xs)">— 需在 openclaw.json 中配置</span></div>`
|
||||
|
||||
let installHtml = ''
|
||||
if (status === 'missing') {
|
||||
if (installOpts.length) {
|
||||
installHtml = `<div style="margin-top:6px">${installOpts.map(opt =>
|
||||
`<button class="btn btn-primary btn-sm" style="margin-right:6px;margin-top:4px" data-action="skill-install-dep" data-kind="${esc(opt.kind)}" data-install='${esc(JSON.stringify(opt))}' data-skill-name="${esc(name)}">${esc(opt.label)}</button>`
|
||||
).join('')}</div>`
|
||||
} else if (missingBins.length && !missingEnv.length && !missingConfig.length) {
|
||||
installHtml = `<div class="form-hint" style="margin-top:6px;color:var(--text-tertiary);font-size:var(--font-size-xs)">无自动安装选项,请手动安装: ${missingBins.map(b => `<code>brew install ${esc(b)}</code> 或 <code>npm i -g ${esc(b)}</code>`).join(' / ')}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="clawhub-item skill-card-item" data-name="${esc(name)}" data-desc="${esc(desc)}">
|
||||
<div class="clawhub-item-main">
|
||||
<div class="clawhub-item-title">${emoji} ${esc(name)}</div>
|
||||
<div class="clawhub-item-meta">${esc(source)}${skill.homepage ? ` · <a href="${esc(skill.homepage)}" target="_blank" rel="noopener" style="color:var(--accent)">${esc(skill.homepage)}</a>` : ''}</div>
|
||||
<div class="clawhub-item-desc">${esc(desc)}</div>
|
||||
${missingHtml}
|
||||
${installHtml}
|
||||
</div>
|
||||
<div class="clawhub-item-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-info" data-name="${esc(name)}">详情</button>
|
||||
${statusBadge}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
async function handleInspect(page, slug) {
|
||||
async function handleInfo(page, name) {
|
||||
const detail = page.querySelector('#skill-detail-area')
|
||||
if (!detail) return
|
||||
detail.innerHTML = '<div class="form-hint" style="margin-top:var(--space-md)">正在加载 Skill 详情...</div>'
|
||||
detail.innerHTML = '<div class="form-hint" style="margin-top:var(--space-md)">正在加载详情...</div>'
|
||||
try {
|
||||
const data = await api.clawhubInspect(slug)
|
||||
const skill = data?.skill || {}
|
||||
const owner = data?.owner || {}
|
||||
const version = data?.latestVersion || {}
|
||||
const skill = await api.skillsInfo(name)
|
||||
const s = skill || {}
|
||||
const reqs = s.requirements || {}
|
||||
const miss = s.missing || {}
|
||||
|
||||
let reqsHtml = ''
|
||||
if (reqs.bins?.length) {
|
||||
reqsHtml += `<div style="margin-top:8px"><strong>需要命令:</strong> ${reqs.bins.map(b => {
|
||||
const ok = !(miss.bins || []).includes(b)
|
||||
return `<code style="color:var(--${ok ? 'success' : 'error'})">${ok ? '✓' : '✗'} ${esc(b)}</code>`
|
||||
}).join(' ')}</div>`
|
||||
}
|
||||
if (reqs.env?.length) {
|
||||
reqsHtml += `<div style="margin-top:4px"><strong>环境变量:</strong> ${reqs.env.map(e => {
|
||||
const ok = !(miss.env || []).includes(e)
|
||||
return `<code style="color:var(--${ok ? 'success' : 'error'})">${ok ? '✓' : '✗'} ${esc(e)}</code>`
|
||||
}).join(' ')}</div>`
|
||||
}
|
||||
|
||||
detail.innerHTML = `
|
||||
<div class="clawhub-detail-card">
|
||||
<div class="clawhub-detail-title">${escapeHtml(skill.displayName || slug)}</div>
|
||||
<div class="clawhub-detail-meta">slug: ${escapeHtml(skill.slug || slug)} · 作者: @${escapeHtml(owner.handle || 'unknown')} · 版本: ${escapeHtml(version.version || 'latest')}</div>
|
||||
<div class="clawhub-detail-desc">${escapeHtml(skill.summary || '暂无摘要')}</div>
|
||||
<div class="clawhub-detail-stats">
|
||||
<span>下载 ${escapeHtml(skill?.stats?.downloads ?? '-')}</span>
|
||||
<span>当前安装 ${escapeHtml(skill?.stats?.installsCurrent ?? '-')}</span>
|
||||
<span>Star ${escapeHtml(skill?.stats?.stars ?? '-')}</span>
|
||||
<div class="clawhub-detail-title">${esc(s.emoji || '📦')} ${esc(s.name || name)}</div>
|
||||
<div class="clawhub-detail-meta">
|
||||
来源: ${esc(s.source || '')} · 路径: <code>${esc(s.filePath || '')}</code>
|
||||
${s.homepage ? ` · <a href="${esc(s.homepage)}" target="_blank" rel="noopener">${esc(s.homepage)}</a>` : ''}
|
||||
</div>
|
||||
<div class="clawhub-detail-desc" style="margin-top:8px">${esc(s.description || '')}</div>
|
||||
${reqsHtml}
|
||||
${(s.install || []).length && !s.eligible ? `<div style="margin-top:8px"><strong>安装选项:</strong> ${s.install.map(i => `<span class="form-hint">→ ${esc(i.label)}</span>`).join(' ')}</div>` : ''}
|
||||
</div>
|
||||
`
|
||||
} catch (e) {
|
||||
detail.innerHTML = `<div style="color:var(--error);margin-top:var(--space-md)">加载详情失败: ${escapeHtml(e.message || e)}</div>`
|
||||
detail.innerHTML = `<div style="color:var(--error);margin-top:var(--space-md)">加载详情失败: ${esc(e?.message || e)}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInstall(page, slug) {
|
||||
const btn = page.querySelector(`[data-action="skill-install"][data-slug="${slug}"]`)
|
||||
if (btn) {
|
||||
btn.disabled = true
|
||||
btn.textContent = '安装中...'
|
||||
}
|
||||
async function handleInstallDep(page, btn) {
|
||||
const kind = btn.dataset.kind
|
||||
let spec
|
||||
try { spec = JSON.parse(btn.dataset.install) } catch { spec = {} }
|
||||
const skillName = btn.dataset.skillName || ''
|
||||
btn.disabled = true
|
||||
btn.textContent = '安装中...'
|
||||
try {
|
||||
await api.clawhubInstall(slug)
|
||||
toast(`Skill ${slug} 安装成功`, 'success')
|
||||
await api.skillsInstallDep(kind, spec)
|
||||
toast(`${skillName} 依赖安装成功`, 'success')
|
||||
await loadSkills(page)
|
||||
} catch (e) {
|
||||
const message = (e?.message || String(e || '')).trim()
|
||||
const friendly = message.includes('Rate limit exceeded')
|
||||
? 'ClawHub 当前限流了,稍后再试'
|
||||
: `安装失败: ${message || '未知错误'}`
|
||||
toast(friendly, 'error')
|
||||
toast(`安装失败: ${e?.message || e}`, 'error')
|
||||
btn.disabled = false
|
||||
btn.textContent = spec.label || '重试'
|
||||
}
|
||||
const query = page.querySelector('#skill-search-input')?.value?.trim() || ''
|
||||
await loadSkills(page, query)
|
||||
}
|
||||
|
||||
async function handleClawHubSearch(page) {
|
||||
const input = page.querySelector('#clawhub-search-input')
|
||||
const results = page.querySelector('#clawhub-results')
|
||||
if (!input || !results) return
|
||||
const q = input.value.trim()
|
||||
if (!q) { results.innerHTML = '<div class="clawhub-empty">输入关键词搜索 ClawHub 社区 Skills</div>'; return }
|
||||
results.innerHTML = '<div class="form-hint">正在搜索...</div>'
|
||||
try {
|
||||
const items = await api.skillsClawHubSearch(q)
|
||||
if (!items?.length) { results.innerHTML = '<div class="clawhub-empty">没有找到匹配的 Skill</div>'; return }
|
||||
results.innerHTML = items.map(item => `
|
||||
<div class="clawhub-item">
|
||||
<div class="clawhub-item-main">
|
||||
<div class="clawhub-item-title">${esc(item.slug || item.name || '')}</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')
|
||||
} catch (e) {
|
||||
results.innerHTML = `<div style="color:var(--error)">搜索失败: ${esc(e?.message || e)}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClawHubInstall(page, btn) {
|
||||
const slug = btn.dataset.slug
|
||||
btn.disabled = true
|
||||
btn.textContent = '安装中...'
|
||||
try {
|
||||
await api.skillsClawHubInstall(slug)
|
||||
toast(`Skill ${slug} 安装成功`, 'success')
|
||||
await loadSkills(page)
|
||||
} catch (e) {
|
||||
toast(`安装失败: ${e?.message || e}`, 'error')
|
||||
btn.disabled = false
|
||||
btn.textContent = '安装'
|
||||
}
|
||||
}
|
||||
|
||||
function bindEvents(page) {
|
||||
page.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('[data-action]')
|
||||
if (!btn) return
|
||||
const action = btn.dataset.action
|
||||
switch (action) {
|
||||
case 'skill-search':
|
||||
await loadSkills(page, page.querySelector('#skill-search-input')?.value?.trim() || '')
|
||||
break
|
||||
case 'skill-refresh':
|
||||
await loadSkills(page, page.querySelector('#skill-search-input')?.value?.trim() || '')
|
||||
break
|
||||
switch (btn.dataset.action) {
|
||||
case 'skill-retry':
|
||||
await loadSkills(page, page.querySelector('#skill-search-input')?.value?.trim() || '')
|
||||
await loadSkills(page)
|
||||
break
|
||||
case 'skill-inspect':
|
||||
await handleInspect(page, btn.dataset.slug)
|
||||
case 'skill-info':
|
||||
await handleInfo(page, btn.dataset.name)
|
||||
break
|
||||
case 'skill-install':
|
||||
await handleInstall(page, btn.dataset.slug)
|
||||
case 'skill-install-dep':
|
||||
await handleInstallDep(page, btn)
|
||||
break
|
||||
case 'clawhub-search':
|
||||
await handleClawHubSearch(page)
|
||||
break
|
||||
case 'clawhub-install':
|
||||
await handleClawHubInstall(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()
|
||||
}, 500)
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
page.addEventListener('keydown', async (e) => {
|
||||
if (e.key === 'Enter' && e.target?.id === 'skill-search-input') {
|
||||
if (e.key === 'Enter' && e.target?.id === 'clawhub-search-input') {
|
||||
e.preventDefault()
|
||||
await loadSkills(page, e.target.value.trim())
|
||||
await handleClawHubSearch(page)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -61,7 +61,13 @@ async function loadRoute() {
|
||||
`
|
||||
_contentEl.appendChild(spinnerEl)
|
||||
|
||||
mod = await loader()
|
||||
try {
|
||||
mod = await withTimeout(loader(), 15000, '模块加载超时')
|
||||
} catch (e) {
|
||||
console.error('[router] 模块加载失败:', hash, e)
|
||||
if (thisLoad === _loadId) showLoadError(_contentEl, hash, e)
|
||||
return
|
||||
}
|
||||
_moduleCache[hash] = mod
|
||||
} else {
|
||||
_contentEl.innerHTML = ''
|
||||
@@ -70,7 +76,17 @@ async function loadRoute() {
|
||||
// 如果加载期间路由又变了,丢弃本次结果
|
||||
if (thisLoad !== _loadId) return
|
||||
|
||||
const page = mod.render ? await mod.render() : mod.default ? await mod.default() : mod
|
||||
let page
|
||||
try {
|
||||
const renderFn = mod.render || mod.default
|
||||
page = renderFn ? await withTimeout(renderFn(), 15000, '页面渲染超时') : mod
|
||||
} catch (e) {
|
||||
console.error('[router] 页面渲染失败:', hash, e)
|
||||
// 渲染失败时清除缓存,下次重试时重新加载模块
|
||||
delete _moduleCache[hash]
|
||||
if (thisLoad === _loadId) showLoadError(_contentEl, hash, e)
|
||||
return
|
||||
}
|
||||
if (thisLoad !== _loadId) return
|
||||
|
||||
// 插入页面内容
|
||||
@@ -90,6 +106,31 @@ async function loadRoute() {
|
||||
})
|
||||
}
|
||||
|
||||
function withTimeout(promise, ms, msg) {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error(msg)), ms))
|
||||
])
|
||||
}
|
||||
|
||||
function showLoadError(container, hash, error) {
|
||||
const name = hash.replace('/', '') || 'unknown'
|
||||
container.innerHTML = `
|
||||
<div class="page-loader">
|
||||
<div style="color:var(--error,#ef4444);margin-bottom:12px">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="48" height="48"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
|
||||
</div>
|
||||
<div class="page-loader-text" style="color:var(--text-primary)">页面加载失败</div>
|
||||
<div style="color:var(--text-tertiary);font-size:12px;margin:8px 0 16px;max-width:400px;word-break:break-all">${escHtml(String(error?.message || error))}</div>
|
||||
<button onclick="location.hash='${hash}';location.reload()" style="padding:6px 20px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);cursor:pointer;font-size:13px">重新加载</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')
|
||||
}
|
||||
|
||||
export function getCurrentRoute() {
|
||||
return window.location.hash.slice(1) || _defaultRoute
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user