diff --git a/src/components/sidebar.js b/src/components/sidebar.js index 8f65066c..eb22af54 100644 --- a/src/components/sidebar.js +++ b/src/components/sidebar.js @@ -74,9 +74,9 @@ const NAV_ITEMS_SETUP = [ const ICONS = { setup: '', - dashboard: '', + dashboard: '', chat: '', - services: '', + services: '', logs: '', models: '', agents: '', @@ -86,7 +86,7 @@ const ICONS = { about: '', assistant: '', security: '', - skills: '', + skills: '', channels: '', clock: '', 'bar-chart': '', @@ -109,11 +109,31 @@ function _checkMultiInstances(el) { } const LS_SIDEBAR_COLLAPSED = 'clawpanel_sidebar_collapsed' +const LS_SIDEBAR_GROUPS = 'clawpanel_sidebar_groups' function _isDesktopSidebarCollapsed() { try { return localStorage.getItem(LS_SIDEBAR_COLLAPSED) === '1' } catch { return false } } +function _getSidebarGroupState() { + try { + return JSON.parse(localStorage.getItem(LS_SIDEBAR_GROUPS) || '{}') || {} + } catch { + return {} + } +} + +function _isSidebarGroupOpen(key) { + const state = _getSidebarGroupState() + return state[key] !== false +} + +function _setSidebarGroupOpen(key, open) { + const state = _getSidebarGroupState() + state[key] = !!open + try { localStorage.setItem(LS_SIDEBAR_GROUPS, JSON.stringify(state)) } catch {} +} + function _setDesktopSidebarCollapsed(collapsed) { try { localStorage.setItem(LS_SIDEBAR_COLLAPSED, collapsed ? '1' : '0') } catch {} const sidebar = document.getElementById('sidebar') @@ -155,8 +175,16 @@ export function renderSidebar(el) { const navItems = isOpenclawReady() ? NAV_ITEMS_FULL : NAV_ITEMS_SETUP for (const section of navItems) { - html += `' } html += '' @@ -200,6 +228,22 @@ export function renderSidebar(el) { if (!_delegated) { _delegated = true el.addEventListener('click', (e) => { + const sectionToggle = e.target.closest('[data-section-toggle]') + if (sectionToggle) { + const key = sectionToggle.dataset.sectionToggle + const sectionEl = e.target.closest('.nav-section[data-section]') + const itemsEl = sectionEl?.querySelector('.nav-section-items') + const nextOpen = sectionEl?.classList.contains('collapsed') + if (sectionEl) { + sectionEl.classList.toggle('expanded', !!nextOpen) + sectionEl.classList.toggle('collapsed', !nextOpen) + } + if (itemsEl) itemsEl.style.display = nextOpen ? '' : 'none' + const chevron = sectionToggle.querySelector('.nav-section-chevron') + if (chevron) chevron.textContent = nextOpen ? '−' : '+' + if (key) _setSidebarGroupOpen(key, !!nextOpen) + return + } // 导航点击 const navItem = e.target.closest('.nav-item[data-route]') if (navItem) { diff --git a/src/lib/skills-catalog.js b/src/lib/skills-catalog.js new file mode 100644 index 00000000..7e9f92ce --- /dev/null +++ b/src/lib/skills-catalog.js @@ -0,0 +1,64 @@ +import { api } from './tauri-api.js' + +const SKILLS_CACHE_TTL_MS = 20_000 + +let _skillsCache = { + data: null, + expiresAt: 0, + pending: null, +} + +function normalizeSkillsData(data) { + return { + ...data, + skills: Array.isArray(data?.skills) ? data.skills : [], + } +} + +export function summarizeSkillsCatalog(data) { + const skills = Array.isArray(data?.skills) ? data.skills : [] + 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) + return { + total: skills.length, + eligible: eligible.length, + missing: missing.length, + disabled: disabled.length, + blocked: blocked.length, + } +} + +export function getCachedSkillsCatalog() { + if (!_skillsCache.data) return null + if (Date.now() > _skillsCache.expiresAt) return null + return _skillsCache.data +} + +export function invalidateSkillsCatalog() { + _skillsCache.expiresAt = 0 +} + +export async function loadSkillsCatalog(options = {}) { + const force = !!options.force + const now = Date.now() + if (!force && _skillsCache.data && now <= _skillsCache.expiresAt) { + return _skillsCache.data + } + if (!force && _skillsCache.pending) { + return _skillsCache.pending + } + const request = api.skillsList() + .then(normalizeSkillsData) + .then(data => { + _skillsCache.data = data + _skillsCache.expiresAt = Date.now() + SKILLS_CACHE_TTL_MS + return data + }) + .finally(() => { + if (_skillsCache.pending === request) _skillsCache.pending = null + }) + _skillsCache.pending = request + return request +} diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js index b73e1bd9..eecaf32c 100644 --- a/src/pages/dashboard.js +++ b/src/pages/dashboard.js @@ -4,6 +4,7 @@ import { api } from '../lib/tauri-api.js' import { toast } from '../components/toast.js' import { onGatewayChange } from '../lib/app-state.js' +import { loadSkillsCatalog, summarizeSkillsCatalog } from '../lib/skills-catalog.js' import { navigate } from '../router.js' let _unsubGw = null @@ -80,7 +81,7 @@ async function loadDashboardData(page, fullRefresh = false) { ]), 15000) const secondaryP = withTimeout(Promise.allSettled([ api.listAgents(), - api.readMcpConfig(), + loadSkillsCatalog({ force: fullRefresh }), api.listBackups(), // getStatusSummary 是最重的调用(spawn openclaw status --json),只在首次加载时调用 (!_dashboardInitialized || fullRefresh) ? api.getStatusSummary() : Promise.resolve(null), @@ -122,15 +123,15 @@ async function loadDashboardData(page, fullRefresh = false) { renderStatCards(page, services, version, [], config) - // 第二波:Agent、MCP、备份 → 更新卡片 + 渲染总览 - const [agentsRes, mcpRes, backupsRes, statusRes] = await secondaryP + // 第二波:Agent、Skills、备份 → 更新卡片 + 渲染总览 + const [agentsRes, skillsRes, backupsRes, statusRes] = await secondaryP const agents = agentsRes.status === 'fulfilled' ? agentsRes.value : [] - const mcpConfig = mcpRes.status === 'fulfilled' ? mcpRes.value : null + const skillsData = skillsRes.status === 'fulfilled' ? skillsRes.value : null const backups = backupsRes.status === 'fulfilled' ? backupsRes.value : [] const statusSummary = statusRes.status === 'fulfilled' ? statusRes.value : null renderStatCards(page, services, version, agents, config) - renderOverview(page, services, mcpConfig, backups, config, agents, statusSummary) + renderOverview(page, services, skillsData, backups, config, agents, statusSummary) // 第三波:日志(最低优先级) const logs = await logsP @@ -199,10 +200,11 @@ function renderStatCards(page, services, version, agents, config) { ` } -function renderOverview(page, services, mcpConfig, backups, config, agents, statusSummary) { +function renderOverview(page, services, skillsData, backups, config, agents, statusSummary) { + services = Array.isArray(services) ? services : [] const containerEl = page.querySelector('#dashboard-overview-container') const gw = services.find(s => s.label === 'ai.openclaw.gateway') - const mcpCount = mcpConfig?.mcpServers ? Object.keys(mcpConfig.mcpServers).length : 0 + const skillSummary = summarizeSkillsCatalog(skillsData) const formatDate = (timestamp) => { if (!timestamp) return '——' @@ -255,12 +257,12 @@ function renderOverview(page, services, mcpConfig, backups, config, agents, stat
- +
-
MCP 工具
-
${mcpCount} 个
-
已挂载扩展
+
Skills
+
${skillSummary.total} 个
+
${skillSummary.eligible} 可用 · ${skillSummary.missing + skillSummary.blocked} 待处理 · ${skillSummary.disabled} 已禁用
diff --git a/src/pages/settings.js b/src/pages/settings.js index 123d5c77..5d01b420 100644 --- a/src/pages/settings.js +++ b/src/pages/settings.js @@ -142,6 +142,12 @@ async function loadRegistry(page) { // ===== 事件绑定 ===== function bindEvents(page) { + page.addEventListener('change', (e) => { + if (e.target?.matches?.('[data-name="cloudflared-mode"], [data-name="cloudflared-expose"], [data-name="cloudflared-port"]')) { + syncCloudflaredFormState(page) + } + }) + page.addEventListener('click', async (e) => { const btn = e.target.closest('[data-action]') if (!btn) return @@ -244,3 +250,344 @@ async function handleSaveRegistry(page) { await api.setNpmRegistry(registry) toast('npm 源已保存', 'success') } + +// ===== Cloudflared 公网访问 ===== + +function getCloudflaredForm(page) { + const mode = page.querySelector('[data-name="cloudflared-mode"]')?.value || 'quick' + const exposeTarget = page.querySelector('[data-name="cloudflared-expose"]')?.value || 'gateway' + const customPort = Number(page.querySelector('[data-name="cloudflared-port"]')?.value || 0) + const useHttp2 = !!page.querySelector('[data-name="cloudflared-http2"]')?.checked + const tunnelName = (page.querySelector('[data-name="cloudflared-tunnel"]')?.value || '').trim() + const hostname = (page.querySelector('[data-name="cloudflared-hostname"]')?.value || '').trim() + return { mode, exposeTarget, customPort, useHttp2, tunnelName, hostname } +} + +function resolveExposePort(form) { + if (form.exposeTarget === 'webui') return 1420 + if (form.exposeTarget === 'custom') return form.customPort || 18789 + return 18789 +} + +function validateCloudflaredForm(form) { + if (form.exposeTarget === 'custom' && !(Number(form.customPort) > 0)) { + return '自定义端口模式下必须填写有效端口' + } + if (form.mode === 'named' && !form.tunnelName) { + return '命名隧道模式下必须填写隧道名称' + } + if (form.mode === 'named' && !form.hostname) { + return '命名隧道模式下必须填写绑定域名' + } + return '' +} + +function syncCloudflaredFormState(page) { + const form = getCloudflaredForm(page) + const modeBlocks = page.querySelectorAll('[data-cloudflared-mode-block]') + const exposeBlocks = page.querySelectorAll('[data-cloudflared-expose-block]') + modeBlocks.forEach(node => { + node.style.display = node.dataset.cloudflaredModeBlock === form.mode ? '' : 'none' + }) + exposeBlocks.forEach(node => { + node.style.display = node.dataset.cloudflaredExposeBlock === form.exposeTarget ? '' : 'none' + }) + const resolvedPortEl = page.querySelector('[data-cloudflared-resolved-port]') + if (resolvedPortEl) resolvedPortEl.textContent = String(resolveExposePort(form)) + const customPortInput = page.querySelector('[data-name="cloudflared-port"]') + if (customPortInput) customPortInput.disabled = form.exposeTarget !== 'custom' + const tunnelNameInput = page.querySelector('[data-name="cloudflared-tunnel"]') + const hostnameInput = page.querySelector('[data-name="cloudflared-hostname"]') + if (tunnelNameInput) tunnelNameInput.disabled = form.mode !== 'named' + if (hostnameInput) hostnameInput.disabled = form.mode !== 'named' + const validationEl = page.querySelector('[data-cloudflared-validation]') + const errorText = validateCloudflaredForm(form) + if (validationEl) { + validationEl.textContent = errorText || '当前配置可直接保存;启动前会按你的选择自动计算实际端口。' + validationEl.style.color = errorText ? 'var(--warning)' : 'var(--text-tertiary)' + } + const startBtn = page.querySelector('[data-action="cloudflared-start"]') + if (startBtn) startBtn.disabled = !!errorText +} + +async function loadCloudflared(page) { + const el = page.querySelector('#cloudflared-bar') + if (!el) return + + const cfg = await api.readPanelConfig() + if (!cfg.cloudflared || typeof cfg.cloudflared !== 'object') { + cfg.cloudflared = { mode: 'quick', exposeTarget: 'gateway', customPort: '', useHttp2: true, tunnelName: '', hostname: '' } + await api.writePanelConfig(cfg) + } + const saved = cfg.cloudflared || {} + const status = await api.cloudflaredGetStatus().catch(() => ({ installed: false, running: false })) + page.__cloudflaredInstalled = !!status.installed + + const mode = saved.mode || 'quick' + const exposeTarget = saved.exposeTarget || 'gateway' + const customPort = saved.customPort || '' + const useHttp2 = saved.useHttp2 !== false + const tunnelName = saved.tunnelName || '' + const hostname = saved.hostname || '' + + el.innerHTML = ` +
+
+
+ 运行状态 + +
+
${status.running ? '运行中' : '未运行'}
+
版本 ${escapeHtml(status.version || '未知')}
+
+
+
当前模式
+
${mode === 'named' ? '命名隧道' : '快速隧道'}
+
暴露 ${exposeTarget === 'gateway' ? 'Gateway' : exposeTarget === 'webui' ? 'Web UI' : '自定义端口'}
+
+
+
实际端口
+
${resolveExposePort({ mode, exposeTarget, customPort, useHttp2, tunnelName, hostname })}
+
启动时传给 cloudflared
+
+
+
公网地址
+
${status.url ? '已生成' : '未生成'}
+
${status.url ? `打开公网地址` : '启动后自动生成'}
+
+
+ +
+ + + ${status.running + ? '' + : '' + } + + +
+ +
+
1. 选择暴露目标
+
+ +
推荐默认暴露 Gateway。选择 Gateway 时,会自动把 Cloudflare URL 写入 gateway.controlUi.allowedOrigins
+
+
+
固定暴露 OpenClaw Gateway,端口 18789,无需额外输入。
+
+
+
固定暴露 ClawPanel Web UI,端口 1420,适合只开放管理面板。
+
+
+ +
只有在“自定义端口”模式下这里才生效。
+
+
+ +
+
2. 选择隧道模式
+
+ + +
+
+
快速隧道无需隧道名和域名,适合临时开放,启动后自动生成公网地址。
+
+
+
+ + +
+
命名隧道适合长期使用。通常先登录,再填写隧道名称和域名。
+
+
+ +
+ 推荐顺序:安装 → 登录 Cloudflare → 选择暴露目标 → 选择隧道模式 → 保存设置 → 启动公网访问。 +
+ ` + syncCloudflaredFormState(page) +} + +async function handleCloudflaredSave(page) { + const cfg = await api.readPanelConfig() + const form = getCloudflaredForm(page) + cfg.cloudflared = { + mode: form.mode, + exposeTarget: form.exposeTarget, + customPort: form.customPort, + useHttp2: form.useHttp2, + tunnelName: form.tunnelName, + hostname: form.hostname, + } + await api.writePanelConfig(cfg) + toast('Cloudflared 设置已保存', 'success') +} + +async function handleCloudflaredInstall(page) { + await api.cloudflaredInstall() + await loadCloudflared(page) + toast('Cloudflared 已安装', 'success') +} + +async function handleCloudflaredLogin(page) { + if (page.__cloudflaredInstalled === false) { + toast('请先安装 Cloudflared', 'warning') + syncCloudflaredFormState(page) + return + } + await api.cloudflaredLogin() + await loadCloudflared(page) + toast('Cloudflared 登录完成', 'success') +} + +async function handleCloudflaredStart(page) { + const form = getCloudflaredForm(page) + if (page.__cloudflaredInstalled === false) { + syncCloudflaredFormState(page) + toast('请先安装 Cloudflared', 'warning') + return + } + const validationError = validateCloudflaredForm(form) + if (validationError) { + syncCloudflaredFormState(page) + toast(validationError, 'warning') + return + } + const port = resolveExposePort(form) + await handleCloudflaredSave(page) + await api.cloudflaredStart({ + mode: form.mode, + port, + use_http2: form.useHttp2, + tunnel_name: form.tunnelName || null, + hostname: form.hostname || null, + add_allowed_origins: true, + expose_target: form.exposeTarget, + }) + await loadCloudflared(page) + toast('Cloudflared 已启动', 'success') +} + +async function handleCloudflaredStop(page) { + await api.cloudflaredStop() + await loadCloudflared(page) + toast('Cloudflared 已停止', 'success') +} + +// ===== OpenClaw CLI ===== + +async function loadOpenclawCli(page) { + const bar = page.querySelector('#openclaw-bar') + if (!bar) return + try { + const [cfg, services] = await Promise.all([ + api.readPanelConfig(), + api.getServicesStatus(), + ]) + const svc = Array.isArray(services) + ? services.find(s => s.label === 'ai.openclaw.gateway' || s.id === 'ai.openclaw.gateway' || s.name === 'ai.openclaw.gateway' || s.label === 'openclaw' || s.id === 'openclaw') + : null + const overridePath = cfg?.openclawPath || '' + const cliMeta = buildOpenclawCliMeta(svc, { overridePath }) + const detectedPath = cliMeta.path || '' + const detectedVersion = cliMeta.version || '' + const candidates = Array.isArray(svc?.cli_candidates) ? svc.cli_candidates : [] + const selectedValue = overridePath || detectedPath || '' + + bar.innerHTML = ` +
+
+ + ${escapeHtml(cliMeta.statusLabel)} + ${detectedVersion ? `CLI 版本: ${escapeHtml(detectedVersion)}` : ''} + 路径来源: ${escapeHtml(cliMeta.pathSourceLabel)} + 路径策略: ${escapeHtml(cliMeta.strategyLabel)} +
+ ${detectedPath ? `
CLI 路径: ${escapeHtml(detectedPath)}
` : ''} + ${detectedVersion ? `
版本来源: ${escapeHtml(cliMeta.versionSourceLabel)}
` : ''} +
+ + + + + + +
+
+ 保存路径后将优先使用该路径检测与启动 Gateway。若检测到多条 CLI 路径,可先在下拉框中选中再保存。清除覆盖会回退到自动检测。 +
+
+ ` + + const candidateSelect = bar.querySelector('[data-name="openclaw-candidate"]') + const pathInput = bar.querySelector('[data-name="openclaw-path"]') + candidateSelect?.addEventListener('change', () => { + if (candidateSelect.value) pathInput.value = candidateSelect.value + }) + } catch (e) { + bar.innerHTML = `
加载失败: ${escapeHtml(String(e))}
` + } +} + +async function handleOpenclawSave(page) { + const candidate = page.querySelector('[data-name="openclaw-candidate"]') + const input = page.querySelector('[data-name="openclaw-path"]') + const inputValue = String(input?.value || '').trim() + const candidateValue = String(candidate?.value || '').trim() + const value = inputValue || candidateValue + const cfg = await api.readPanelConfig() + if (!value) { + delete cfg.openclawPath + await api.writePanelConfig(cfg) + toast('路径为空,已清除覆盖', 'info') + await loadOpenclawCli(page) + return + } + cfg.openclawPath = value + await api.writePanelConfig(cfg) + toast('OpenClaw 路径已保存', 'success') + await loadOpenclawCli(page) +} + +async function handleOpenclawClear(page) { + const cfg = await api.readPanelConfig() + delete cfg.openclawPath + await api.writePanelConfig(cfg) + toast('OpenClaw 路径覆盖已清除', 'success') + await loadOpenclawCli(page) +} + +async function handleOpenclawSetup() { + try { + const cfg = await api.readPanelConfig().catch(() => ({})) + await api.writePanelConfig({ ...cfg, forceSetup: true, skipSetup: false }) + window.location.hash = '#/setup' + } catch (e) { + toast('进入初始化设置失败: ' + (e?.message || e), 'error') + } +} diff --git a/src/pages/skills.js b/src/pages/skills.js index af7b3215..ef1014cc 100644 --- a/src/pages/skills.js +++ b/src/pages/skills.js @@ -4,6 +4,7 @@ */ import { api } from '../lib/tauri-api.js' import { toast } from '../components/toast.js' +import { invalidateSkillsCatalog, getCachedSkillsCatalog, loadSkillsCatalog } from '../lib/skills-catalog.js' let _loadSeq = 0 @@ -51,22 +52,29 @@ export async function render() { return page } -async function loadSkills(page) { +async function loadSkills(page, options = {}) { const el = page.querySelector('#skills-tab-installed') if (!el) return const seq = ++_loadSeq + const force = !!options.force + const cached = !force ? getCachedSkillsCatalog() : null - el.innerHTML = `
-
-
正在加载 Skills...
-
` + if (cached) { + renderSkills(el, cached) + } else { + el.innerHTML = `
+
+
正在加载 Skills...
+
` + } try { - const data = await api.skillsList() + const data = await loadSkillsCatalog({ force }) if (seq !== _loadSeq) return renderSkills(el, data) } catch (e) { if (seq !== _loadSeq) return + if (cached) return el.innerHTML = `
加载失败: ${esc(e?.message || e)}
请确认 OpenClaw 已安装并可用
@@ -83,7 +91,7 @@ function renderSkills(el, data) { const disabled = skills.filter(s => s.disabled) const blocked = skills.filter(s => s.blockedByAllowlist && !s.disabled) - const summary = `${eligible.length} 可用 / ${missing.length} 缺依赖 / ${disabled.length} 已禁用` + const summary = `${eligible.length} 可用 / ${missing.length} 缺依赖 / ${disabled.length} 已禁用 / ${blocked.length} 已阻止` el.innerHTML = `
@@ -93,10 +101,37 @@ function renderSkills(el, data) { ${!cliAvailable ? 'CLI 不可用,仅显示本地扫描结果' : ''}
+
+
+
Skills 总数
+
${skills.length}
+
已扫描本地可见技能
+
+
+
可直接使用
+
${eligible.length}
+
环境与依赖已满足
+
+
+
待处理
+
${missing.length + blocked.length}
+
${missing.length} 缺依赖 · ${blocked.length} 已阻止
+
+
+
不可用
+
${disabled.length}
+
当前已禁用
+
+
+
共 ${skills.length} 个 Skills: ${summary}
+ + ${eligible.length ? `
✓ 可用 (${eligible.length})
@@ -145,14 +180,19 @@ function renderSkills(el, data) { // 实时过滤 const input = el.querySelector('#skill-filter-input') + const emptyEl = el.querySelector('#skill-filter-empty') if (input) { input.addEventListener('input', () => { const q = input.value.trim().toLowerCase() + let visibleCount = 0 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' + const visible = !q || name.includes(q) || desc.includes(q) + card.style.display = visible ? '' : 'none' + if (visible) visibleCount += 1 }) + if (emptyEl) emptyEl.style.display = q && visibleCount === 0 ? '' : 'none' }) } } @@ -431,7 +471,8 @@ function bindEvents(page) { if (!btn) return switch (btn.dataset.action) { case 'skill-retry': - await loadSkills(page) + invalidateSkillsCatalog() + await loadSkills(page, { force: true }) break case 'skill-info': await handleInfo(page, btn.dataset.name) diff --git a/src/style/layout.css b/src/style/layout.css index 77a84c3f..72629dc1 100644 --- a/src/style/layout.css +++ b/src/style/layout.css @@ -19,7 +19,8 @@ #sidebar.sidebar-collapsed .nav-section-title, #sidebar.sidebar-collapsed .nav-item span, #sidebar.sidebar-collapsed .sidebar-meta, -#sidebar.sidebar-collapsed .instance-switcher { +#sidebar.sidebar-collapsed .instance-switcher, +#sidebar.sidebar-collapsed .nav-section-toggle { display: none; } #sidebar.sidebar-collapsed .sidebar-header { @@ -247,13 +248,44 @@ margin-bottom: var(--space-md); } +.nav-section-toggle { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); + padding: 6px var(--space-sm); + margin-bottom: var(--space-xs); + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} + +.nav-section-toggle:hover { + background: var(--bg-glass-hover); + color: var(--text-secondary); +} + .nav-section-title { font-size: var(--font-size-xs); - color: var(--text-tertiary); + color: inherit; text-transform: uppercase; letter-spacing: 0.5px; - padding: var(--space-sm) var(--space-sm); - margin-bottom: var(--space-xs); + text-align: left; +} + +.nav-section-chevron { + min-width: 16px; + text-align: center; + font-size: 14px; + line-height: 1; +} + +.nav-section-items { + display: block; } .nav-item {