fix(skills): stabilize loading, install list and scroll behavior

This commit is contained in:
0xsline
2026-03-07 20:56:48 +08:00
parent 881b49c9ef
commit 5eb5a93a76
3 changed files with 646 additions and 0 deletions

View File

@@ -657,6 +657,88 @@ const handlers = {
return true
},
clawhub_trending() {
const fallback = [
{ slug: 'agent-browser', displayName: 'Agent Browser', summary: '浏览器自动化 CLI支持点击、输入、抓取和截图。', author: 'TheSethRose', downloadsText: '73.9k', url: 'https://clawhub.ai/TheSethRose/agent-browser', source: 'clawhub' },
{ slug: 'github', displayName: 'Github', summary: '通过 gh CLI 与 GitHub issues、PR、CI 交互。', author: 'steipete', downloadsText: '72.5k', url: 'https://clawhub.ai/steipete/github', source: 'clawhub' },
{ slug: 'weather', displayName: 'Weather', summary: '获取当前天气和预报,无需 API Key。', author: 'steipete', downloadsText: '61.9k', url: 'https://clawhub.ai/steipete/weather', source: 'clawhub' },
{ slug: 'find-skills', displayName: 'Find Skills', summary: '帮助用户发现并安装合适的 skills。', author: 'JimLiuxinghai', downloadsText: '99.3k', url: 'https://clawhub.ai/JimLiuxinghai/find-skills', source: 'clawhub' },
{ slug: 'summarize', displayName: 'Summarize', summary: '总结网页、PDF、图片、音频等内容。', author: 'steipete', downloadsText: '82.7k', url: 'https://clawhub.ai/steipete/summarize', source: 'clawhub' },
{ slug: 'brave-search', displayName: 'Brave Search', summary: '轻量网页搜索和内容提取。', author: 'steipete', downloadsText: '29.4k', url: 'https://clawhub.ai/steipete/brave-search', source: 'clawhub' },
]
try {
const out = execSync('npx -y clawhub explore --sort downloads --limit 12 --json', { encoding: 'utf8', timeout: 30000 })
const data = JSON.parse(out)
const items = Array.isArray(data) ? data : (Array.isArray(data?.items) ? data.items : [])
const normalized = items
.map(item => ({
slug: String(item?.slug || '').trim(),
displayName: String(item?.displayName || item?.name || item?.slug || '').trim(),
summary: String(item?.summary || item?.description || '').trim(),
author: String(item?.author?.handle || item?.author || '').trim(),
downloadsText: String(item?.stats?.downloadsText || item?.downloadsText || item?.downloads || '').trim(),
url: String(item?.url || item?.canonicalUrl || '').trim(),
source: 'clawhub'
}))
.filter(item => item.slug)
return normalized.length ? normalized : fallback
} catch {
return fallback
}
},
clawhub_search({ query }) {
const q = String(query || '').trim()
if (!q) return []
const out = execSync(`npx -y clawhub search ${JSON.stringify(q)} --limit 12`, { encoding: 'utf8', timeout: 30000 })
return out.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('-'))
.map(line => {
const parts = line.split(/\s{2,}/).filter(Boolean)
return {
slug: parts[0] || '',
displayName: parts[1] || parts[0] || '',
summary: '',
source: 'clawhub'
}
})
},
clawhub_list_installed() {
const skillsDir = path.join(OPENCLAW_DIR, 'skills')
if (!fs.existsSync(skillsDir)) fs.mkdirSync(skillsDir, { recursive: true })
try {
const out = execSync('npx -y clawhub list', { cwd: homedir(), encoding: 'utf8', timeout: 30000 })
const fromCli = out.split('\n')
.map(line => line.trim())
.filter(line => line && line !== 'No installed skills.')
.map(line => ({ slug: line.split(/\s+/)[0], installed: true }))
if (fromCli.length) return fromCli
} catch {}
// 兜底:直接扫描 ~/.openclaw/skills 目录,避免 CLI 输出格式变化导致空列表
try {
return fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(entry => entry.isDirectory() || entry.isSymbolicLink())
.map(entry => ({ slug: entry.name, installed: true }))
} catch {
return []
}
},
clawhub_inspect({ slug }) {
const out = execSync(`npx -y clawhub inspect ${JSON.stringify(slug)} --json`, { encoding: 'utf8', timeout: 30000 })
return JSON.parse(out)
},
clawhub_install({ slug }) {
const skillsDir = path.join(OPENCLAW_DIR, 'skills')
if (!fs.existsSync(skillsDir)) fs.mkdirSync(skillsDir, { recursive: true })
const out = execSync(`npx -y clawhub install ${JSON.stringify(slug)} --workdir .openclaw --dir skills`, { cwd: homedir(), encoding: 'utf8', timeout: 120000 })
return { success: true, slug, output: out.trim() }
},
// 扩展工具
get_cftunnel_status() {
if (!isMac) return { installed: false }

299
src/pages/skills.js Normal file
View File

@@ -0,0 +1,299 @@
/**
* Skills 页面
* 默认展示 ClawHub 热门推荐 + 已安装 + 搜索结果 + 详情 + 安装
*/
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
function escapeHtml(str) {
if (!str) return ''
return String(str)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
export async function render() {
const page = document.createElement('div')
page.className = 'page'
page.innerHTML = `
<div class="page-header">
<h1 class="page-title">Skills</h1>
<p class="page-desc">从 ClawHub 浏览热门推荐、搜索 Skill、查看详情并一键安装</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...') {
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>
`
}
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...')
}
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 })
} 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 })
}
}
}
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 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))
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>
</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>
<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>
</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 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>'}
</div>
</div>
<div id="skill-detail-area"></div>
`
}
async function handleInspect(page, slug) {
const detail = page.querySelector('#skill-detail-area')
if (!detail) return
detail.innerHTML = '<div class="form-hint" style="margin-top:var(--space-md)">正在加载 Skill 详情...</div>'
try {
const data = await api.clawhubInspect(slug)
const skill = data?.skill || {}
const owner = data?.owner || {}
const version = data?.latestVersion || {}
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>
</div>
`
} catch (e) {
detail.innerHTML = `<div style="color:var(--error);margin-top:var(--space-md)">加载详情失败: ${escapeHtml(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 = '安装中...'
}
try {
await api.clawhubInstall(slug)
toast(`Skill ${slug} 安装成功`, 'success')
} catch (e) {
const message = (e?.message || String(e || '')).trim()
const friendly = message.includes('Rate limit exceeded')
? 'ClawHub 当前限流了,稍后再试'
: `安装失败: ${message || '未知错误'}`
toast(friendly, 'error')
}
const query = page.querySelector('#skill-search-input')?.value?.trim() || ''
await loadSkills(page, query)
}
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
case 'skill-retry':
await loadSkills(page, page.querySelector('#skill-search-input')?.value?.trim() || '')
break
case 'skill-inspect':
await handleInspect(page, btn.dataset.slug)
break
case 'skill-install':
await handleInstall(page, btn.dataset.slug)
break
}
})
page.addEventListener('keydown', async (e) => {
if (e.key === 'Enter' && e.target?.id === 'skill-search-input') {
e.preventDefault()
await loadSkills(page, e.target.value.trim())
}
})
}

View File

@@ -214,6 +214,271 @@
border-bottom: 1px solid var(--border-secondary);
}
/* ClawHub Skills */
.clawhub-toolbar {
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
margin-bottom: var(--space-lg);
}
.clawhub-search-input {
flex: 1;
min-width: 260px;
}
.clawhub-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-lg);
}
.clawhub-panel {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: var(--space-md);
}
.clawhub-panel-title {
font-weight: 600;
margin-bottom: var(--space-md);
}
.clawhub-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.skills-scroll-area {
overflow-y: auto;
scrollbar-gutter: stable;
overscroll-behavior: contain;
}
.skills-trending-scroll {
max-height: 560px;
padding-right: 4px;
}
.skills-installed-scroll {
max-height: 420px;
padding-right: 4px;
}
.skills-search-scroll {
max-height: 480px;
padding-right: 4px;
}
.clawhub-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-md);
padding: var(--space-md);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
background: var(--bg-card);
}
.clawhub-item-main {
flex: 1;
min-width: 0;
}
.clawhub-item-title {
font-weight: 600;
margin-bottom: 4px;
}
.clawhub-item-meta {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
margin-bottom: 4px;
}
.clawhub-item-desc {
font-size: var(--font-size-sm);
color: var(--text-secondary);
line-height: 1.5;
}
.clawhub-item-actions {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-shrink: 0;
}
.clawhub-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 8px;
border-radius: 999px;
font-size: var(--font-size-xs);
font-weight: 600;
white-space: nowrap;
}
.clawhub-badge.installed {
background: rgba(34, 197, 94, 0.14);
color: #16a34a;
}
.clawhub-empty {
color: var(--text-tertiary);
padding: var(--space-md);
}
.clawhub-detail-card {
margin-top: var(--space-lg);
padding: var(--space-lg);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: var(--bg-secondary);
}
.clawhub-detail-title {
font-size: var(--font-size-lg);
font-weight: 700;
margin-bottom: 6px;
}
.clawhub-detail-meta,
.clawhub-detail-desc,
.clawhub-detail-stats {
color: var(--text-secondary);
line-height: 1.6;
}
.clawhub-detail-stats {
display: flex;
gap: var(--space-md);
flex-wrap: wrap;
margin-top: var(--space-sm);
font-size: var(--font-size-sm);
}
.skills-hero-panel {
position: relative;
overflow: hidden;
}
.skills-hero-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-md);
}
.skill-hero-card {
position: relative;
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: var(--space-lg);
background:
radial-gradient(circle at top right, rgba(99, 102, 241, 0.12), transparent 32%),
linear-gradient(180deg, rgba(255,255,255,0.02), transparent),
var(--bg-card);
box-shadow: 0 12px 30px rgba(0,0,0,0.12);
}
.skill-hero-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-md);
margin-bottom: var(--space-md);
}
.skill-hero-title {
font-size: var(--font-size-lg);
font-weight: 700;
margin-bottom: 4px;
}
.skill-hero-meta {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
.skill-hero-badges {
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
justify-content: flex-end;
}
.clawhub-badge.hot {
background: rgba(99, 102, 241, 0.14);
color: #6366f1;
}
.skill-hero-desc {
color: var(--text-secondary);
line-height: 1.6;
min-height: 48px;
}
.skill-hero-actions {
margin-top: var(--space-lg);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
flex-wrap: wrap;
}
.skill-hero-installed {
font-size: var(--font-size-sm);
color: var(--text-tertiary);
}
.skills-tips-panel {
background: linear-gradient(180deg, rgba(99, 102, 241, 0.06), transparent), var(--bg-secondary);
}
.skills-tip-list {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.skills-tip-item {
color: var(--text-secondary);
line-height: 1.7;
padding: var(--space-sm) 0;
border-bottom: 1px dashed var(--border-secondary);
}
.skills-tip-item:last-child {
border-bottom: none;
}
.skills-loading-panel,
.skills-load-error {
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: var(--bg-secondary);
padding: var(--space-md);
}
@media (max-width: 900px) {
.clawhub-grid,
.skills-hero-grid {
grid-template-columns: 1fr;
}
.skills-trending-scroll,
.skills-installed-scroll,
.skills-search-scroll {
max-height: none;
padding-right: 0;
}
}
/* 记忆文件管理 */
.memory-layout {
display: grid;