From 5eb5a93a76f9c87da5071655cf6e33f37a2f9a80 Mon Sep 17 00:00:00 2001 From: 0xsline Date: Sat, 7 Mar 2026 20:56:48 +0800 Subject: [PATCH] fix(skills): stabilize loading, install list and scroll behavior --- scripts/dev-api.js | 82 ++++++++++++ src/pages/skills.js | 299 ++++++++++++++++++++++++++++++++++++++++++++ src/style/pages.css | 265 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 646 insertions(+) create mode 100644 src/pages/skills.js diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 9846aa7..e41155e 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -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 } diff --git a/src/pages/skills.js b/src/pages/skills.js new file mode 100644 index 0000000..4ccf1e6 --- /dev/null +++ b/src/pages/skills.js @@ -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, '>') + .replace(/"/g, '"') +} + +export async function render() { + const page = document.createElement('div') + page.className = 'page' + page.innerHTML = ` + +
+
+
+ ` + + 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 = ` +
+
+
${escapeHtml(text)}
+
+ ` +} + +function renderLoadError(el, message, canAutoRetry) { + el.innerHTML = ` +
+
加载失败:${escapeHtml(message)}
+
${canAutoRetry ? '正在自动重试...' : '你可以手动重试'}
+
+ +
+
+ ` +} + +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 '
暂无内容
' + return items.map(item => ` +
+
+
${escapeHtml(item.displayName || item.slug)}
+
${escapeHtml(item.slug)}${item.author ? ` · @${escapeHtml(item.author)}` : ''}${item.downloadsText ? ` · ${escapeHtml(item.downloadsText)}` : ''}
+
${escapeHtml(item.summary || '暂无摘要,可点击查看详情')}
+
+
+ + ${installedSet.has(item.slug) + ? '已安装' + : ``} +
+
+ `).join('') +} + +function renderTrendingCards(items, installedSet) { + if (!items.length) return '
暂无推荐内容
' + return ` +
+ ${items.map(item => ` +
+
+
+
${escapeHtml(item.displayName || item.slug)}
+
${escapeHtml(item.slug)}${item.author ? ` · @${escapeHtml(item.author)}` : ''}
+
+
+ ${item.downloadsText ? `${escapeHtml(item.downloadsText)}` : ''} + ${installedSet.has(item.slug) ? '已安装' : ''} +
+
+
${escapeHtml(item.summary || '暂无摘要')}
+
+ + ${installedSet.has(item.slug) + ? '已在本地可用' + : ``} +
+
+ `).join('')} +
+ ` +} + +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 = ` +
+ + + + 打开 ClawHub +
+ +
+
热门推荐
+ +
+ +
+
+
已安装 Skills
+
+ ${installed.length ? installed.map(item => ` +
+
+
${escapeHtml(item.slug)}
+
已安装到本地 Skills 目录
+
+ 已安装 +
+ `).join('') : '
还没有已安装的 Skill
'} +
+
+
+
使用提示
+
+
默认推荐:首屏展示 ClawHub 热门技能,方便直接浏览
+
搜索:输入关键词后会调用 ClawHub CLI 实时搜索
+
安装:安装受外部服务限流影响,失败时可稍后重试
+
+
+
+ +
+
搜索结果
+
+ ${state.query ? renderSkillItems(results, installedSet) : '
输入关键词开始搜索
'} +
+
+ +
+ ` +} + +async function handleInspect(page, slug) { + const detail = page.querySelector('#skill-detail-area') + if (!detail) return + detail.innerHTML = '
正在加载 Skill 详情...
' + try { + const data = await api.clawhubInspect(slug) + const skill = data?.skill || {} + const owner = data?.owner || {} + const version = data?.latestVersion || {} + detail.innerHTML = ` +
+
${escapeHtml(skill.displayName || slug)}
+
slug: ${escapeHtml(skill.slug || slug)} · 作者: @${escapeHtml(owner.handle || 'unknown')} · 版本: ${escapeHtml(version.version || 'latest')}
+
${escapeHtml(skill.summary || '暂无摘要')}
+
+ 下载 ${escapeHtml(skill?.stats?.downloads ?? '-')} + 当前安装 ${escapeHtml(skill?.stats?.installsCurrent ?? '-')} + Star ${escapeHtml(skill?.stats?.stars ?? '-')} +
+
+ ` + } catch (e) { + detail.innerHTML = `
加载详情失败: ${escapeHtml(e.message || e)}
` + } +} + +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()) + } + }) +} diff --git a/src/style/pages.css b/src/style/pages.css index 763b05b..0d2e65e 100644 --- a/src/style/pages.css +++ b/src/style/pages.css @@ -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;