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:
晴天
2026-03-08 01:46:27 +08:00
parent dbc2aa8a61
commit 02e1ef6b14
23 changed files with 1892 additions and 381 deletions

View File

@@ -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?.() },
}
}

View File

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

View File

@@ -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"/>',

View File

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

View File

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

View File

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

View File

@@ -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 需要 formulanode 需要 packagego 需要 moduleuv 需要 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 仅限 macOSWindows 用 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('')

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
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/&lt;name&gt;/</code> 目录即可</div>
<div class="skills-tip-item"><strong>依赖检查</strong>:某些 Skills 需要特定命令行工具(如 gh、curl才能使用</div>
<div class="skills-tip-item"><strong>浏览更多</strong>:访问 <a href="https://clawhub.ai/skills" target="_blank" rel="noopener">ClawHub</a> 发现社区共享的 Skills</div>
</div>
</div>
`
// 实时过滤
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)
}
})
}

View File

@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')
}
export function getCurrentRoute() {
return window.location.hash.slice(1) || _defaultRoute
}