mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-06 20:02:49 +08:00
feat: 版本管理 + macOS提示优化 + 部署文档更新
- OpenClaw 版本管理: 安装/升级/降级/切换版本, 汉化版/原版选择 - 新增 list_openclaw_versions API (Rust + Web) - upgrade_openclaw 支持指定版本号 - 版本选择器弹窗 (about.js) - macOS Gatekeeper 提示优化: 强调拖入应用程序, No such file 备选 - 部署文档统一使用 npm run serve 替代 npx vite - showUpgradeModal 支持自定义标题 + onClose 回调 - serve.js 路径分隔符跨平台修复 - 扩展工具页面优化 + AI助手危险工具确认
This commit is contained in:
11
README.md
11
README.md
@@ -50,15 +50,20 @@ ClawPanel 是 [OpenClaw](https://github.com/1186258278/OpenClawChineseTranslatio
|
||||
|
||||
> 不确定芯片类型?点击左上角 → 关于本机,查看「芯片」一栏。
|
||||
|
||||
安装方式:打开 `.dmg` 文件,将 ClawPanel 拖入「应用程序」文件夹。
|
||||
安装方式:打开 `.dmg` 文件,**先将 ClawPanel 拖入「应用程序」文件夹**,再双击打开。
|
||||
|
||||
> **⚠️ 首次打开提示"无法验证开发者"?** 由于应用未签名,macOS 会拦截。请在终端执行以下命令解除限制:
|
||||
> **⚠️ 首次打开提示"已损坏"或"无法验证开发者"?** 由于应用未签名,macOS 会拦截。请在终端执行以下命令解除限制:
|
||||
>
|
||||
> ```bash
|
||||
> sudo xattr -rd com.apple.quarantine /Applications/ClawPanel.app
|
||||
> ```
|
||||
>
|
||||
> 或者前往「系统设置 → 隐私与安全性」,找到 ClawPanel 点击「仍要打开」。
|
||||
>
|
||||
> 提示 `No such file`?说明没有拖入应用程序文件夹。请先拖入,或改用:
|
||||
> ```bash
|
||||
> sudo xattr -rd com.apple.quarantine ~/Downloads/ClawPanel.app
|
||||
> ```
|
||||
|
||||
### Windows
|
||||
|
||||
@@ -96,7 +101,7 @@ docker run -d --name clawpanel --restart unless-stopped \
|
||||
sh -c "apt-get update && apt-get install -y git && \
|
||||
npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmmirror.com && \
|
||||
git clone https://github.com/qingchencloud/clawpanel.git /app && \
|
||||
cd /app && npm install && npx vite --port 1420 --host 0.0.0.0"
|
||||
cd /app && npm install && npm run build && npm run serve"
|
||||
```
|
||||
|
||||
📖 详细教程见 [Docker 部署指南](docs/docker-deploy.md)(含 Compose、自定义镜像、Nginx 反向代理等)
|
||||
|
||||
@@ -54,8 +54,8 @@ docker run -d \
|
||||
npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmmirror.com && \
|
||||
openclaw init 2>/dev/null || true && \
|
||||
git clone https://github.com/qingchencloud/clawpanel.git /app && \
|
||||
cd /app && npm install && \
|
||||
npx vite --port 1420 --host 0.0.0.0"
|
||||
cd /app && npm install && npm run build && \
|
||||
npm run serve"
|
||||
```
|
||||
|
||||
访问 `http://服务器IP:1420` 即可使用。
|
||||
@@ -120,7 +120,9 @@ RUN git clone https://github.com/qingchencloud/clawpanel.git . && \
|
||||
|
||||
EXPOSE 1420
|
||||
|
||||
CMD ["npx", "vite", "--port", "1420", "--host", "0.0.0.0"]
|
||||
RUN npm run build
|
||||
|
||||
CMD ["npm", "run", "serve"]
|
||||
```
|
||||
|
||||
启动:
|
||||
@@ -151,7 +153,9 @@ RUN git clone https://github.com/qingchencloud/clawpanel.git . && \
|
||||
|
||||
EXPOSE 1420
|
||||
|
||||
CMD ["npx", "vite", "--port", "1420", "--host", "0.0.0.0"]
|
||||
RUN npm run build
|
||||
|
||||
CMD ["npm", "run", "serve"]
|
||||
```
|
||||
|
||||
构建并运行:
|
||||
|
||||
@@ -991,6 +991,13 @@
|
||||
<span class="dl-format">.dmg</span>
|
||||
</a>
|
||||
</div>
|
||||
<div style="margin-top:16px;padding:12px;border-radius:10px;background:rgba(234,179,8,0.08);border:1px solid rgba(234,179,8,0.15);text-align:left">
|
||||
<p style="font-size:12px;color:var(--text-s);margin-bottom:6px"><strong style="color:#eab308">⚠️ 首次打开提示"已损坏"或"无法验证"?</strong></p>
|
||||
<p style="font-size:12px;color:var(--text-s);margin-bottom:8px">① 先将 ClawPanel <strong>拖入「应用程序」文件夹</strong>,然后打开终端执行:</p>
|
||||
<code style="display:block;font-size:11px;padding:8px 10px;border-radius:6px;background:rgba(0,0,0,0.15);color:var(--accent);word-break:break-all;cursor:pointer" onclick="navigator.clipboard.writeText('sudo xattr -rd com.apple.quarantine /Applications/ClawPanel.app');this.textContent='✅ 已复制!';setTimeout(()=>this.textContent='sudo xattr -rd com.apple.quarantine /Applications/ClawPanel.app',1500)">sudo xattr -rd com.apple.quarantine /Applications/ClawPanel.app</code>
|
||||
<p style="font-size:11px;color:var(--text-t);margin-top:6px">② 或前往 <strong>系统设置 → 隐私与安全性</strong>,找到 ClawPanel 点击「仍要打开」</p>
|
||||
<p style="font-size:11px;color:var(--text-t);margin-top:4px">提示 No such file?说明没拖入应用程序,改用:<code style="font-size:10px;color:var(--accent);cursor:pointer" onclick="navigator.clipboard.writeText('sudo xattr -rd com.apple.quarantine ~/Downloads/ClawPanel.app');this.textContent='✅ 已复制';setTimeout(()=>this.textContent='sudo xattr -rd com.apple.quarantine ~/Downloads/ClawPanel.app',1500)">sudo xattr -rd com.apple.quarantine ~/Downloads/ClawPanel.app</code></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reveal download-card">
|
||||
<span class="download-icon"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="var(--text)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/><path d="m6 8 4 4-4 4"/><path d="M12 16h4"/></svg></span>
|
||||
|
||||
@@ -128,20 +128,27 @@ cd clawpanel
|
||||
npm install
|
||||
```
|
||||
|
||||
### 4. 启动 ClawPanel Web
|
||||
### 4. 构建并启动 ClawPanel Web
|
||||
|
||||
```bash
|
||||
npx vite --port 1420 --host 0.0.0.0
|
||||
npm run build # 构建生产版前端
|
||||
npm run serve # 启动 Web 服务器 (默认 0.0.0.0:1420)
|
||||
```
|
||||
|
||||
自定义端口:
|
||||
|
||||
```bash
|
||||
npm run serve -- --port 8080
|
||||
```
|
||||
|
||||
看到以下输出即为成功:
|
||||
|
||||
```
|
||||
VITE v6.x.x ready in xxx ms
|
||||
|
||||
➜ Local: http://localhost:1420/
|
||||
➜ Network: http://xxx.xxx.xxx.xxx:1420/
|
||||
[dev-api] 开发 API 已启动,配置目录: /root/.openclaw
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 🦀 ClawPanel Web Server (Headless) │
|
||||
│ http://localhost:1420/ │
|
||||
└─────────────────────────────────────────┘
|
||||
[api] API 已启动,配置目录: /root/.openclaw
|
||||
```
|
||||
|
||||
打开浏览器访问 `http://服务器IP:1420` 即可使用 ClawPanel。
|
||||
@@ -164,7 +171,7 @@ docker run -d \
|
||||
sh -c "apt-get update && apt-get install -y git && \
|
||||
npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmmirror.com && \
|
||||
git clone https://github.com/qingchencloud/clawpanel.git /app && \
|
||||
cd /app && npm install && npx vite --port 1420 --host 0.0.0.0"
|
||||
cd /app && npm install && npm run build && npm run serve"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -204,7 +211,7 @@ After=network.target
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/clawpanel
|
||||
ExecStart=/usr/bin/npx vite --port 1420 --host 0.0.0.0
|
||||
ExecStart=/usr/bin/node scripts/serve.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=NODE_ENV=production
|
||||
@@ -236,7 +243,8 @@ sudo journalctl -u clawpanel -f # 查看日志
|
||||
npm install -g pm2
|
||||
|
||||
cd /opt/clawpanel
|
||||
pm2 start "npx vite --port 1420 --host 0.0.0.0" --name clawpanel
|
||||
npm run build
|
||||
pm2 start "npm run serve" --name clawpanel
|
||||
pm2 save
|
||||
pm2 startup # 开机自启
|
||||
```
|
||||
@@ -324,7 +332,7 @@ npm install -g @qingchencloud/openclaw-zh@latest --registry https://registry.npm
|
||||
lsof -i :1420
|
||||
|
||||
# 使用其他端口
|
||||
npx vite --port 3000 --host 0.0.0.0
|
||||
npm run serve -- --port 3000
|
||||
```
|
||||
|
||||
systemd 服务也需要改 ExecStart 中的端口。
|
||||
@@ -365,7 +373,7 @@ openclaw gateway start &
|
||||
|
||||
# 启动 ClawPanel Web
|
||||
cd /opt/clawpanel
|
||||
npx vite --port 1420 --host 0.0.0.0
|
||||
npm run serve
|
||||
```
|
||||
|
||||
或者用 systemd 分别创建两个服务。也可以在 ClawPanel 面板中直接点击「启动」按钮管理 Gateway。
|
||||
|
||||
9
docs/update/latest.json
Normal file
9
docs/update/latest.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": "0.6.0",
|
||||
"minAppVersion": "0.6.0",
|
||||
"hash": "",
|
||||
"url": "",
|
||||
"size": 0,
|
||||
"changelog": "",
|
||||
"releasedAt": ""
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"serve": "node scripts/serve.js",
|
||||
"version:sync": "node scripts/sync-version.js",
|
||||
"version:set": "node scripts/sync-version.js"
|
||||
},
|
||||
|
||||
@@ -840,18 +840,52 @@ const handlers = {
|
||||
return execSync('openclaw gateway install 2>&1', { windowsHide: true }).toString() || 'Gateway 服务已安装'
|
||||
},
|
||||
|
||||
upgrade_openclaw({ source = 'chinese' } = {}) {
|
||||
async list_openclaw_versions({ source = 'chinese' } = {}) {
|
||||
const pkg = source === 'official' ? 'openclaw' : '@qingchencloud/openclaw-zh'
|
||||
const encodedPkg = pkg.replace('/', '%2F')
|
||||
const registry = 'https://registry.npmmirror.com'
|
||||
try {
|
||||
const resp = await fetch(`${registry}/${encodedPkg}`, { headers: { 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000) })
|
||||
const data = await resp.json()
|
||||
const versions = Object.keys(data.versions || {})
|
||||
versions.sort((a, b) => {
|
||||
const pa = a.split(/[^0-9]/).filter(Boolean).map(Number)
|
||||
const pb = b.split(/[^0-9]/).filter(Boolean).map(Number)
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
if ((pb[i] || 0) !== (pa[i] || 0)) return (pb[i] || 0) - (pa[i] || 0)
|
||||
}
|
||||
return 0
|
||||
})
|
||||
return versions
|
||||
} catch (e) {
|
||||
throw new Error('查询版本失败: ' + e.message)
|
||||
}
|
||||
},
|
||||
|
||||
upgrade_openclaw({ source = 'chinese', version } = {}) {
|
||||
const OPENCLAW_DIR = path.join(homedir(), '.openclaw')
|
||||
const pkg = source === 'official' ? 'openclaw' : '@qingchencloud/openclaw-zh'
|
||||
const ver = version || 'latest'
|
||||
const npmBin = isWindows ? 'npm.cmd' : 'npm'
|
||||
try {
|
||||
const out = execSync(`${npmBin} install ${pkg}@latest --prefix "${OPENCLAW_DIR}" 2>&1`, { timeout: 120000, windowsHide: true }).toString()
|
||||
return `升级完成 (${source})\n${out.slice(-200)}`
|
||||
const out = execSync(`${npmBin} install ${pkg}@${ver} --prefix "${OPENCLAW_DIR}" 2>&1`, { timeout: 120000, windowsHide: true }).toString()
|
||||
const action = ver === 'latest' ? '升级' : '安装'
|
||||
return `${action}完成 (${pkg}@${ver})\n${out.slice(-200)}`
|
||||
} catch (e) {
|
||||
throw new Error('升级失败: ' + (e.stderr?.toString() || e.message).slice(-300))
|
||||
throw new Error('安装失败: ' + (e.stderr?.toString() || e.message).slice(-300))
|
||||
}
|
||||
},
|
||||
|
||||
uninstall_openclaw({ cleanConfig = false } = {}) {
|
||||
const npmBin = isWindows ? 'npm.cmd' : 'npm'
|
||||
try { execSync(`${npmBin} uninstall -g openclaw 2>&1`, { timeout: 60000, windowsHide: true }) } catch {}
|
||||
try { execSync(`${npmBin} uninstall -g @qingchencloud/openclaw-zh 2>&1`, { timeout: 60000, windowsHide: true }) } catch {}
|
||||
if (cleanConfig && fs.existsSync(OPENCLAW_DIR)) {
|
||||
try { fs.rmSync(OPENCLAW_DIR, { recursive: true, force: true }) } catch {}
|
||||
}
|
||||
return cleanConfig ? 'OpenClaw 已完全卸载(包括配置文件)' : 'OpenClaw 已卸载(配置文件保留)'
|
||||
},
|
||||
|
||||
uninstall_gateway() {
|
||||
if (isMac) {
|
||||
const uid = getUid()
|
||||
@@ -903,86 +937,85 @@ 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' },
|
||||
]
|
||||
// Skills 管理(模拟 openclaw skills CLI JSON 输出)
|
||||
skills_list() {
|
||||
// 尝试真实 CLI
|
||||
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
|
||||
const out = execSync('npx -y openclaw skills list --json --verbose', { encoding: 'utf8', timeout: 30000 })
|
||||
return JSON.parse(out)
|
||||
} catch {
|
||||
return fallback
|
||||
// CLI 不可用时返回 mock 数据
|
||||
return {
|
||||
skills: [
|
||||
{ name: 'github', description: 'GitHub operations via gh CLI: issues, PRs, CI runs, code review.', source: 'openclaw-bundled', bundled: true, emoji: '🐙', eligible: true, disabled: false, blockedByAllowlist: false, requirements: { bins: ['gh'], anyBins: [], env: [], config: [], os: [] }, missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, install: [{ id: 'brew', kind: 'brew', label: 'Install GitHub CLI (brew)', bins: ['gh'] }] },
|
||||
{ name: 'weather', description: 'Get current weather and forecasts via wttr.in. No API key needed.', source: 'openclaw-bundled', bundled: true, emoji: '🌤️', eligible: true, disabled: false, blockedByAllowlist: false, requirements: { bins: ['curl'], anyBins: [], env: [], config: [], os: [] }, missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, install: [] },
|
||||
{ name: 'summarize', description: 'Summarize web pages, PDFs, images, audio and more.', source: 'openclaw-bundled', bundled: true, emoji: '📝', eligible: false, disabled: false, blockedByAllowlist: false, requirements: { bins: [], anyBins: [], env: [], config: [], os: [] }, missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, install: [] },
|
||||
{ name: 'slack', description: 'Send and read Slack messages via CLI.', source: 'openclaw-bundled', bundled: true, emoji: '💬', eligible: false, disabled: false, blockedByAllowlist: false, requirements: { bins: ['slack-cli'], anyBins: [], env: [], config: [], os: [] }, missing: { bins: ['slack-cli'], anyBins: [], env: [], config: [], os: [] }, install: [{ id: 'brew', kind: 'brew', label: 'Install Slack CLI (brew)', bins: ['slack-cli'] }] },
|
||||
{ name: 'notion', description: 'Create and search Notion pages using the API.', source: 'openclaw-bundled', bundled: true, emoji: '📓', eligible: false, disabled: true, blockedByAllowlist: false, requirements: { bins: [], anyBins: [], env: ['NOTION_API_KEY'], config: [], os: [] }, missing: { bins: [], anyBins: [], env: ['NOTION_API_KEY'], config: [], os: [] }, install: [] },
|
||||
],
|
||||
source: 'mock',
|
||||
cliAvailable: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clawhub_search({ query }) {
|
||||
skills_info({ name }) {
|
||||
try {
|
||||
const out = execSync(`npx -y openclaw skills info ${JSON.stringify(name)} --json`, { encoding: 'utf8', timeout: 30000 })
|
||||
return JSON.parse(out)
|
||||
} catch (e) {
|
||||
throw new Error('查看详情失败: ' + (e.message || e))
|
||||
}
|
||||
},
|
||||
skills_check() {
|
||||
try {
|
||||
const out = execSync('npx -y openclaw skills check --json', { encoding: 'utf8', timeout: 30000 })
|
||||
return JSON.parse(out)
|
||||
} catch {
|
||||
return { summary: { total: 0, eligible: 0, disabled: 0, blocked: 0, missingRequirements: 0 }, eligible: [], disabled: [], blocked: [], missingRequirements: [] }
|
||||
}
|
||||
},
|
||||
skills_install_dep({ kind, spec }) {
|
||||
const cmds = {
|
||||
brew: `brew install ${spec?.formula || ''}`,
|
||||
node: `npm install -g ${spec?.package || ''}`,
|
||||
go: `go install ${spec?.module || ''}`,
|
||||
uv: `uv tool install ${spec?.package || ''}`,
|
||||
}
|
||||
const cmd = cmds[kind]
|
||||
if (!cmd) throw new Error(`不支持的安装类型: ${kind}`)
|
||||
try {
|
||||
const out = execSync(cmd, { encoding: 'utf8', timeout: 120000 })
|
||||
return { success: true, output: out.trim() }
|
||||
} catch (e) {
|
||||
throw new Error(`安装失败: ${e.message || e}`)
|
||||
}
|
||||
},
|
||||
skills_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')
|
||||
const out = execSync(`npx -y clawhub search ${JSON.stringify(q)}`, { encoding: 'utf8', timeout: 30000 })
|
||||
return 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 []
|
||||
.filter(line => line && !line.startsWith('-') && !line.startsWith('Search'))
|
||||
.map(line => {
|
||||
const parts = line.split(/\s{2,}/).filter(Boolean)
|
||||
return { slug: parts[0] || '', description: parts.slice(1).join(' ').trim(), source: 'clawhub' }
|
||||
})
|
||||
.filter(item => item.slug)
|
||||
} catch (e) {
|
||||
throw new Error('搜索失败: ' + (e.message || e))
|
||||
}
|
||||
},
|
||||
|
||||
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 }) {
|
||||
skills_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() }
|
||||
try {
|
||||
const out = execSync(`npx -y clawhub install ${JSON.stringify(slug)}`, { cwd: homedir(), encoding: 'utf8', timeout: 120000 })
|
||||
return { success: true, slug, output: out.trim() }
|
||||
} catch (e) {
|
||||
throw new Error('安装失败: ' + (e.message || e))
|
||||
}
|
||||
},
|
||||
|
||||
// 扩展工具
|
||||
@@ -1568,6 +1601,14 @@ const handlers = {
|
||||
},
|
||||
|
||||
check_panel_update() { return { latest: null, url: 'https://github.com/qingchencloud/clawpanel/releases' } },
|
||||
|
||||
// 前端热更新
|
||||
check_frontend_update() {
|
||||
return { currentVersion: '0.6.0', latestVersion: '0.6.0', hasUpdate: false, compatible: true, updateReady: false, manifest: { version: '0.6.0', minAppVersion: '0.6.0' } }
|
||||
},
|
||||
download_frontend_update() { return { success: true, files: 12, path: path.join(OPENCLAW_DIR, 'clawpanel', 'web-update') } },
|
||||
rollback_frontend_update() { return { success: true } },
|
||||
get_update_status() { return { currentVersion: '0.6.0', updateReady: false, updateVersion: '', updateDir: path.join(OPENCLAW_DIR, 'clawpanel', 'web-update') } },
|
||||
write_env_file({ path: p, config }) {
|
||||
const expanded = p.startsWith('~/') ? path.join(homedir(), p.slice(2)) : p
|
||||
if (!expanded.startsWith(OPENCLAW_DIR)) throw new Error('只允许写入 ~/.openclaw/ 下的文件')
|
||||
@@ -1791,6 +1832,9 @@ async function _apiMiddleware(req, res, next) {
|
||||
}
|
||||
}
|
||||
|
||||
// 导出供 serve.js 独立部署使用
|
||||
export { _initApi, _apiMiddleware }
|
||||
|
||||
export function devApiPlugin() {
|
||||
let _inited = false
|
||||
function ensureInit() {
|
||||
|
||||
@@ -145,13 +145,14 @@ install_clawpanel() {
|
||||
cd "$INSTALL_DIR"
|
||||
npx vite build
|
||||
echo "✅ ClawPanel 安装完成: $INSTALL_DIR"
|
||||
echo "✅ 启动命令: npm run serve"
|
||||
}
|
||||
|
||||
# 创建 systemd 服务
|
||||
setup_systemd() {
|
||||
if ! command -v systemctl &> /dev/null; then
|
||||
echo "⚠️ systemd 不可用,请手动启动:"
|
||||
echo " cd $INSTALL_DIR && npx vite --port $PANEL_PORT --host 0.0.0.0"
|
||||
echo " cd $INSTALL_DIR && npm run serve -- --port $PANEL_PORT"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -168,7 +169,7 @@ After=network.target
|
||||
Type=simple
|
||||
User=$(whoami)
|
||||
WorkingDirectory=$INSTALL_DIR
|
||||
ExecStart=$(which npx) vite preview --port $PANEL_PORT --host 0.0.0.0
|
||||
ExecStart=$(which node) scripts/serve.js --port $PANEL_PORT
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=NODE_ENV=production
|
||||
@@ -189,7 +190,7 @@ After=network.target
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=$INSTALL_DIR
|
||||
ExecStart=$(which npx) vite preview --port $PANEL_PORT --host 0.0.0.0
|
||||
ExecStart=$(which node) scripts/serve.js --port $PANEL_PORT
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=NODE_ENV=production
|
||||
|
||||
205
scripts/serve.js
Normal file
205
scripts/serve.js
Normal file
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ClawPanel 独立 Web 服务器(Headless 模式)
|
||||
* 无需 Tauri / Rust / GUI,纯 Node.js 运行
|
||||
* 适用于 Linux 服务器、Docker 等无桌面环境
|
||||
*
|
||||
* 用法:
|
||||
* npm run serve # 默认 0.0.0.0:1420
|
||||
* npm run serve -- --port 8080
|
||||
* npm run serve -- --host 127.0.0.1 --port 3000
|
||||
* PORT=8080 npm run serve
|
||||
*/
|
||||
import http from 'http'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { homedir } from 'os'
|
||||
import net from 'net'
|
||||
import { _initApi, _apiMiddleware } from './dev-api.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const DIST_DIR = path.resolve(__dirname, '..', 'dist')
|
||||
|
||||
// === 解析命令行参数 ===
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2)
|
||||
let host = process.env.HOST || '0.0.0.0'
|
||||
let port = parseInt(process.env.PORT, 10) || 1420
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--host' && args[i + 1]) host = args[++i]
|
||||
if (args[i] === '--port' && args[i + 1]) port = parseInt(args[++i], 10)
|
||||
if (args[i] === '-p' && args[i + 1]) port = parseInt(args[++i], 10)
|
||||
if (args[i] === '--help' || args[i] === '-h') {
|
||||
console.log(`
|
||||
ClawPanel Web Server (Headless)
|
||||
|
||||
用法: node scripts/serve.js [选项]
|
||||
|
||||
选项:
|
||||
--host <addr> 监听地址 (默认: 0.0.0.0)
|
||||
--port, -p <n> 监听端口 (默认: 1420)
|
||||
--help, -h 显示帮助
|
||||
|
||||
环境变量:
|
||||
HOST 监听地址
|
||||
PORT 监听端口
|
||||
|
||||
示例:
|
||||
npm run serve # 0.0.0.0:1420
|
||||
npm run serve -- --port 8080 # 0.0.0.0:8080
|
||||
npm run serve -- --host 127.0.0.1 -p 3000
|
||||
`)
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
return { host, port }
|
||||
}
|
||||
|
||||
// === MIME 类型映射 ===
|
||||
const MIME_TYPES = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
'.webp': 'image/webp',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.map': 'application/json',
|
||||
}
|
||||
|
||||
// === 静态文件服务 ===
|
||||
function serveStatic(req, res) {
|
||||
// URL 去掉 query string
|
||||
const urlPath = req.url.split('?')[0]
|
||||
let filePath = path.join(DIST_DIR, urlPath === '/' ? 'index.html' : urlPath)
|
||||
|
||||
// 安全检查:不允许目录遍历
|
||||
if (!filePath.startsWith(DIST_DIR)) {
|
||||
res.statusCode = 403
|
||||
res.end('Forbidden')
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试读取文件
|
||||
fs.stat(filePath, (err, stats) => {
|
||||
if (!err && stats.isFile()) {
|
||||
sendFile(res, filePath)
|
||||
return
|
||||
}
|
||||
|
||||
// SPA fallback:非 API、非静态资源 → index.html
|
||||
const ext = path.extname(urlPath)
|
||||
if (!ext || ext === '.html') {
|
||||
sendFile(res, path.join(DIST_DIR, 'index.html'))
|
||||
} else {
|
||||
res.statusCode = 404
|
||||
res.end('Not Found')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function sendFile(res, filePath) {
|
||||
const ext = path.extname(filePath)
|
||||
const contentType = MIME_TYPES[ext] || 'application/octet-stream'
|
||||
|
||||
// 缓存策略:资源文件长缓存,HTML 不缓存
|
||||
if (ext === '.html') {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||
} else if (filePath.includes(`${path.sep}assets${path.sep}`)) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', contentType)
|
||||
fs.createReadStream(filePath).pipe(res)
|
||||
}
|
||||
|
||||
// === 启动服务器 ===
|
||||
async function main() {
|
||||
// 检查 dist 目录
|
||||
if (!fs.existsSync(path.join(DIST_DIR, 'index.html'))) {
|
||||
console.error('❌ 未找到 dist/index.html,请先运行: npm run build')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const { host, port } = parseArgs()
|
||||
|
||||
// 初始化 API
|
||||
_initApi()
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
// CORS 头(方便开发调试)
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||
if (req.method === 'OPTIONS') { res.statusCode = 204; res.end(); return }
|
||||
|
||||
// API 请求
|
||||
await _apiMiddleware(req, res, () => {
|
||||
// 非 API → 静态文件
|
||||
serveStatic(req, res)
|
||||
})
|
||||
})
|
||||
|
||||
// WebSocket 代理
|
||||
let gatewayPort = 18789
|
||||
try {
|
||||
const cfgPath = path.join(homedir(), '.openclaw', 'openclaw.json')
|
||||
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'))
|
||||
gatewayPort = cfg?.gateway?.port || 18789
|
||||
} catch {}
|
||||
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
if (!req.url?.startsWith('/ws')) {
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
|
||||
const target = net.createConnection(gatewayPort, '127.0.0.1', () => {
|
||||
const reqLine = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`
|
||||
const headers = Object.entries(req.headers)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('\r\n')
|
||||
target.write(reqLine + headers + '\r\n\r\n')
|
||||
if (head.length) target.write(head)
|
||||
socket.pipe(target)
|
||||
target.pipe(socket)
|
||||
})
|
||||
|
||||
target.on('error', () => socket.destroy())
|
||||
socket.on('error', () => target.destroy())
|
||||
})
|
||||
|
||||
server.listen(port, host, () => {
|
||||
console.log('')
|
||||
console.log(' ┌─────────────────────────────────────────┐')
|
||||
console.log(' │ │')
|
||||
console.log(' │ 🦀 ClawPanel Web Server (Headless) │')
|
||||
console.log(' │ │')
|
||||
console.log(` │ http://${host === '0.0.0.0' ? 'localhost' : host}:${port}/`.padEnd(44) + '│')
|
||||
if (host === '0.0.0.0') {
|
||||
console.log(` │ http://0.0.0.0:${port}/`.padEnd(44) + '│')
|
||||
}
|
||||
console.log(' │ │')
|
||||
console.log(' └─────────────────────────────────────────┘')
|
||||
console.log('')
|
||||
console.log(' 按 Ctrl+C 停止服务')
|
||||
console.log('')
|
||||
})
|
||||
|
||||
// 优雅退出
|
||||
process.on('SIGINT', () => { console.log('\n 👋 服务已停止'); process.exit(0) })
|
||||
process.on('SIGTERM', () => { console.log('\n 👋 服务已停止'); process.exit(0) })
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('启动失败:', e); process.exit(1) })
|
||||
@@ -495,16 +495,55 @@ fn npm_package_name(source: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行 npm 全局升级 openclaw(流式推送日志)
|
||||
/// 获取指定源的所有可用版本列表(从 npm registry 查询)
|
||||
#[tauri::command]
|
||||
pub async fn upgrade_openclaw(app: tauri::AppHandle, source: String) -> Result<String, String> {
|
||||
pub async fn list_openclaw_versions(source: String) -> Result<Vec<String>, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| format!("HTTP 初始化失败: {e}"))?;
|
||||
let pkg = npm_package_name(&source)
|
||||
.replace('/', "%2F");
|
||||
let registry = get_configured_registry();
|
||||
let url = format!("{registry}/{pkg}");
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Accept", "application/json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("查询版本失败: {e}"))?;
|
||||
let json: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("解析响应失败: {e}"))?;
|
||||
let versions = json
|
||||
.get("versions")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|obj| {
|
||||
let mut vers: Vec<String> = obj.keys().cloned().collect();
|
||||
// 按版本号排序(新版本在前)
|
||||
vers.sort_by(|a, b| {
|
||||
let pa: Vec<u32> = a.split(|c: char| !c.is_ascii_digit()).filter_map(|s| s.parse().ok()).collect();
|
||||
let pb: Vec<u32> = b.split(|c: char| !c.is_ascii_digit()).filter_map(|s| s.parse().ok()).collect();
|
||||
pb.cmp(&pa)
|
||||
});
|
||||
vers
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Ok(versions)
|
||||
}
|
||||
|
||||
/// 执行 npm 全局安装/升级/降级 openclaw(流式推送日志)
|
||||
#[tauri::command]
|
||||
pub async fn upgrade_openclaw(app: tauri::AppHandle, source: String, version: Option<String>) -> Result<String, String> {
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::Stdio;
|
||||
use tauri::Emitter;
|
||||
|
||||
let current_source = detect_installed_source();
|
||||
let pkg_name = npm_package_name(&source);
|
||||
let pkg = format!("{}@latest", pkg_name);
|
||||
let ver = version.as_deref().unwrap_or("latest");
|
||||
let pkg = format!("{}@{}", pkg_name, ver);
|
||||
|
||||
// 切换源时需要卸载旧包,但为避免安装失败导致 CLI 丢失,
|
||||
// 先安装新包,成功后再卸载旧包
|
||||
@@ -652,11 +691,128 @@ pub async fn upgrade_openclaw(app: tauri::AppHandle, source: String) -> Result<S
|
||||
}
|
||||
|
||||
let new_ver = get_local_version().await.unwrap_or_else(|| "未知".into());
|
||||
let msg = format!("✅ 升级成功,当前版本: {new_ver}");
|
||||
let action = if ver == "latest" { "升级" } else { "安装" };
|
||||
let msg = format!("✅ {action}成功,当前版本: {new_ver}");
|
||||
let _ = app.emit("upgrade-log", &msg);
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
/// 卸载 OpenClaw(npm uninstall + 可选清理配置)
|
||||
#[tauri::command]
|
||||
pub async fn uninstall_openclaw(
|
||||
app: tauri::AppHandle,
|
||||
clean_config: bool,
|
||||
) -> Result<String, String> {
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::Stdio;
|
||||
use tauri::Emitter;
|
||||
|
||||
let source = detect_installed_source();
|
||||
let pkg = npm_package_name(&source);
|
||||
|
||||
// 1. 先停止 Gateway
|
||||
let _ = app.emit("upgrade-log", "正在停止 Gateway...");
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let uid = get_uid().unwrap_or(501);
|
||||
let _ = Command::new("launchctl")
|
||||
.args(["bootout", &format!("gui/{uid}/ai.openclaw.gateway")])
|
||||
.output();
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let _ = openclaw_command().args(["gateway", "stop"]).output();
|
||||
}
|
||||
|
||||
// 2. 卸载 Gateway 服务
|
||||
let _ = app.emit("upgrade-log", "正在卸载 Gateway 服务...");
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let _ = openclaw_command()
|
||||
.args(["gateway", "uninstall"])
|
||||
.output();
|
||||
}
|
||||
|
||||
// 3. npm uninstall
|
||||
let _ = app.emit("upgrade-log", format!("$ npm uninstall -g {pkg}"));
|
||||
let _ = app.emit("upgrade-progress", 20);
|
||||
|
||||
let mut child = npm_command()
|
||||
.args(["uninstall", "-g", pkg])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("执行卸载命令失败: {e}"))?;
|
||||
|
||||
let stderr = child.stderr.take();
|
||||
let stdout = child.stdout.take();
|
||||
|
||||
let app2 = app.clone();
|
||||
let handle = std::thread::spawn(move || {
|
||||
if let Some(pipe) = stderr {
|
||||
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
|
||||
let _ = app2.emit("upgrade-log", &line);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(pipe) = stdout {
|
||||
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
|
||||
let _ = app.emit("upgrade-log", &line);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = handle.join();
|
||||
let _ = app.emit("upgrade-progress", 60);
|
||||
|
||||
let status = child.wait().map_err(|e| format!("等待进程失败: {e}"))?;
|
||||
if !status.success() {
|
||||
let code = status
|
||||
.code()
|
||||
.map(|c| c.to_string())
|
||||
.unwrap_or("unknown".into());
|
||||
return Err(format!("卸载失败,exit code: {code}"));
|
||||
}
|
||||
|
||||
// 4. 两个包都尝试卸载(确保干净)
|
||||
let other_pkg = if source == "official" {
|
||||
"@qingchencloud/openclaw-zh"
|
||||
} else {
|
||||
"openclaw"
|
||||
};
|
||||
let _ = app.emit("upgrade-log", format!("清理 {other_pkg}..."));
|
||||
let _ = npm_command()
|
||||
.args(["uninstall", "-g", other_pkg])
|
||||
.output();
|
||||
let _ = app.emit("upgrade-progress", 80);
|
||||
|
||||
// 5. 可选:清理配置目录
|
||||
if clean_config {
|
||||
let config_dir = super::openclaw_dir();
|
||||
if config_dir.exists() {
|
||||
let _ = app.emit(
|
||||
"upgrade-log",
|
||||
format!("清理配置目录: {}", config_dir.display()),
|
||||
);
|
||||
if let Err(e) = std::fs::remove_dir_all(&config_dir) {
|
||||
let _ = app.emit(
|
||||
"upgrade-log",
|
||||
format!("⚠️ 清理配置目录失败: {e}(可能有文件被占用)"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = app.emit("upgrade-progress", 100);
|
||||
let msg = if clean_config {
|
||||
"✅ OpenClaw 已完全卸载(包括配置文件)"
|
||||
} else {
|
||||
"✅ OpenClaw 已卸载(配置文件保留在 ~/.openclaw/)"
|
||||
};
|
||||
let _ = app.emit("upgrade-log", msg);
|
||||
Ok(msg.into())
|
||||
}
|
||||
|
||||
/// 自动初始化配置文件(CLI 已装但 openclaw.json 不存在时)
|
||||
#[tauri::command]
|
||||
pub fn init_openclaw_config() -> Result<Value, String> {
|
||||
|
||||
@@ -10,6 +10,8 @@ pub mod logs;
|
||||
pub mod memory;
|
||||
pub mod pairing;
|
||||
pub mod service;
|
||||
pub mod skills;
|
||||
pub mod update;
|
||||
|
||||
/// 获取 OpenClaw 配置目录 (~/.openclaw/)
|
||||
pub fn openclaw_dir() -> PathBuf {
|
||||
|
||||
271
src-tauri/src/commands/skills.rs
Normal file
271
src-tauri/src/commands/skills.rs
Normal file
@@ -0,0 +1,271 @@
|
||||
use crate::utils::openclaw_command_async;
|
||||
use serde_json::Value;
|
||||
|
||||
/// 列出所有 Skills 及其状态(openclaw skills list --json)
|
||||
#[tauri::command]
|
||||
pub async fn skills_list() -> Result<Value, String> {
|
||||
let output = openclaw_command_async()
|
||||
.args(["skills", "list", "--json", "--verbose"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match output {
|
||||
Ok(o) if o.status.success() => {
|
||||
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||
serde_json::from_str(&stdout).map_err(|e| format!("解析失败: {e}"))
|
||||
}
|
||||
_ => {
|
||||
// CLI 不可用时,兜底扫描本地 skills 目录
|
||||
scan_local_skills()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 查看单个 Skill 详情(openclaw skills info <name> --json)
|
||||
#[tauri::command]
|
||||
pub async fn skills_info(name: String) -> Result<Value, String> {
|
||||
let output = openclaw_command_async()
|
||||
.args(["skills", "info", &name, "--json"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("执行 openclaw 失败: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("获取详情失败: {}", stderr.trim()));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
serde_json::from_str(&stdout).map_err(|e| format!("解析详情失败: {e}"))
|
||||
}
|
||||
|
||||
/// 检查 Skills 依赖状态(openclaw skills check --json)
|
||||
#[tauri::command]
|
||||
pub async fn skills_check() -> Result<Value, String> {
|
||||
let output = openclaw_command_async()
|
||||
.args(["skills", "check", "--json"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("执行 openclaw 失败: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("检查失败: {}", stderr.trim()));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
serde_json::from_str(&stdout).map_err(|e| format!("解析失败: {e}"))
|
||||
}
|
||||
|
||||
/// 安装 Skill 依赖(根据 install spec 执行 brew/npm/go/uv/download)
|
||||
#[tauri::command]
|
||||
pub async fn skills_install_dep(kind: String, spec: Value) -> Result<Value, String> {
|
||||
let path_env = super::enhanced_path();
|
||||
|
||||
let (program, args) = match kind.as_str() {
|
||||
"brew" => {
|
||||
let formula = spec
|
||||
.get("formula")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("缺少 formula 参数")?
|
||||
.to_string();
|
||||
("brew".to_string(), vec!["install".to_string(), formula])
|
||||
}
|
||||
"node" => {
|
||||
let package = spec
|
||||
.get("package")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("缺少 package 参数")?
|
||||
.to_string();
|
||||
("npm".to_string(), vec!["install".to_string(), "-g".to_string(), package])
|
||||
}
|
||||
"go" => {
|
||||
let module = spec
|
||||
.get("module")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("缺少 module 参数")?
|
||||
.to_string();
|
||||
("go".to_string(), vec!["install".to_string(), module])
|
||||
}
|
||||
"uv" => {
|
||||
let package = spec
|
||||
.get("package")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("缺少 package 参数")?
|
||||
.to_string();
|
||||
("uv".to_string(), vec!["tool".to_string(), "install".to_string(), package])
|
||||
}
|
||||
other => return Err(format!("不支持的安装类型: {other}")),
|
||||
};
|
||||
|
||||
let output = tokio::process::Command::new(&program)
|
||||
.args(&args)
|
||||
.env("PATH", &path_env)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("执行 {program} 失败: {e}"))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"安装失败 ({program} {}): {}",
|
||||
output.status,
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"output": stdout.trim(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// 从 ClawHub 安装 Skill(npx clawhub install <slug>)
|
||||
#[tauri::command]
|
||||
pub async fn skills_clawhub_install(slug: String) -> Result<Value, String> {
|
||||
let path_env = super::enhanced_path();
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
|
||||
// 确保 skills 目录存在
|
||||
let skills_dir = super::openclaw_dir().join("skills");
|
||||
if !skills_dir.exists() {
|
||||
std::fs::create_dir_all(&skills_dir)
|
||||
.map_err(|e| format!("创建 skills 目录失败: {e}"))?;
|
||||
}
|
||||
|
||||
let output = tokio::process::Command::new("npx")
|
||||
.args(["-y", "clawhub", "install", &slug])
|
||||
.env("PATH", &path_env)
|
||||
.current_dir(&home)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("执行 clawhub 失败: {e}"))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!("安装失败: {}", stderr.trim()));
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"slug": slug,
|
||||
"output": stdout.trim(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// 从 ClawHub 搜索 Skills(npx clawhub search <query>)
|
||||
#[tauri::command]
|
||||
pub async fn skills_clawhub_search(query: String) -> Result<Value, String> {
|
||||
let q = query.trim().to_string();
|
||||
if q.is_empty() {
|
||||
return Ok(Value::Array(vec![]));
|
||||
}
|
||||
|
||||
let path_env = super::enhanced_path();
|
||||
let output = tokio::process::Command::new("npx")
|
||||
.args(["-y", "clawhub", "search", &q])
|
||||
.env("PATH", &path_env)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("执行 clawhub 失败: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("搜索失败: {}", stderr.trim()));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// clawhub search 输出是文本行,每行一个 skill
|
||||
let items: Vec<Value> = stdout
|
||||
.lines()
|
||||
.map(|l| l.trim())
|
||||
.filter(|l| !l.is_empty() && !l.starts_with('-') && !l.starts_with("Search"))
|
||||
.map(|l| {
|
||||
let parts: Vec<&str> = l.splitn(2, char::is_whitespace).collect();
|
||||
let slug = parts.first().unwrap_or(&"").trim();
|
||||
let desc = parts.get(1).unwrap_or(&"").trim();
|
||||
serde_json::json!({
|
||||
"slug": slug,
|
||||
"description": desc,
|
||||
"source": "clawhub"
|
||||
})
|
||||
})
|
||||
.filter(|v| !v["slug"].as_str().unwrap_or("").is_empty())
|
||||
.collect();
|
||||
|
||||
Ok(Value::Array(items))
|
||||
}
|
||||
|
||||
/// CLI 不可用时的兜底:扫描 ~/.openclaw/skills 目录
|
||||
fn scan_local_skills() -> Result<Value, String> {
|
||||
let skills_dir = super::openclaw_dir().join("skills");
|
||||
if !skills_dir.exists() {
|
||||
return Ok(serde_json::json!({
|
||||
"skills": [],
|
||||
"source": "local-scan",
|
||||
"cliAvailable": false
|
||||
}));
|
||||
}
|
||||
|
||||
let mut skills = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&skills_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let ft = match entry.file_type() {
|
||||
Ok(ft) => ft,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !ft.is_dir() && !ft.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let skill_md = entry.path().join("SKILL.md");
|
||||
let description = if skill_md.exists() {
|
||||
// 尝试从 SKILL.md 的 frontmatter 中提取 description
|
||||
parse_skill_description(&skill_md)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
skills.push(serde_json::json!({
|
||||
"name": name,
|
||||
"description": description,
|
||||
"source": "managed",
|
||||
"eligible": true,
|
||||
"bundled": false,
|
||||
"filePath": skill_md.to_string_lossy(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"skills": skills,
|
||||
"source": "local-scan",
|
||||
"cliAvailable": false
|
||||
}))
|
||||
}
|
||||
|
||||
/// 从 SKILL.md 的 YAML frontmatter 中提取 description
|
||||
fn parse_skill_description(path: &std::path::Path) -> String {
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return String::new(),
|
||||
};
|
||||
// frontmatter 格式: ---\n...\n---
|
||||
if !content.starts_with("---") {
|
||||
return String::new();
|
||||
}
|
||||
if let Some(end) = content[3..].find("---") {
|
||||
let fm = &content[3..3 + end];
|
||||
for line in fm.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(rest) = trimmed.strip_prefix("description:") {
|
||||
return rest.trim().trim_matches('"').trim_matches('\'').to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
215
src-tauri/src/commands/update.rs
Normal file
215
src-tauri/src/commands/update.rs
Normal file
@@ -0,0 +1,215 @@
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 前端热更新目录 (~/.openclaw/clawpanel/web-update/)
|
||||
pub fn update_dir() -> PathBuf {
|
||||
super::openclaw_dir().join("clawpanel").join("web-update")
|
||||
}
|
||||
|
||||
/// 更新清单 URL(GitHub Pages 托管)
|
||||
const LATEST_JSON_URL: &str = "https://claw.qt.cool/update/latest.json";
|
||||
|
||||
/// 检查前端是否有新版本可用
|
||||
#[tauri::command]
|
||||
pub async fn check_frontend_update() -> Result<Value, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.user_agent("ClawPanel")
|
||||
.build()
|
||||
.map_err(|e| format!("HTTP 客户端错误: {e}"))?;
|
||||
|
||||
let resp = client
|
||||
.get(LATEST_JSON_URL)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("请求失败: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("服务器返回 {}", resp.status()));
|
||||
}
|
||||
|
||||
let manifest: Value = resp.json().await.map_err(|e| format!("解析失败: {e}"))?;
|
||||
|
||||
let latest = manifest
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let current = env!("CARGO_PKG_VERSION");
|
||||
|
||||
// 检查最低兼容的 app 版本(前端可能依赖较新的 Rust 后端命令)
|
||||
let min_app = manifest
|
||||
.get("minAppVersion")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("0.0.0");
|
||||
|
||||
let compatible = version_ge(current, min_app);
|
||||
let has_update = !latest.is_empty() && latest != current && compatible;
|
||||
let update_ready = update_dir().join("index.html").exists();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"currentVersion": current,
|
||||
"latestVersion": latest,
|
||||
"hasUpdate": has_update,
|
||||
"compatible": compatible,
|
||||
"updateReady": update_ready,
|
||||
"manifest": manifest
|
||||
}))
|
||||
}
|
||||
|
||||
/// 下载并解压前端更新包
|
||||
#[tauri::command]
|
||||
pub async fn download_frontend_update(url: String, expected_hash: String) -> Result<Value, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.user_agent("ClawPanel")
|
||||
.build()
|
||||
.map_err(|e| format!("HTTP 客户端错误: {e}"))?;
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("下载失败: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("下载失败: HTTP {}", resp.status()));
|
||||
}
|
||||
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("读取数据失败: {e}"))?;
|
||||
|
||||
// 校验 SHA-256
|
||||
if !expected_hash.is_empty() {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&bytes);
|
||||
let hash = format!("{:x}", hasher.finalize());
|
||||
let expected = expected_hash
|
||||
.strip_prefix("sha256:")
|
||||
.unwrap_or(&expected_hash);
|
||||
if hash != expected {
|
||||
return Err(format!("哈希校验失败: 期望 {},实际 {}", expected, hash));
|
||||
}
|
||||
}
|
||||
|
||||
// 清理旧更新,解压新包
|
||||
let dir = update_dir();
|
||||
if dir.exists() {
|
||||
fs::remove_dir_all(&dir).map_err(|e| format!("清理旧更新失败: {e}"))?;
|
||||
}
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("创建更新目录失败: {e}"))?;
|
||||
|
||||
let cursor = std::io::Cursor::new(bytes.as_ref());
|
||||
let mut archive = zip::ZipArchive::new(cursor).map_err(|e| format!("解压失败: {e}"))?;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| format!("读取压缩条目失败: {e}"))?;
|
||||
|
||||
let name = file.name().to_string();
|
||||
let target = dir.join(&name);
|
||||
|
||||
if name.ends_with('/') {
|
||||
fs::create_dir_all(&target).map_err(|e| format!("创建子目录失败: {e}"))?;
|
||||
} else {
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("创建父目录失败: {e}"))?;
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf)
|
||||
.map_err(|e| format!("读取文件内容失败: {e}"))?;
|
||||
fs::write(&target, &buf).map_err(|e| format!("写入文件失败: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"files": archive.len(),
|
||||
"path": dir.to_string_lossy()
|
||||
}))
|
||||
}
|
||||
|
||||
/// 回退前端更新(删除热更新目录,下次启动使用内嵌资源)
|
||||
#[tauri::command]
|
||||
pub fn rollback_frontend_update() -> Result<Value, String> {
|
||||
let dir = update_dir();
|
||||
if dir.exists() {
|
||||
fs::remove_dir_all(&dir).map_err(|e| format!("回退失败: {e}"))?;
|
||||
}
|
||||
Ok(serde_json::json!({ "success": true }))
|
||||
}
|
||||
|
||||
/// 获取当前热更新状态
|
||||
#[tauri::command]
|
||||
pub fn get_update_status() -> Result<Value, String> {
|
||||
let dir = update_dir();
|
||||
let ready = dir.join("index.html").exists();
|
||||
|
||||
// 尝试读取已下载更新的版本信息
|
||||
let update_version = if ready {
|
||||
dir.join(".version")
|
||||
.exists()
|
||||
.then(|| fs::read_to_string(dir.join(".version")).ok())
|
||||
.flatten()
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"currentVersion": env!("CARGO_PKG_VERSION"),
|
||||
"updateReady": ready,
|
||||
"updateVersion": update_version,
|
||||
"updateDir": dir.to_string_lossy()
|
||||
}))
|
||||
}
|
||||
|
||||
/// 简单的语义化版本比较:current >= required
|
||||
fn version_ge(current: &str, required: &str) -> bool {
|
||||
let parse = |s: &str| -> Vec<u32> {
|
||||
s.trim_start_matches('v')
|
||||
.split('.')
|
||||
.filter_map(|p| p.parse().ok())
|
||||
.collect()
|
||||
};
|
||||
let c = parse(current);
|
||||
let r = parse(required);
|
||||
for i in 0..r.len().max(c.len()) {
|
||||
let cv = c.get(i).copied().unwrap_or(0);
|
||||
let rv = r.get(i).copied().unwrap_or(0);
|
||||
if cv > rv {
|
||||
return true;
|
||||
}
|
||||
if cv < rv {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// 根据文件扩展名推断 MIME 类型
|
||||
pub fn mime_from_path(path: &str) -> &'static str {
|
||||
match path.rsplit('.').next().unwrap_or("") {
|
||||
"html" => "text/html",
|
||||
"js" | "mjs" => "application/javascript",
|
||||
"css" => "text/css",
|
||||
"json" => "application/json",
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"svg" => "image/svg+xml",
|
||||
"ico" => "image/x-icon",
|
||||
"woff" => "font/woff",
|
||||
"woff2" => "font/woff2",
|
||||
"ttf" => "font/ttf",
|
||||
"wasm" => "application/wasm",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,57 @@ mod models;
|
||||
mod tray;
|
||||
mod utils;
|
||||
|
||||
use commands::{agent, assistant, config, device, extensions, logs, memory, pairing, service};
|
||||
use commands::{
|
||||
agent, assistant, config, device, extensions, logs, memory, pairing, service, skills, update,
|
||||
};
|
||||
|
||||
pub fn run() {
|
||||
let hot_update_dir = commands::openclaw_dir()
|
||||
.join("clawpanel")
|
||||
.join("web-update");
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.register_uri_scheme_protocol("tauri", move |ctx, request| {
|
||||
let uri_path = request.uri().path();
|
||||
let path = if uri_path == "/" || uri_path.is_empty() {
|
||||
"index.html"
|
||||
} else {
|
||||
uri_path.strip_prefix('/').unwrap_or(uri_path)
|
||||
};
|
||||
|
||||
// 1. 优先检查热更新目录
|
||||
let update_file = hot_update_dir.join(path);
|
||||
if update_file.is_file() {
|
||||
if let Ok(data) = std::fs::read(&update_file) {
|
||||
return tauri::http::Response::builder()
|
||||
.header(
|
||||
tauri::http::header::CONTENT_TYPE,
|
||||
update::mime_from_path(path),
|
||||
)
|
||||
.body(data)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 回退到内嵌资源
|
||||
if let Some(asset) = ctx.app_handle().asset_resolver().get(path.to_string()) {
|
||||
let builder = tauri::http::Response::builder()
|
||||
.header(tauri::http::header::CONTENT_TYPE, &asset.mime_type);
|
||||
// Tauri 内嵌资源可能带 CSP header
|
||||
let builder = if let Some(csp) = asset.csp_header {
|
||||
builder.header("Content-Security-Policy", csp)
|
||||
} else {
|
||||
builder
|
||||
};
|
||||
builder.body(asset.bytes).unwrap()
|
||||
} else {
|
||||
tauri::http::Response::builder()
|
||||
.status(tauri::http::StatusCode::NOT_FOUND)
|
||||
.body(b"Not Found".to_vec())
|
||||
.unwrap()
|
||||
}
|
||||
})
|
||||
.setup(|app| {
|
||||
tray::setup_tray(app.handle())?;
|
||||
Ok(())
|
||||
@@ -34,7 +80,9 @@ pub fn run() {
|
||||
config::restart_gateway,
|
||||
config::test_model,
|
||||
config::list_remote_models,
|
||||
config::list_openclaw_versions,
|
||||
config::upgrade_openclaw,
|
||||
config::uninstall_openclaw,
|
||||
config::install_gateway,
|
||||
config::uninstall_gateway,
|
||||
config::patch_model_vision,
|
||||
@@ -91,6 +139,18 @@ pub fn run() {
|
||||
assistant::assistant_save_image,
|
||||
assistant::assistant_load_image,
|
||||
assistant::assistant_delete_image,
|
||||
// Skills 管理(openclaw skills CLI)
|
||||
skills::skills_list,
|
||||
skills::skills_info,
|
||||
skills::skills_check,
|
||||
skills::skills_install_dep,
|
||||
skills::skills_clawhub_search,
|
||||
skills::skills_clawhub_install,
|
||||
// 前端热更新
|
||||
update::check_frontend_update,
|
||||
update::download_frontend_update,
|
||||
update::rollback_frontend_update,
|
||||
update::get_update_status,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("启动 ClawPanel 失败");
|
||||
|
||||
@@ -141,12 +141,12 @@ export function showModal({ title, fields, onConfirm }) {
|
||||
* 升级进度弹窗 — 带进度条和实时日志
|
||||
* @returns {{ appendLog, setProgress, setDone, setError, destroy }}
|
||||
*/
|
||||
export function showUpgradeModal() {
|
||||
export function showUpgradeModal(title) {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = 'modal-overlay'
|
||||
overlay.innerHTML = `
|
||||
<div class="modal" style="max-width:520px">
|
||||
<div class="modal-title">升级 OpenClaw</div>
|
||||
<div class="modal-title">${title || '升级 OpenClaw'}</div>
|
||||
<div class="upgrade-progress-wrap">
|
||||
<div class="upgrade-progress-bar"><div class="upgrade-progress-fill" style="width:0%"></div></div>
|
||||
<div class="upgrade-progress-text">准备中...</div>
|
||||
@@ -165,9 +165,10 @@ export function showUpgradeModal() {
|
||||
const closeBtn = overlay.querySelector('[data-action="close"]')
|
||||
const _logLines = []
|
||||
|
||||
closeBtn.onclick = () => overlay.remove()
|
||||
let _onClose = null
|
||||
closeBtn.onclick = () => { overlay.remove(); _onClose?.() }
|
||||
overlay.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !closeBtn.disabled) overlay.remove()
|
||||
if (e.key === 'Escape' && !closeBtn.disabled) { overlay.remove(); _onClose?.() }
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -206,6 +207,7 @@ export function showUpgradeModal() {
|
||||
closeBtn.disabled = false
|
||||
closeBtn.focus()
|
||||
},
|
||||
destroy() { overlay.remove() },
|
||||
onClose(fn) { _onClose = fn },
|
||||
destroy() { overlay.remove(); _onClose?.() },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ const NAV_ITEMS_FULL = [
|
||||
section: '扩展',
|
||||
items: [
|
||||
{ route: '/extensions', label: '扩展工具', icon: 'extensions' },
|
||||
{ route: '/skills', label: 'Skills', icon: 'skills' },
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -59,6 +60,7 @@ const NAV_ITEMS_SETUP = [
|
||||
section: '扩展',
|
||||
items: [
|
||||
{ route: '/extensions', label: '扩展工具', icon: 'extensions' },
|
||||
{ route: '/skills', label: 'Skills', icon: 'skills' },
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -84,6 +86,7 @@ const ICONS = {
|
||||
about: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
|
||||
assistant: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/><path d="M18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"/></svg>',
|
||||
security: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>',
|
||||
skills: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/></svg>',
|
||||
debug: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/><circle cx="12" cy="12" r="3"/></svg>',
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ const PATHS = {
|
||||
'file-text': '<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
|
||||
'file-plain': '<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/>',
|
||||
'package': '<line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>',
|
||||
'box': '<line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>',
|
||||
'trash': '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>',
|
||||
'terminal': '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
|
||||
'edit': '<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>',
|
||||
'folder': '<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>',
|
||||
|
||||
@@ -277,7 +277,9 @@ export const api = {
|
||||
writeMcpConfig: (config) => { invalidate('read_mcp_config'); return invoke('write_mcp_config', { config }) },
|
||||
reloadGateway: () => invoke('reload_gateway'),
|
||||
restartGateway: () => invoke('restart_gateway'),
|
||||
upgradeOpenclaw: (source = 'chinese') => invoke('upgrade_openclaw', { source }),
|
||||
listOpenclawVersions: (source = 'chinese') => invoke('list_openclaw_versions', { source }),
|
||||
upgradeOpenclaw: (source = 'chinese', version = null) => invoke('upgrade_openclaw', { source, version }),
|
||||
uninstallOpenclaw: (cleanConfig = false) => invoke('uninstall_openclaw', { cleanConfig }),
|
||||
installGateway: () => invoke('install_gateway'),
|
||||
uninstallGateway: () => invoke('uninstall_gateway'),
|
||||
getNpmRegistry: () => cachedInvoke('get_npm_registry', {}, 30000),
|
||||
@@ -352,6 +354,20 @@ export const api = {
|
||||
assistantWebSearch: (query, maxResults) => invoke('assistant_web_search', { query, max_results: maxResults || 5 }),
|
||||
assistantFetchUrl: (url) => invoke('assistant_fetch_url', { url }),
|
||||
|
||||
// Skills 管理(openclaw skills CLI)
|
||||
skillsList: () => invoke('skills_list'),
|
||||
skillsInfo: (name) => invoke('skills_info', { name }),
|
||||
skillsCheck: () => invoke('skills_check'),
|
||||
skillsInstallDep: (kind, spec) => invoke('skills_install_dep', { kind, spec }),
|
||||
skillsClawHubSearch: (query) => invoke('skills_clawhub_search', { query }),
|
||||
skillsClawHubInstall: (slug) => invoke('skills_clawhub_install', { slug }),
|
||||
|
||||
// 前端热更新
|
||||
checkFrontendUpdate: () => invoke('check_frontend_update'),
|
||||
downloadFrontendUpdate: (url, expectedHash) => invoke('download_frontend_update', { url, expectedHash: expectedHash || '' }),
|
||||
rollbackFrontendUpdate: () => invoke('rollback_frontend_update'),
|
||||
getUpdateStatus: () => invoke('get_update_status'),
|
||||
|
||||
// 数据目录 & 图片存储
|
||||
ensureDataDir: () => invoke('assistant_ensure_data_dir'),
|
||||
saveImage: (id, data) => invoke('assistant_save_image', { id, data }),
|
||||
|
||||
@@ -164,6 +164,7 @@ async function boot() {
|
||||
registerRoute('/gateway', () => import('./pages/gateway.js'))
|
||||
registerRoute('/memory', () => import('./pages/memory.js'))
|
||||
registerRoute('/extensions', () => import('./pages/extensions.js'))
|
||||
registerRoute('/skills', () => import('./pages/skills.js'))
|
||||
registerRoute('/security', () => import('./pages/security.js'))
|
||||
registerRoute('/about', () => import('./pages/about.js'))
|
||||
registerRoute('/assistant', () => import('./pages/assistant.js'))
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { showUpgradeModal } from '../components/modal.js'
|
||||
import { showUpgradeModal, showConfirm } from '../components/modal.js'
|
||||
import { setUpgrading } from '../lib/app-state.js'
|
||||
import { icon, statusIcon } from '../lib/icons.js'
|
||||
|
||||
@@ -72,26 +72,13 @@ async function loadData(page) {
|
||||
// 非 Tauri 环境或 API 不可用,使用 fallback
|
||||
}
|
||||
|
||||
// 异步检查 ClawPanel 自身更新
|
||||
// 异步检查前端热更新
|
||||
let panelUpdateHtml = '<span style="color:var(--text-tertiary)">检查更新中...</span>'
|
||||
api.checkPanelUpdate().then(info => {
|
||||
const panelCard = cards.querySelector('#panel-update-meta')
|
||||
if (!panelCard) return
|
||||
if (info.latest && info.latest !== panelVersion && compareVersions(info.latest, panelVersion) > 0) {
|
||||
panelCard.innerHTML = `<span style="color:var(--accent)">新版本: ${info.latest}</span> <a class="btn btn-primary btn-sm" href="${info.url}" target="_blank" rel="noopener" style="padding:2px 8px;font-size:var(--font-size-xs)">下载更新</a>`
|
||||
} else {
|
||||
panelCard.innerHTML = '<span style="color:var(--success)">已是最新</span>'
|
||||
}
|
||||
}).catch((err) => {
|
||||
const panelCard = cards.querySelector('#panel-update-meta')
|
||||
if (!panelCard) return
|
||||
const msg = String(err?.message || err || '')
|
||||
if (msg.includes('403') || msg.includes('404') || msg.includes('rate limit')) {
|
||||
panelCard.innerHTML = '<span style="color:var(--text-tertiary)">仓库未公开,发布后可自动检测</span>'
|
||||
} else {
|
||||
panelCard.innerHTML = '<span style="color:var(--text-tertiary)">检查更新失败</span>'
|
||||
}
|
||||
})
|
||||
checkHotUpdate(cards, panelVersion)
|
||||
|
||||
const isInstalled = !!version.current
|
||||
const sourceLabel = version.source === 'official' ? '官方版' : '汉化版'
|
||||
const btnSm = 'padding:2px 8px;font-size:var(--font-size-xs)'
|
||||
|
||||
cards.innerHTML = `
|
||||
<div class="stat-card">
|
||||
@@ -100,12 +87,17 @@ async function loadData(page) {
|
||||
<div class="stat-card-meta" id="panel-update-meta" style="display:flex;align-items:center;gap:8px">${panelUpdateHtml}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header"><span class="stat-card-label">OpenClaw · ${version.source === 'official' ? '官方版' : '汉化版'}</span></div>
|
||||
<div class="stat-card-header"><span class="stat-card-label">OpenClaw · ${sourceLabel}</span></div>
|
||||
<div class="stat-card-value">${version.current || '未安装'}</div>
|
||||
<div class="stat-card-meta" style="display:flex;align-items:center;gap:8px">
|
||||
${version.update_available
|
||||
? `<span style="color:var(--accent)">新版本: ${version.latest}</span><button class="btn btn-primary btn-sm" id="btn-upgrade" style="padding:2px 8px;font-size:var(--font-size-xs)">升级</button>`
|
||||
: version.current ? '<span style="color:var(--success)">已是最新</span>' : '<span style="color:var(--error)">未检测到</span>'}
|
||||
<div class="stat-card-meta" style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||
${isInstalled ? (version.update_available
|
||||
? `<span style="color:var(--accent)">新版本: ${version.latest}</span>
|
||||
<button class="btn btn-primary btn-sm" id="btn-upgrade-latest" style="${btnSm}">升级到最新</button>`
|
||||
: '<span style="color:var(--success)">已是最新</span>') : ''}
|
||||
<button class="btn btn-${isInstalled ? 'secondary' : 'primary'} btn-sm" id="btn-version-mgmt" style="${btnSm}">
|
||||
${isInstalled ? '切换版本' : '安装 OpenClaw'}
|
||||
</button>
|
||||
${isInstalled ? `<button class="btn btn-secondary btn-sm" id="btn-uninstall" style="${btnSm};color:var(--error)">卸载</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
@@ -115,46 +107,41 @@ async function loadData(page) {
|
||||
</div>
|
||||
`
|
||||
|
||||
// 绑定升级按钮
|
||||
const upgradeBtn = cards.querySelector('#btn-upgrade')
|
||||
if (upgradeBtn) {
|
||||
upgradeBtn.onclick = async () => {
|
||||
const modal = showUpgradeModal()
|
||||
// 升级到最新
|
||||
const upgLatestBtn = cards.querySelector('#btn-upgrade-latest')
|
||||
if (upgLatestBtn) {
|
||||
upgLatestBtn.onclick = () => doInstall(page, '升级 OpenClaw', version.source, null)
|
||||
}
|
||||
|
||||
// 版本管理 / 安装
|
||||
const versionMgmtBtn = cards.querySelector('#btn-version-mgmt')
|
||||
if (versionMgmtBtn) {
|
||||
versionMgmtBtn.onclick = () => showVersionPicker(page, version)
|
||||
}
|
||||
|
||||
// 卸载
|
||||
const uninstallBtn = cards.querySelector('#btn-uninstall')
|
||||
if (uninstallBtn) {
|
||||
uninstallBtn.onclick = async () => {
|
||||
const confirmed = await showConfirm('确定要卸载 OpenClaw 吗?\n\n这将停止 Gateway 服务并卸载 npm 全局包。\n配置文件(~/.openclaw/)默认保留,可稍后手动删除。')
|
||||
if (!confirmed) return
|
||||
const modal = showUpgradeModal('卸载 OpenClaw')
|
||||
modal.onClose(() => loadData(page))
|
||||
modal.appendLog('开始卸载 OpenClaw...')
|
||||
let unlistenLog, unlistenProgress
|
||||
setUpgrading(true)
|
||||
try {
|
||||
if (window.__TAURI_INTERNALS__) {
|
||||
try {
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
|
||||
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
|
||||
} catch { /* Web 模式无 Tauri event */ }
|
||||
} else {
|
||||
modal.appendLog('Web 模式:升级过程日志不可用,请等待完成...')
|
||||
} catch {}
|
||||
}
|
||||
const msg = await api.upgradeOpenclaw()
|
||||
modal.setDone(typeof msg === 'string' ? msg : (msg?.message || '升级完成'))
|
||||
loadData(page)
|
||||
const msg = await api.uninstallOpenclaw(false)
|
||||
modal.setDone(typeof msg === 'string' ? msg : '卸载完成')
|
||||
} catch (e) {
|
||||
const errStr = String(e)
|
||||
modal.appendLog(errStr)
|
||||
const { diagnoseInstallError } = await import('../lib/error-diagnosis.js')
|
||||
const fullLog = modal.getLogText() + '\n' + errStr
|
||||
const diagnosis = diagnoseInstallError(fullLog)
|
||||
modal.setError(diagnosis.title)
|
||||
if (diagnosis.hint) modal.appendLog('')
|
||||
if (diagnosis.hint) modal.appendHtmlLog(`${statusIcon('info', 14)} ${diagnosis.hint}`)
|
||||
if (diagnosis.command) modal.appendHtmlLog(`${icon('clipboard', 14)} ${diagnosis.command}`)
|
||||
if (window.__openAIDrawerWithError) {
|
||||
window.__openAIDrawerWithError({
|
||||
title: diagnosis.title,
|
||||
error: fullLog,
|
||||
scene: '升级 OpenClaw',
|
||||
hint: diagnosis.hint,
|
||||
})
|
||||
}
|
||||
modal.setError('卸载失败: ' + (e?.message || e))
|
||||
} finally {
|
||||
setUpgrading(false)
|
||||
unlistenLog?.()
|
||||
unlistenProgress?.()
|
||||
}
|
||||
@@ -165,6 +152,272 @@ async function loadData(page) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 版本选择器弹窗 — 选择版本(汉化版/原版)+ 版本号
|
||||
*/
|
||||
async function showVersionPicker(page, currentVersion) {
|
||||
const isInstalled = !!currentVersion.current
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = 'modal-overlay'
|
||||
overlay.innerHTML = `
|
||||
<div class="modal" style="max-width:460px">
|
||||
<div class="modal-title">${isInstalled ? '切换版本' : '安装 OpenClaw'}</div>
|
||||
<div style="display:flex;flex-direction:column;gap:16px;margin:16px 0">
|
||||
<div>
|
||||
<label style="font-size:var(--font-size-sm);color:var(--text-secondary);display:block;margin-bottom:8px">版本</label>
|
||||
<div style="display:flex;gap:8px">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;padding:6px 12px;border-radius:8px;border:1px solid var(--border);font-size:var(--font-size-sm);flex:1;justify-content:center;transition:all .15s" id="lbl-chinese">
|
||||
<input type="radio" name="oc-source" value="chinese" ${currentVersion.source !== 'official' ? 'checked' : ''} style="accent-color:var(--primary)">
|
||||
汉化版
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;padding:6px 12px;border-radius:8px;border:1px solid var(--border);font-size:var(--font-size-sm);flex:1;justify-content:center;transition:all .15s" id="lbl-official">
|
||||
<input type="radio" name="oc-source" value="official" ${currentVersion.source === 'official' ? 'checked' : ''} style="accent-color:var(--primary)">
|
||||
原版
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:var(--font-size-sm);color:var(--text-secondary);display:block;margin-bottom:8px">选择版本号</label>
|
||||
<select id="oc-version-select" class="input" style="width:100%;padding:8px 12px;font-size:var(--font-size-sm)">
|
||||
<option value="">加载中...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="oc-action-hint" style="font-size:var(--font-size-xs);color:var(--text-tertiary);min-height:18px"></div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-action="cancel">取消</button>
|
||||
<button class="btn btn-primary btn-sm" data-action="confirm" disabled id="oc-confirm-btn">${isInstalled ? '切换' : '安装'}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
const select = overlay.querySelector('#oc-version-select')
|
||||
const confirmBtn = overlay.querySelector('#oc-confirm-btn')
|
||||
const hintEl = overlay.querySelector('#oc-action-hint')
|
||||
const radios = overlay.querySelectorAll('input[name="oc-source"]')
|
||||
const lblChinese = overlay.querySelector('#lbl-chinese')
|
||||
const lblOfficial = overlay.querySelector('#lbl-official')
|
||||
|
||||
const close = () => overlay.remove()
|
||||
overlay.querySelector('[data-action="cancel"]').onclick = close
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close() })
|
||||
overlay.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
|
||||
|
||||
let versionsCache = {}
|
||||
let currentSelect = currentVersion.source === 'official' ? 'official' : 'chinese'
|
||||
|
||||
function updateRadioStyle() {
|
||||
const sel = currentSelect
|
||||
lblChinese.style.borderColor = sel !== 'official' ? 'var(--primary)' : 'var(--border)'
|
||||
lblChinese.style.background = sel !== 'official' ? 'var(--primary-bg, rgba(99,102,241,0.06))' : ''
|
||||
lblOfficial.style.borderColor = sel === 'official' ? 'var(--primary)' : 'var(--border)'
|
||||
lblOfficial.style.background = sel === 'official' ? 'var(--primary-bg, rgba(99,102,241,0.06))' : ''
|
||||
}
|
||||
|
||||
function updateHint() {
|
||||
const targetSource = currentSelect
|
||||
const targetVer = select.value
|
||||
if (!targetVer || targetVer === '') { hintEl.textContent = ''; confirmBtn.disabled = true; return }
|
||||
|
||||
const sameSource = targetSource === (currentVersion.source === 'official' ? 'official' : 'chinese')
|
||||
|
||||
if (!isInstalled) {
|
||||
confirmBtn.textContent = '安装'
|
||||
hintEl.textContent = `将安装 ${targetSource === 'official' ? '原版' : '汉化版'} ${targetVer}`
|
||||
confirmBtn.disabled = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!sameSource) {
|
||||
confirmBtn.textContent = '切换'
|
||||
hintEl.innerHTML = `当前: <strong>${currentVersion.source === 'official' ? '原版' : '汉化版'} ${currentVersion.current}</strong> → <strong>${targetSource === 'official' ? '原版' : '汉化版'} ${targetVer}</strong>`
|
||||
confirmBtn.disabled = false
|
||||
return
|
||||
}
|
||||
|
||||
// 同源,比较版本
|
||||
const parseVer = v => v.split(/[^0-9]/).filter(Boolean).map(Number)
|
||||
const cur = parseVer(currentVersion.current)
|
||||
const tgt = parseVer(targetVer)
|
||||
let cmp = 0
|
||||
for (let i = 0; i < Math.max(cur.length, tgt.length); i++) {
|
||||
if ((tgt[i] || 0) > (cur[i] || 0)) { cmp = 1; break }
|
||||
if ((tgt[i] || 0) < (cur[i] || 0)) { cmp = -1; break }
|
||||
}
|
||||
|
||||
if (cmp === 0) {
|
||||
confirmBtn.textContent = '重新安装'
|
||||
hintEl.textContent = `当前已是 ${targetVer}`
|
||||
confirmBtn.disabled = false
|
||||
} else if (cmp > 0) {
|
||||
confirmBtn.textContent = '升级'
|
||||
hintEl.innerHTML = `<span style="color:var(--accent)">${currentVersion.current} → ${targetVer}</span>`
|
||||
confirmBtn.disabled = false
|
||||
} else {
|
||||
confirmBtn.textContent = '降级'
|
||||
hintEl.innerHTML = `<span style="color:var(--warning,#f59e0b)">${currentVersion.current} → ${targetVer}</span>`
|
||||
confirmBtn.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVersions(source) {
|
||||
select.innerHTML = '<option value="">加载中...</option>'
|
||||
confirmBtn.disabled = true
|
||||
hintEl.textContent = ''
|
||||
try {
|
||||
if (!versionsCache[source]) {
|
||||
versionsCache[source] = await api.listOpenclawVersions(source)
|
||||
}
|
||||
const versions = versionsCache[source]
|
||||
if (!versions.length) {
|
||||
select.innerHTML = '<option value="">未找到可用版本</option>'
|
||||
return
|
||||
}
|
||||
select.innerHTML = versions.map(v => {
|
||||
const isCurrent = isInstalled && v === currentVersion.current && source === (currentVersion.source === 'official' ? 'official' : 'chinese')
|
||||
return `<option value="${v}">${v}${isCurrent ? ' (当前)' : ''}</option>`
|
||||
}).join('')
|
||||
updateHint()
|
||||
} catch (e) {
|
||||
select.innerHTML = `<option value="">加载失败: ${e.message || e}</option>`
|
||||
}
|
||||
}
|
||||
|
||||
radios.forEach(radio => {
|
||||
radio.addEventListener('change', () => {
|
||||
currentSelect = radio.value
|
||||
updateRadioStyle()
|
||||
loadVersions(currentSelect)
|
||||
})
|
||||
})
|
||||
|
||||
select.addEventListener('change', updateHint)
|
||||
|
||||
confirmBtn.onclick = () => {
|
||||
const source = currentSelect
|
||||
const ver = select.value
|
||||
const action = confirmBtn.textContent
|
||||
close()
|
||||
doInstall(page, `${action} OpenClaw`, source, ver)
|
||||
}
|
||||
|
||||
updateRadioStyle()
|
||||
loadVersions(currentSelect)
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行安装/升级/降级/切换操作(带进度弹窗)
|
||||
*/
|
||||
async function doInstall(page, title, source, version) {
|
||||
const modal = showUpgradeModal(title)
|
||||
modal.onClose(() => loadData(page))
|
||||
let unlistenLog, unlistenProgress
|
||||
setUpgrading(true)
|
||||
try {
|
||||
if (window.__TAURI_INTERNALS__) {
|
||||
try {
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
|
||||
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
|
||||
} catch {}
|
||||
} else {
|
||||
modal.appendLog('Web 模式:安装过程日志不可用,请等待完成...')
|
||||
}
|
||||
const msg = await api.upgradeOpenclaw(source, version)
|
||||
modal.setDone(typeof msg === 'string' ? msg : (msg?.message || '操作完成'))
|
||||
} catch (e) {
|
||||
const errStr = String(e)
|
||||
modal.appendLog(errStr)
|
||||
const { diagnoseInstallError } = await import('../lib/error-diagnosis.js')
|
||||
const fullLog = modal.getLogText() + '\n' + errStr
|
||||
const diagnosis = diagnoseInstallError(fullLog)
|
||||
modal.setError(diagnosis.title)
|
||||
if (diagnosis.hint) modal.appendLog('')
|
||||
if (diagnosis.hint) modal.appendHtmlLog(`${statusIcon('info', 14)} ${diagnosis.hint}`)
|
||||
if (diagnosis.command) modal.appendHtmlLog(`${icon('clipboard', 14)} ${diagnosis.command}`)
|
||||
if (window.__openAIDrawerWithError) {
|
||||
window.__openAIDrawerWithError({
|
||||
title: diagnosis.title,
|
||||
error: fullLog,
|
||||
scene: title,
|
||||
hint: diagnosis.hint,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setUpgrading(false)
|
||||
unlistenLog?.()
|
||||
unlistenProgress?.()
|
||||
}
|
||||
}
|
||||
|
||||
async function checkHotUpdate(cards, panelVersion) {
|
||||
const el = () => cards.querySelector('#panel-update-meta')
|
||||
try {
|
||||
const info = await api.checkFrontendUpdate()
|
||||
const meta = el()
|
||||
if (!meta) return
|
||||
|
||||
if (info.updateReady) {
|
||||
// 已下载更新,等待重载
|
||||
const ver = info.manifest?.version || info.latestVersion || ''
|
||||
meta.innerHTML = `
|
||||
<span style="color:var(--accent)">v${ver} 已就绪</span>
|
||||
<button class="btn btn-primary btn-sm" id="btn-hot-reload" style="padding:2px 8px;font-size:var(--font-size-xs)">重载应用</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-hot-rollback" style="padding:2px 8px;font-size:var(--font-size-xs)">回退</button>
|
||||
`
|
||||
meta.querySelector('#btn-hot-reload')?.addEventListener('click', () => {
|
||||
window.location.reload()
|
||||
})
|
||||
meta.querySelector('#btn-hot-rollback')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await api.rollbackFrontendUpdate()
|
||||
toast('已回退到内嵌版本,重载中...', 'success')
|
||||
setTimeout(() => window.location.reload(), 800)
|
||||
} catch (e) {
|
||||
toast('回退失败: ' + (e.message || e), 'error')
|
||||
}
|
||||
})
|
||||
} else if (info.hasUpdate) {
|
||||
// 有新版本可下载
|
||||
const ver = info.latestVersion
|
||||
const manifest = info.manifest || {}
|
||||
const changelog = manifest.changelog || ''
|
||||
meta.innerHTML = `
|
||||
<span style="color:var(--accent)">新版本: v${ver}</span>
|
||||
${changelog ? `<span style="color:var(--text-tertiary);font-size:var(--font-size-xs)">${changelog}</span>` : ''}
|
||||
<button class="btn btn-primary btn-sm" id="btn-hot-download" style="padding:2px 8px;font-size:var(--font-size-xs)">热更新</button>
|
||||
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawpanel/releases" target="_blank" rel="noopener" style="padding:2px 8px;font-size:var(--font-size-xs)">完整安装包</a>
|
||||
`
|
||||
meta.querySelector('#btn-hot-download')?.addEventListener('click', async () => {
|
||||
const btn = meta.querySelector('#btn-hot-download')
|
||||
if (btn) { btn.disabled = true; btn.textContent = '下载中...' }
|
||||
try {
|
||||
await api.downloadFrontendUpdate(manifest.url, manifest.hash || '')
|
||||
toast('更新下载完成,点击「重载应用」生效', 'success')
|
||||
checkHotUpdate(cards, panelVersion)
|
||||
} catch (e) {
|
||||
toast('下载失败: ' + (e.message || e), 'error')
|
||||
if (btn) { btn.disabled = false; btn.textContent = '重试' }
|
||||
}
|
||||
})
|
||||
} else if (!info.compatible) {
|
||||
meta.innerHTML = '<span style="color:var(--text-tertiary)">需要更新完整安装包</span> <a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawpanel/releases" target="_blank" rel="noopener" style="padding:2px 8px;font-size:var(--font-size-xs)">下载</a>'
|
||||
} else {
|
||||
meta.innerHTML = '<span style="color:var(--success)">已是最新</span>'
|
||||
}
|
||||
} catch (err) {
|
||||
const meta = el()
|
||||
if (!meta) return
|
||||
const msg = String(err?.message || err || '')
|
||||
if (msg.includes('403') || msg.includes('404') || msg.includes('rate limit')) {
|
||||
meta.innerHTML = '<span style="color:var(--text-tertiary)">暂无法检查更新</span>'
|
||||
} else {
|
||||
meta.innerHTML = '<span style="color:var(--text-tertiary)">检查更新失败</span>'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function compareVersions(a, b) {
|
||||
const pa = a.split('.').map(Number)
|
||||
const pb = b.split('.').map(Number)
|
||||
|
||||
@@ -134,6 +134,14 @@ ${personality}
|
||||
- openclaw gateway install — 安装 Gateway 为系统服务
|
||||
- openclaw gateway uninstall — 卸载 Gateway 系统服务
|
||||
|
||||
### Skills 管理
|
||||
- openclaw skills list — 列出所有 Skills 及其状态
|
||||
- openclaw skills info <name> — 查看某个 Skill 详情
|
||||
- openclaw skills check — 检查所有 Skills 的依赖是否满足
|
||||
- Skill 依赖安装: 根据 install spec 执行 brew/npm/go/uv 安装缺少的命令行工具
|
||||
- ClawHub (clawhub.com): 社区 Skill 市场,可搜索和安装新 Skill
|
||||
- Skills 目录: 捆绑 Skills 在 openclaw 安装包内,自定义 Skills 放在 ~/.openclaw/skills/<name>/
|
||||
|
||||
### 聊天与调试
|
||||
- openclaw chat — 进入交互式聊天
|
||||
- openclaw chat -m "消息" — 发送单条消息
|
||||
@@ -362,6 +370,89 @@ const TOOL_DEFS = {
|
||||
},
|
||||
},
|
||||
],
|
||||
skills: [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'skills_list',
|
||||
description: '列出所有 OpenClaw Skills 及其状态(可用/缺依赖/已禁用)。返回每个 Skill 的名称、描述、来源、依赖状态、缺少的依赖项、可用的安装选项等信息。',
|
||||
parameters: { type: 'object', properties: {}, required: [] },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'skills_info',
|
||||
description: '查看指定 Skill 的详细信息,包括描述、来源、依赖要求、缺少的依赖、安装选项等。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Skill 名称,如 github、weather、coding-agent' },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'skills_check',
|
||||
description: '检查所有 Skills 的依赖状态,返回哪些可用、哪些缺少依赖、哪些已禁用的汇总信息。',
|
||||
parameters: { type: 'object', properties: {}, required: [] },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'skills_install_dep',
|
||||
description: '安装 Skill 缺少的依赖。根据 Skill 的 install spec 执行对应的包管理器命令(brew/npm/go/uv)。安装完成后会自动生效。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
kind: { type: 'string', enum: ['brew', 'node', 'go', 'uv'], description: '安装类型' },
|
||||
spec: {
|
||||
type: 'object',
|
||||
description: '安装参数。brew 需要 formula,node 需要 package,go 需要 module,uv 需要 package。',
|
||||
properties: {
|
||||
formula: { type: 'string', description: 'Homebrew formula 名称' },
|
||||
package: { type: 'string', description: 'npm 或 uv 包名' },
|
||||
module: { type: 'string', description: 'Go module 路径' },
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['kind', 'spec'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'skills_clawhub_search',
|
||||
description: '在 ClawHub 社区市场中搜索 Skills。返回匹配的 Skill 列表(slug 和描述)。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: '搜索关键词' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'skills_clawhub_install',
|
||||
description: '从 ClawHub 社区市场安装一个 Skill 到本地 ~/.openclaw/skills/ 目录。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slug: { type: 'string', description: 'ClawHub 上的 Skill slug(名称标识)' },
|
||||
},
|
||||
required: ['slug'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
fileOps: [
|
||||
{
|
||||
type: 'function',
|
||||
@@ -411,7 +502,7 @@ const TOOL_DEFS = {
|
||||
|
||||
// 危险工具(需要用户确认)
|
||||
const INTERACTIVE_TOOLS = new Set(['ask_user']) // 交互式工具,不走 confirmToolCall
|
||||
const DANGEROUS_TOOLS = new Set(['run_command', 'write_file'])
|
||||
const DANGEROUS_TOOLS = new Set(['run_command', 'write_file', 'skills_install_dep', 'skills_clawhub_install'])
|
||||
|
||||
// 安全围栏:极端危险命令模式(任何模式都必须确认,包括无限模式)
|
||||
const CRITICAL_PATTERNS = [
|
||||
@@ -596,6 +687,27 @@ const BUILTIN_SKILLS = [
|
||||
- 创建 PR 的链接
|
||||
7. 如果用户不熟悉 Git,给出每一步的详细命令`,
|
||||
},
|
||||
{
|
||||
id: 'skills-manager',
|
||||
icon: icon('box', 16),
|
||||
name: 'Skills 管理',
|
||||
desc: '查看、检查依赖、安装 Skills',
|
||||
tools: ['skills'],
|
||||
prompt: `请帮我管理 OpenClaw 的 Skills。
|
||||
|
||||
具体操作:
|
||||
1. 调用 skills_list 获取所有 Skills 及其状态
|
||||
2. 汇总展示:多少个可用、多少个缺依赖、多少个已禁用
|
||||
3. 对于缺依赖的 Skills,列出每个缺少的依赖和对应的安装方法
|
||||
4. 询问用户是否要安装某些缺少的依赖(用 ask_user 列出选项)
|
||||
5. 如果用户选择安装,调用 skills_install_dep 执行安装
|
||||
6. 安装完成后再次调用 skills_list 确认状态变化
|
||||
|
||||
注意:
|
||||
- 安装依赖可能需要特定的包管理器(brew 仅限 macOS,Windows 用 npm/go 等)
|
||||
- 先调用 get_system_info 判断操作系统,过滤出适合当前平台的安装选项
|
||||
- 如果用户想从 ClawHub 搜索安装新 Skill,使用 skills_clawhub_search 和 skills_clawhub_install`,
|
||||
},
|
||||
]
|
||||
|
||||
function currentMode() {
|
||||
@@ -624,6 +736,13 @@ function getEnabledTools() {
|
||||
}
|
||||
}
|
||||
|
||||
// Skills 管理工具:始终启用(规划模式下排除安装操作)
|
||||
if (mode.readOnly) {
|
||||
tools.push(...TOOL_DEFS.skills.filter(td => !['skills_install_dep', 'skills_clawhub_install'].includes(td.function.name)))
|
||||
} else {
|
||||
tools.push(...TOOL_DEFS.skills)
|
||||
}
|
||||
|
||||
return tools
|
||||
}
|
||||
|
||||
@@ -1724,6 +1843,40 @@ async function executeTool(name, args) {
|
||||
return await api.assistantWebSearch(args.query, args.max_results)
|
||||
case 'fetch_url':
|
||||
return await api.assistantFetchUrl(args.url)
|
||||
case 'skills_list': {
|
||||
const data = await api.skillsList()
|
||||
const skills = data?.skills || []
|
||||
const eligible = skills.filter(s => s.eligible && !s.disabled)
|
||||
const missing = skills.filter(s => !s.eligible && !s.disabled)
|
||||
const disabled = skills.filter(s => s.disabled)
|
||||
let summary = `共 ${skills.length} 个 Skills: ${eligible.length} 可用, ${missing.length} 缺依赖, ${disabled.length} 已禁用\n\n`
|
||||
if (eligible.length) summary += `## 可用 (${eligible.length})\n` + eligible.map(s => `- ${s.emoji || '📦'} **${s.name}**: ${s.description || ''}${s.bundled ? ' [捆绑]' : ''}`).join('\n') + '\n\n'
|
||||
if (missing.length) summary += `## 缺依赖 (${missing.length})\n` + missing.map(s => {
|
||||
const m = s.missing || {}
|
||||
const deps = [...(m.bins||[]), ...(m.env||[]).map(e=>'$'+e), ...(m.config||[])].join(', ')
|
||||
const installs = (s.install||[]).map(i => i.label).join(' / ')
|
||||
return `- ${s.emoji || '📦'} **${s.name}**: 缺少 ${deps}${installs ? ' → 可通过: ' + installs : ''}`
|
||||
}).join('\n') + '\n\n'
|
||||
if (disabled.length) summary += `## 已禁用 (${disabled.length})\n` + disabled.map(s => `- ${s.emoji || '📦'} **${s.name}**: ${s.description || ''}`).join('\n') + '\n'
|
||||
return summary
|
||||
}
|
||||
case 'skills_info':
|
||||
return JSON.stringify(await api.skillsInfo(args.name), null, 2)
|
||||
case 'skills_check':
|
||||
return JSON.stringify(await api.skillsCheck(), null, 2)
|
||||
case 'skills_install_dep': {
|
||||
const result = await api.skillsInstallDep(args.kind, args.spec)
|
||||
return result?.success ? `安装成功\n${result.output || ''}` : '安装失败'
|
||||
}
|
||||
case 'skills_clawhub_search': {
|
||||
const items = await api.skillsClawHubSearch(args.query)
|
||||
if (!items?.length) return '未找到匹配的 Skill'
|
||||
return items.map(i => `- **${i.slug}**: ${i.description || '无描述'}`).join('\n')
|
||||
}
|
||||
case 'skills_clawhub_install': {
|
||||
const result = await api.skillsClawHubInstall(args.slug)
|
||||
return result?.success ? `Skill "${args.slug}" 安装成功\n${result.output || ''}` : '安装失败'
|
||||
}
|
||||
default:
|
||||
return `未知工具: ${name}`
|
||||
}
|
||||
@@ -2104,8 +2257,8 @@ function renderToolBlocks(toolHistory) {
|
||||
// ask_user 工具不显示在工具块中(它有自己的交互卡片)
|
||||
if (tc.name === 'ask_user') return ''
|
||||
|
||||
const tcIcon = { run_command: icon('terminal', 14), write_file: icon('edit', 14), read_file: icon('file', 14), list_directory: icon('folder', 14), get_system_info: icon('monitor', 14), list_processes: icon('list', 14), check_port: icon('plug', 14) }[tc.name] || icon('wrench', 14)
|
||||
const label = { run_command: '执行命令', read_file: '读取文件', write_file: '写入文件', list_directory: '列出目录', get_system_info: '系统信息', list_processes: '进程列表', check_port: '端口检测' }[tc.name] || tc.name
|
||||
const tcIcon = { run_command: icon('terminal', 14), write_file: icon('edit', 14), read_file: icon('file', 14), list_directory: icon('folder', 14), get_system_info: icon('monitor', 14), list_processes: icon('list', 14), check_port: icon('plug', 14), skills_list: icon('box', 14), skills_info: icon('box', 14), skills_check: icon('box', 14), skills_install_dep: icon('download', 14), skills_clawhub_search: icon('search', 14), skills_clawhub_install: icon('download', 14) }[tc.name] || icon('wrench', 14)
|
||||
const label = { run_command: '执行命令', read_file: '读取文件', write_file: '写入文件', list_directory: '列出目录', get_system_info: '系统信息', list_processes: '进程列表', check_port: '端口检测', skills_list: 'Skills 列表', skills_info: 'Skill 详情', skills_check: 'Skills 检查', skills_install_dep: '安装依赖', skills_clawhub_search: '搜索 ClawHub', skills_clawhub_install: '安装 Skill' }[tc.name] || tc.name
|
||||
const argsStr = tc.name === 'run_command' ? escHtml(tc.args.command || '')
|
||||
: tc.name === 'read_file' ? escHtml(tc.args.path || '')
|
||||
: tc.name === 'write_file' ? escHtml(tc.args.path || '')
|
||||
@@ -2113,11 +2266,16 @@ function renderToolBlocks(toolHistory) {
|
||||
: tc.name === 'get_system_info' ? ''
|
||||
: tc.name === 'list_processes' ? escHtml(tc.args.filter || '全部')
|
||||
: tc.name === 'check_port' ? escHtml(String(tc.args.port || ''))
|
||||
: tc.name === 'skills_info' ? escHtml(tc.args.name || '')
|
||||
: tc.name === 'skills_install_dep' ? escHtml(`${tc.args.kind}: ${tc.args.spec?.formula || tc.args.spec?.package || tc.args.spec?.module || ''}`)
|
||||
: tc.name === 'skills_clawhub_search' ? escHtml(tc.args.query || '')
|
||||
: tc.name === 'skills_clawhub_install' ? escHtml(tc.args.slug || '')
|
||||
: ['skills_list', 'skills_check'].includes(tc.name) ? ''
|
||||
: escHtml(JSON.stringify(tc.args))
|
||||
|
||||
if (tc.pending) {
|
||||
return `<div class="ast-tool-block pending">
|
||||
<div class="ast-tool-summary">${icon} <strong>${label}</strong> <code>${argsStr}</code> <span class="ast-tool-status"><span class="ast-typing">执行中...</span></span></div>
|
||||
<div class="ast-tool-summary">${tcIcon} <strong>${label}</strong> <code>${argsStr}</code> <span class="ast-tool-status"><span class="ast-typing">执行中...</span></span></div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
@@ -2125,7 +2283,7 @@ function renderToolBlocks(toolHistory) {
|
||||
const statusLabel = tc.approved === false ? '已拒绝' : '已执行'
|
||||
const resultPreview = (tc.result || '').length > 500 ? tc.result.slice(0, 500) + '...' : (tc.result || '')
|
||||
return `<details class="ast-tool-block ${statusClass}">
|
||||
<summary class="ast-tool-summary">${icon} <strong>${label}</strong> <code>${argsStr}</code> <span class="ast-tool-status">${statusLabel}</span></summary>
|
||||
<summary class="ast-tool-summary">${tcIcon} <strong>${label}</strong> <code>${argsStr}</code> <span class="ast-tool-status">${statusLabel}</span></summary>
|
||||
<pre class="ast-tool-result">${escHtml(resultPreview)}</pre>
|
||||
</details>`
|
||||
}).join('')
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
/**
|
||||
* Skills 页面
|
||||
* 默认展示 ClawHub 热门推荐 + 已安装 + 搜索结果 + 详情 + 安装
|
||||
* 基于 openclaw skills CLI,按状态分组展示所有 Skills
|
||||
*/
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
|
||||
const SKILLS_LOAD_TIMEOUT_MS = 10000
|
||||
const SKILLS_AUTO_RETRY_DELAY_MS = 1200
|
||||
const SKILLS_MAX_AUTO_RETRY = 1
|
||||
let skillsLoadSeq = 0
|
||||
let _loadSeq = 0
|
||||
|
||||
function escapeHtml(str) {
|
||||
function esc(str) {
|
||||
if (!str) return ''
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
export async function render() {
|
||||
@@ -25,275 +18,329 @@ export async function render() {
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Skills</h1>
|
||||
<p class="page-desc">从 ClawHub 浏览热门推荐、搜索 Skill、查看详情并一键安装</p>
|
||||
<p class="page-desc">查看 OpenClaw 可用的 Skills 及其依赖状态</p>
|
||||
</div>
|
||||
<div id="skills-content" class="config-section">
|
||||
<div class="stat-card loading-placeholder" style="height:96px"></div>
|
||||
</div>
|
||||
`
|
||||
|
||||
bindEvents(page)
|
||||
loadSkills(page)
|
||||
return page
|
||||
}
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function withTimeout(promise, ms, label = '请求') {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error(`${label}超时(>${Math.round(ms / 1000)}s)`)), ms)
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
function setLoadingHint(page, text = '正在加载 Skills...') {
|
||||
async function loadSkills(page) {
|
||||
const el = page.querySelector('#skills-content')
|
||||
if (!el) return
|
||||
el.innerHTML = `
|
||||
<div class="skills-loading-panel">
|
||||
<div class="stat-card loading-placeholder" style="height:96px"></div>
|
||||
<div class="form-hint" style="margin-top:8px">${escapeHtml(text)}</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
const seq = ++_loadSeq
|
||||
|
||||
function renderLoadError(el, message, canAutoRetry) {
|
||||
el.innerHTML = `
|
||||
<div class="skills-load-error">
|
||||
<div style="color:var(--error);margin-bottom:8px">加载失败:${escapeHtml(message)}</div>
|
||||
<div class="form-hint" style="margin-bottom:10px">${canAutoRetry ? '正在自动重试...' : '你可以手动重试'}</div>
|
||||
<div class="clawhub-toolbar" style="margin-bottom:0">
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-retry">立即重试</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
async function loadSkills(page, query = '', options = {}) {
|
||||
const el = page.querySelector('#skills-content')
|
||||
if (!el) return
|
||||
|
||||
const silent = !!options.silent
|
||||
const retryCount = options.retryCount || 0
|
||||
const requestId = ++skillsLoadSeq
|
||||
|
||||
if (!silent) {
|
||||
setLoadingHint(page, retryCount > 0 ? `正在重试加载(第 ${retryCount + 1} 次)...` : '正在加载 Skills...')
|
||||
}
|
||||
el.innerHTML = `<div class="skills-loading-panel">
|
||||
<div class="stat-card loading-placeholder" style="height:96px"></div>
|
||||
<div class="form-hint" style="margin-top:8px">正在加载 Skills...</div>
|
||||
</div>`
|
||||
|
||||
try {
|
||||
const [installed, trending, results] = await withTimeout(
|
||||
Promise.all([
|
||||
api.clawhubListInstalled(),
|
||||
api.clawhubTrending(),
|
||||
query ? api.clawhubSearch(query) : Promise.resolve([]),
|
||||
]),
|
||||
SKILLS_LOAD_TIMEOUT_MS,
|
||||
'Skills 数据加载'
|
||||
)
|
||||
|
||||
if (requestId !== skillsLoadSeq) return
|
||||
renderSkills(el, { installed, trending, results, query })
|
||||
const data = await api.skillsList()
|
||||
if (seq !== _loadSeq) return
|
||||
renderSkills(el, data)
|
||||
} catch (e) {
|
||||
if (requestId !== skillsLoadSeq) return
|
||||
|
||||
const message = (e?.message || String(e || '')).trim() || '未知错误'
|
||||
const canAutoRetry = retryCount < SKILLS_MAX_AUTO_RETRY
|
||||
|
||||
renderLoadError(el, message, canAutoRetry)
|
||||
|
||||
if (canAutoRetry) {
|
||||
await wait(SKILLS_AUTO_RETRY_DELAY_MS)
|
||||
if (requestId !== skillsLoadSeq) return
|
||||
await loadSkills(page, query, { silent: false, retryCount: retryCount + 1 })
|
||||
}
|
||||
if (seq !== _loadSeq) return
|
||||
el.innerHTML = `<div class="skills-load-error">
|
||||
<div style="color:var(--error);margin-bottom:8px">加载失败: ${esc(e?.message || e)}</div>
|
||||
<div class="form-hint" style="margin-bottom:10px">请确认 OpenClaw 已安装并可用</div>
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-retry">重试</button>
|
||||
</div>`
|
||||
}
|
||||
}
|
||||
|
||||
function renderSkillItems(items, installedSet) {
|
||||
if (!items.length) return '<div class="clawhub-empty">暂无内容</div>'
|
||||
return items.map(item => `
|
||||
<div class="clawhub-item">
|
||||
<div class="clawhub-item-main">
|
||||
<div class="clawhub-item-title">${escapeHtml(item.displayName || item.slug)}</div>
|
||||
<div class="clawhub-item-meta">${escapeHtml(item.slug)}${item.author ? ` · @${escapeHtml(item.author)}` : ''}${item.downloadsText ? ` · ${escapeHtml(item.downloadsText)}` : ''}</div>
|
||||
<div class="clawhub-item-desc">${escapeHtml(item.summary || '暂无摘要,可点击查看详情')}</div>
|
||||
</div>
|
||||
<div class="clawhub-item-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-inspect" data-slug="${escapeHtml(item.slug)}">详情</button>
|
||||
${installedSet.has(item.slug)
|
||||
? '<span class="clawhub-badge installed">已安装</span>'
|
||||
: `<button class="btn btn-primary btn-sm" data-action="skill-install" data-slug="${escapeHtml(item.slug)}">安装</button>`}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')
|
||||
}
|
||||
function renderSkills(el, data) {
|
||||
const skills = data?.skills || []
|
||||
const cliAvailable = data?.cliAvailable !== false
|
||||
const eligible = skills.filter(s => s.eligible && !s.disabled)
|
||||
const missing = skills.filter(s => !s.eligible && !s.disabled && !s.blockedByAllowlist)
|
||||
const disabled = skills.filter(s => s.disabled)
|
||||
const blocked = skills.filter(s => s.blockedByAllowlist && !s.disabled)
|
||||
|
||||
function renderTrendingCards(items, installedSet) {
|
||||
if (!items.length) return '<div class="clawhub-empty">暂无推荐内容</div>'
|
||||
return `
|
||||
<div class="skills-hero-grid">
|
||||
${items.map(item => `
|
||||
<div class="skill-hero-card">
|
||||
<div class="skill-hero-top">
|
||||
<div>
|
||||
<div class="skill-hero-title">${escapeHtml(item.displayName || item.slug)}</div>
|
||||
<div class="skill-hero-meta">${escapeHtml(item.slug)}${item.author ? ` · @${escapeHtml(item.author)}` : ''}</div>
|
||||
</div>
|
||||
<div class="skill-hero-badges">
|
||||
${item.downloadsText ? `<span class="clawhub-badge hot">${escapeHtml(item.downloadsText)}</span>` : ''}
|
||||
${installedSet.has(item.slug) ? '<span class="clawhub-badge installed">已安装</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="skill-hero-desc">${escapeHtml(item.summary || '暂无摘要')}</div>
|
||||
<div class="skill-hero-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-inspect" data-slug="${escapeHtml(item.slug)}">查看详情</button>
|
||||
${installedSet.has(item.slug)
|
||||
? '<span class="skill-hero-installed">已在本地可用</span>'
|
||||
: `<button class="btn btn-primary btn-sm" data-action="skill-install" data-slug="${escapeHtml(item.slug)}">一键安装</button>`}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderSkills(el, state) {
|
||||
const installed = state.installed || []
|
||||
const trending = state.trending || []
|
||||
const results = state.results || []
|
||||
const installedSet = new Set(installed.map(x => x.slug))
|
||||
const summary = `${eligible.length} 可用 / ${missing.length} 缺依赖 / ${disabled.length} 已禁用`
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="clawhub-toolbar">
|
||||
<input class="input clawhub-search-input" id="skill-search-input" placeholder="搜索 Skill,比如 weather / github / summarize" value="${escapeHtml(state.query || '')}">
|
||||
<button class="btn btn-primary btn-sm" data-action="skill-search">搜索</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-refresh">刷新</button>
|
||||
<a class="btn btn-secondary btn-sm" href="https://clawhub.ai/skills?sort=downloads" target="_blank" rel="noopener">打开 ClawHub</a>
|
||||
<input class="input clawhub-search-input" id="skill-filter-input" placeholder="过滤 Skills..." type="text">
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-retry">刷新</button>
|
||||
<a class="btn btn-secondary btn-sm" href="https://clawhub.ai/skills" target="_blank" rel="noopener">ClawHub</a>
|
||||
${!cliAvailable ? '<span class="form-hint" style="margin-left:auto;color:var(--warning)">CLI 不可用,仅显示本地扫描结果</span>' : ''}
|
||||
</div>
|
||||
|
||||
<div class="clawhub-panel skills-hero-panel">
|
||||
<div class="clawhub-panel-title">热门推荐</div>
|
||||
<div class="skills-scroll-area skills-trending-scroll">
|
||||
${renderTrendingCards(trending, installedSet)}
|
||||
</div>
|
||||
<div class="skills-summary" style="margin-bottom:var(--space-lg);color:var(--text-secondary);font-size:var(--font-size-sm)">
|
||||
共 ${skills.length} 个 Skills: ${summary}
|
||||
</div>
|
||||
|
||||
<div class="clawhub-grid" style="margin-top:var(--space-lg)">
|
||||
<div class="clawhub-panel">
|
||||
<div class="clawhub-panel-title">已安装 Skills</div>
|
||||
<div class="clawhub-list skills-scroll-area skills-installed-scroll">
|
||||
${installed.length ? installed.map(item => `
|
||||
<div class="clawhub-item">
|
||||
<div>
|
||||
<div class="clawhub-item-title">${escapeHtml(item.slug)}</div>
|
||||
<div class="clawhub-item-desc">已安装到本地 Skills 目录</div>
|
||||
</div>
|
||||
<span class="clawhub-badge installed">已安装</span>
|
||||
</div>
|
||||
`).join('') : '<div class="clawhub-empty">还没有已安装的 Skill</div>'}
|
||||
</div>
|
||||
${eligible.length ? `
|
||||
<div class="clawhub-panel" style="margin-bottom:var(--space-lg)">
|
||||
<div class="clawhub-panel-title" style="color:var(--success)">✓ 可用 (${eligible.length})</div>
|
||||
<div class="clawhub-list skills-scroll-area skills-trending-scroll" id="skills-eligible">
|
||||
${eligible.map(s => renderSkillCard(s, 'eligible')).join('')}
|
||||
</div>
|
||||
<div class="clawhub-panel skills-tips-panel">
|
||||
<div class="clawhub-panel-title">使用提示</div>
|
||||
<div class="skills-tip-list">
|
||||
<div class="skills-tip-item"><strong>默认推荐</strong>:首屏展示 ClawHub 热门技能,方便直接浏览</div>
|
||||
<div class="skills-tip-item"><strong>搜索</strong>:输入关键词后会调用 ClawHub CLI 实时搜索</div>
|
||||
<div class="skills-tip-item"><strong>安装</strong>:安装受外部服务限流影响,失败时可稍后重试</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<div class="clawhub-panel" style="margin-top:var(--space-lg)">
|
||||
<div class="clawhub-panel-title">搜索结果</div>
|
||||
<div class="clawhub-list skills-scroll-area skills-search-scroll">
|
||||
${state.query ? renderSkillItems(results, installedSet) : '<div class="clawhub-empty">输入关键词开始搜索</div>'}
|
||||
${missing.length ? `
|
||||
<div class="clawhub-panel" style="margin-bottom:var(--space-lg)">
|
||||
<div class="clawhub-panel-title" style="color:var(--warning);display:flex;align-items:center;gap:var(--space-sm)">
|
||||
<span>✗ 缺少依赖 (${missing.length})</span>
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-ai-fix" style="font-size:var(--font-size-xs);padding:2px 8px">让 AI 助手帮我安装</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clawhub-list skills-scroll-area skills-installed-scroll" id="skills-missing">
|
||||
${missing.map(s => renderSkillCard(s, 'missing')).join('')}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
${disabled.length ? `
|
||||
<div class="clawhub-panel" style="margin-bottom:var(--space-lg)">
|
||||
<div class="clawhub-panel-title" style="color:var(--text-tertiary)">⏸ 已禁用 (${disabled.length})</div>
|
||||
<div class="clawhub-list skills-scroll-area skills-search-scroll" id="skills-disabled">
|
||||
${disabled.map(s => renderSkillCard(s, 'disabled')).join('')}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
${blocked.length ? `
|
||||
<div class="clawhub-panel" style="margin-bottom:var(--space-lg)">
|
||||
<div class="clawhub-panel-title" style="color:var(--text-tertiary)">🚫 白名单阻止 (${blocked.length})</div>
|
||||
<div class="clawhub-list">
|
||||
${blocked.map(s => renderSkillCard(s, 'blocked')).join('')}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
${!skills.length ? `
|
||||
<div class="clawhub-panel">
|
||||
<div class="clawhub-empty" style="text-align:center;padding:var(--space-xl)">
|
||||
<div style="margin-bottom:var(--space-sm)">未检测到任何 Skills</div>
|
||||
<div class="form-hint">请确认 OpenClaw 已正确安装。Skills 随 OpenClaw 捆绑提供,也可自定义放置在 <code>~/.openclaw/skills/</code> 目录下。</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<div id="skill-detail-area"></div>
|
||||
|
||||
<div class="clawhub-panel" style="margin-top:var(--space-lg)">
|
||||
<div class="clawhub-panel-title">从 ClawHub 安装新 Skill</div>
|
||||
<div class="clawhub-toolbar" style="margin-bottom:var(--space-sm)">
|
||||
<input class="input clawhub-search-input" id="clawhub-search-input" placeholder="搜索 ClawHub,如 weather / github / summarize" type="text">
|
||||
<button class="btn btn-primary btn-sm" data-action="clawhub-search">搜索</button>
|
||||
</div>
|
||||
<div id="clawhub-results" class="clawhub-list skills-scroll-area" style="max-height:320px">
|
||||
<div class="clawhub-empty">输入关键词搜索 ClawHub 社区 Skills</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clawhub-panel skills-tips-panel" style="margin-top:var(--space-lg)">
|
||||
<div class="clawhub-panel-title">关于 Skills</div>
|
||||
<div class="skills-tip-list">
|
||||
<div class="skills-tip-item"><strong>捆绑 Skills</strong>:随 OpenClaw 安装包自带,无需额外安装</div>
|
||||
<div class="skills-tip-item"><strong>自定义 Skills</strong>:将 SKILL.md 放入 <code>~/.openclaw/skills/<name>/</code> 目录即可</div>
|
||||
<div class="skills-tip-item"><strong>依赖检查</strong>:某些 Skills 需要特定命令行工具(如 gh、curl)才能使用</div>
|
||||
<div class="skills-tip-item"><strong>浏览更多</strong>:访问 <a href="https://clawhub.ai/skills" target="_blank" rel="noopener">ClawHub</a> 发现社区共享的 Skills</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// 实时过滤
|
||||
const input = el.querySelector('#skill-filter-input')
|
||||
if (input) {
|
||||
input.addEventListener('input', () => {
|
||||
const q = input.value.trim().toLowerCase()
|
||||
el.querySelectorAll('.skill-card-item').forEach(card => {
|
||||
const name = (card.dataset.name || '').toLowerCase()
|
||||
const desc = (card.dataset.desc || '').toLowerCase()
|
||||
card.style.display = (!q || name.includes(q) || desc.includes(q)) ? '' : 'none'
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function renderSkillCard(skill, status) {
|
||||
const emoji = skill.emoji || '📦'
|
||||
const name = skill.name || ''
|
||||
const desc = skill.description || ''
|
||||
const source = skill.bundled ? '捆绑' : (skill.source || '自定义')
|
||||
const missingBins = skill.missing?.bins || []
|
||||
const missingEnv = skill.missing?.env || []
|
||||
const missingConfig = skill.missing?.config || []
|
||||
const installOpts = skill.install || []
|
||||
|
||||
let statusBadge = ''
|
||||
if (status === 'eligible') statusBadge = '<span class="clawhub-badge installed">可用</span>'
|
||||
else if (status === 'missing') statusBadge = '<span class="clawhub-badge" style="background:rgba(245,158,11,0.14);color:#d97706">缺依赖</span>'
|
||||
else if (status === 'disabled') statusBadge = '<span class="clawhub-badge" style="background:rgba(107,114,128,0.14);color:#6b7280">已禁用</span>'
|
||||
else if (status === 'blocked') statusBadge = '<span class="clawhub-badge" style="background:rgba(239,68,68,0.14);color:#ef4444">已阻止</span>'
|
||||
|
||||
let missingHtml = ''
|
||||
if (missingBins.length) missingHtml += `<div class="form-hint" style="margin-top:4px">缺少命令: ${missingBins.map(b => `<code>${esc(b)}</code>`).join(', ')}</div>`
|
||||
if (missingEnv.length) missingHtml += `<div class="form-hint" style="margin-top:4px">缺少环境变量: ${missingEnv.map(e => `<code>${esc(e)}</code>`).join(', ')} <span style="color:var(--text-tertiary);font-size:var(--font-size-xs)">— 需在系统环境变量中配置</span></div>`
|
||||
if (missingConfig.length) missingHtml += `<div class="form-hint" style="margin-top:4px">缺少配置: ${missingConfig.map(c => `<code>${esc(c)}</code>`).join(', ')} <span style="color:var(--text-tertiary);font-size:var(--font-size-xs)">— 需在 openclaw.json 中配置</span></div>`
|
||||
|
||||
let installHtml = ''
|
||||
if (status === 'missing') {
|
||||
if (installOpts.length) {
|
||||
installHtml = `<div style="margin-top:6px">${installOpts.map(opt =>
|
||||
`<button class="btn btn-primary btn-sm" style="margin-right:6px;margin-top:4px" data-action="skill-install-dep" data-kind="${esc(opt.kind)}" data-install='${esc(JSON.stringify(opt))}' data-skill-name="${esc(name)}">${esc(opt.label)}</button>`
|
||||
).join('')}</div>`
|
||||
} else if (missingBins.length && !missingEnv.length && !missingConfig.length) {
|
||||
installHtml = `<div class="form-hint" style="margin-top:6px;color:var(--text-tertiary);font-size:var(--font-size-xs)">无自动安装选项,请手动安装: ${missingBins.map(b => `<code>brew install ${esc(b)}</code> 或 <code>npm i -g ${esc(b)}</code>`).join(' / ')}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="clawhub-item skill-card-item" data-name="${esc(name)}" data-desc="${esc(desc)}">
|
||||
<div class="clawhub-item-main">
|
||||
<div class="clawhub-item-title">${emoji} ${esc(name)}</div>
|
||||
<div class="clawhub-item-meta">${esc(source)}${skill.homepage ? ` · <a href="${esc(skill.homepage)}" target="_blank" rel="noopener" style="color:var(--accent)">${esc(skill.homepage)}</a>` : ''}</div>
|
||||
<div class="clawhub-item-desc">${esc(desc)}</div>
|
||||
${missingHtml}
|
||||
${installHtml}
|
||||
</div>
|
||||
<div class="clawhub-item-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-info" data-name="${esc(name)}">详情</button>
|
||||
${statusBadge}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
async function handleInspect(page, slug) {
|
||||
async function handleInfo(page, name) {
|
||||
const detail = page.querySelector('#skill-detail-area')
|
||||
if (!detail) return
|
||||
detail.innerHTML = '<div class="form-hint" style="margin-top:var(--space-md)">正在加载 Skill 详情...</div>'
|
||||
detail.innerHTML = '<div class="form-hint" style="margin-top:var(--space-md)">正在加载详情...</div>'
|
||||
try {
|
||||
const data = await api.clawhubInspect(slug)
|
||||
const skill = data?.skill || {}
|
||||
const owner = data?.owner || {}
|
||||
const version = data?.latestVersion || {}
|
||||
const skill = await api.skillsInfo(name)
|
||||
const s = skill || {}
|
||||
const reqs = s.requirements || {}
|
||||
const miss = s.missing || {}
|
||||
|
||||
let reqsHtml = ''
|
||||
if (reqs.bins?.length) {
|
||||
reqsHtml += `<div style="margin-top:8px"><strong>需要命令:</strong> ${reqs.bins.map(b => {
|
||||
const ok = !(miss.bins || []).includes(b)
|
||||
return `<code style="color:var(--${ok ? 'success' : 'error'})">${ok ? '✓' : '✗'} ${esc(b)}</code>`
|
||||
}).join(' ')}</div>`
|
||||
}
|
||||
if (reqs.env?.length) {
|
||||
reqsHtml += `<div style="margin-top:4px"><strong>环境变量:</strong> ${reqs.env.map(e => {
|
||||
const ok = !(miss.env || []).includes(e)
|
||||
return `<code style="color:var(--${ok ? 'success' : 'error'})">${ok ? '✓' : '✗'} ${esc(e)}</code>`
|
||||
}).join(' ')}</div>`
|
||||
}
|
||||
|
||||
detail.innerHTML = `
|
||||
<div class="clawhub-detail-card">
|
||||
<div class="clawhub-detail-title">${escapeHtml(skill.displayName || slug)}</div>
|
||||
<div class="clawhub-detail-meta">slug: ${escapeHtml(skill.slug || slug)} · 作者: @${escapeHtml(owner.handle || 'unknown')} · 版本: ${escapeHtml(version.version || 'latest')}</div>
|
||||
<div class="clawhub-detail-desc">${escapeHtml(skill.summary || '暂无摘要')}</div>
|
||||
<div class="clawhub-detail-stats">
|
||||
<span>下载 ${escapeHtml(skill?.stats?.downloads ?? '-')}</span>
|
||||
<span>当前安装 ${escapeHtml(skill?.stats?.installsCurrent ?? '-')}</span>
|
||||
<span>Star ${escapeHtml(skill?.stats?.stars ?? '-')}</span>
|
||||
<div class="clawhub-detail-title">${esc(s.emoji || '📦')} ${esc(s.name || name)}</div>
|
||||
<div class="clawhub-detail-meta">
|
||||
来源: ${esc(s.source || '')} · 路径: <code>${esc(s.filePath || '')}</code>
|
||||
${s.homepage ? ` · <a href="${esc(s.homepage)}" target="_blank" rel="noopener">${esc(s.homepage)}</a>` : ''}
|
||||
</div>
|
||||
<div class="clawhub-detail-desc" style="margin-top:8px">${esc(s.description || '')}</div>
|
||||
${reqsHtml}
|
||||
${(s.install || []).length && !s.eligible ? `<div style="margin-top:8px"><strong>安装选项:</strong> ${s.install.map(i => `<span class="form-hint">→ ${esc(i.label)}</span>`).join(' ')}</div>` : ''}
|
||||
</div>
|
||||
`
|
||||
} catch (e) {
|
||||
detail.innerHTML = `<div style="color:var(--error);margin-top:var(--space-md)">加载详情失败: ${escapeHtml(e.message || e)}</div>`
|
||||
detail.innerHTML = `<div style="color:var(--error);margin-top:var(--space-md)">加载详情失败: ${esc(e?.message || e)}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInstall(page, slug) {
|
||||
const btn = page.querySelector(`[data-action="skill-install"][data-slug="${slug}"]`)
|
||||
if (btn) {
|
||||
btn.disabled = true
|
||||
btn.textContent = '安装中...'
|
||||
}
|
||||
async function handleInstallDep(page, btn) {
|
||||
const kind = btn.dataset.kind
|
||||
let spec
|
||||
try { spec = JSON.parse(btn.dataset.install) } catch { spec = {} }
|
||||
const skillName = btn.dataset.skillName || ''
|
||||
btn.disabled = true
|
||||
btn.textContent = '安装中...'
|
||||
try {
|
||||
await api.clawhubInstall(slug)
|
||||
toast(`Skill ${slug} 安装成功`, 'success')
|
||||
await api.skillsInstallDep(kind, spec)
|
||||
toast(`${skillName} 依赖安装成功`, 'success')
|
||||
await loadSkills(page)
|
||||
} catch (e) {
|
||||
const message = (e?.message || String(e || '')).trim()
|
||||
const friendly = message.includes('Rate limit exceeded')
|
||||
? 'ClawHub 当前限流了,稍后再试'
|
||||
: `安装失败: ${message || '未知错误'}`
|
||||
toast(friendly, 'error')
|
||||
toast(`安装失败: ${e?.message || e}`, 'error')
|
||||
btn.disabled = false
|
||||
btn.textContent = spec.label || '重试'
|
||||
}
|
||||
const query = page.querySelector('#skill-search-input')?.value?.trim() || ''
|
||||
await loadSkills(page, query)
|
||||
}
|
||||
|
||||
async function handleClawHubSearch(page) {
|
||||
const input = page.querySelector('#clawhub-search-input')
|
||||
const results = page.querySelector('#clawhub-results')
|
||||
if (!input || !results) return
|
||||
const q = input.value.trim()
|
||||
if (!q) { results.innerHTML = '<div class="clawhub-empty">输入关键词搜索 ClawHub 社区 Skills</div>'; return }
|
||||
results.innerHTML = '<div class="form-hint">正在搜索...</div>'
|
||||
try {
|
||||
const items = await api.skillsClawHubSearch(q)
|
||||
if (!items?.length) { results.innerHTML = '<div class="clawhub-empty">没有找到匹配的 Skill</div>'; return }
|
||||
results.innerHTML = items.map(item => `
|
||||
<div class="clawhub-item">
|
||||
<div class="clawhub-item-main">
|
||||
<div class="clawhub-item-title">${esc(item.slug || item.name || '')}</div>
|
||||
<div class="clawhub-item-desc">${esc(item.description || item.summary || '')}</div>
|
||||
</div>
|
||||
<div class="clawhub-item-actions">
|
||||
<button class="btn btn-primary btn-sm" data-action="clawhub-install" data-slug="${esc(item.slug || item.name || '')}">安装</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')
|
||||
} catch (e) {
|
||||
results.innerHTML = `<div style="color:var(--error)">搜索失败: ${esc(e?.message || e)}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClawHubInstall(page, btn) {
|
||||
const slug = btn.dataset.slug
|
||||
btn.disabled = true
|
||||
btn.textContent = '安装中...'
|
||||
try {
|
||||
await api.skillsClawHubInstall(slug)
|
||||
toast(`Skill ${slug} 安装成功`, 'success')
|
||||
await loadSkills(page)
|
||||
} catch (e) {
|
||||
toast(`安装失败: ${e?.message || e}`, 'error')
|
||||
btn.disabled = false
|
||||
btn.textContent = '安装'
|
||||
}
|
||||
}
|
||||
|
||||
function bindEvents(page) {
|
||||
page.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('[data-action]')
|
||||
if (!btn) return
|
||||
const action = btn.dataset.action
|
||||
switch (action) {
|
||||
case 'skill-search':
|
||||
await loadSkills(page, page.querySelector('#skill-search-input')?.value?.trim() || '')
|
||||
break
|
||||
case 'skill-refresh':
|
||||
await loadSkills(page, page.querySelector('#skill-search-input')?.value?.trim() || '')
|
||||
break
|
||||
switch (btn.dataset.action) {
|
||||
case 'skill-retry':
|
||||
await loadSkills(page, page.querySelector('#skill-search-input')?.value?.trim() || '')
|
||||
await loadSkills(page)
|
||||
break
|
||||
case 'skill-inspect':
|
||||
await handleInspect(page, btn.dataset.slug)
|
||||
case 'skill-info':
|
||||
await handleInfo(page, btn.dataset.name)
|
||||
break
|
||||
case 'skill-install':
|
||||
await handleInstall(page, btn.dataset.slug)
|
||||
case 'skill-install-dep':
|
||||
await handleInstallDep(page, btn)
|
||||
break
|
||||
case 'clawhub-search':
|
||||
await handleClawHubSearch(page)
|
||||
break
|
||||
case 'clawhub-install':
|
||||
await handleClawHubInstall(page, btn)
|
||||
break
|
||||
case 'skill-ai-fix':
|
||||
// 跳转到 AI 助手并触发 Skills 管理快捷操作
|
||||
window.location.hash = '#/assistant'
|
||||
// 延迟触发内置 skill(等路由加载完)
|
||||
setTimeout(() => {
|
||||
const skillBtn = document.querySelector('.ast-skill-card[data-skill="skills-manager"]')
|
||||
if (skillBtn) skillBtn.click()
|
||||
}, 500)
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
page.addEventListener('keydown', async (e) => {
|
||||
if (e.key === 'Enter' && e.target?.id === 'skill-search-input') {
|
||||
if (e.key === 'Enter' && e.target?.id === 'clawhub-search-input') {
|
||||
e.preventDefault()
|
||||
await loadSkills(page, e.target.value.trim())
|
||||
await handleClawHubSearch(page)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -61,7 +61,13 @@ async function loadRoute() {
|
||||
`
|
||||
_contentEl.appendChild(spinnerEl)
|
||||
|
||||
mod = await loader()
|
||||
try {
|
||||
mod = await withTimeout(loader(), 15000, '模块加载超时')
|
||||
} catch (e) {
|
||||
console.error('[router] 模块加载失败:', hash, e)
|
||||
if (thisLoad === _loadId) showLoadError(_contentEl, hash, e)
|
||||
return
|
||||
}
|
||||
_moduleCache[hash] = mod
|
||||
} else {
|
||||
_contentEl.innerHTML = ''
|
||||
@@ -70,7 +76,17 @@ async function loadRoute() {
|
||||
// 如果加载期间路由又变了,丢弃本次结果
|
||||
if (thisLoad !== _loadId) return
|
||||
|
||||
const page = mod.render ? await mod.render() : mod.default ? await mod.default() : mod
|
||||
let page
|
||||
try {
|
||||
const renderFn = mod.render || mod.default
|
||||
page = renderFn ? await withTimeout(renderFn(), 15000, '页面渲染超时') : mod
|
||||
} catch (e) {
|
||||
console.error('[router] 页面渲染失败:', hash, e)
|
||||
// 渲染失败时清除缓存,下次重试时重新加载模块
|
||||
delete _moduleCache[hash]
|
||||
if (thisLoad === _loadId) showLoadError(_contentEl, hash, e)
|
||||
return
|
||||
}
|
||||
if (thisLoad !== _loadId) return
|
||||
|
||||
// 插入页面内容
|
||||
@@ -90,6 +106,31 @@ async function loadRoute() {
|
||||
})
|
||||
}
|
||||
|
||||
function withTimeout(promise, ms, msg) {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error(msg)), ms))
|
||||
])
|
||||
}
|
||||
|
||||
function showLoadError(container, hash, error) {
|
||||
const name = hash.replace('/', '') || 'unknown'
|
||||
container.innerHTML = `
|
||||
<div class="page-loader">
|
||||
<div style="color:var(--error,#ef4444);margin-bottom:12px">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="48" height="48"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
|
||||
</div>
|
||||
<div class="page-loader-text" style="color:var(--text-primary)">页面加载失败</div>
|
||||
<div style="color:var(--text-tertiary);font-size:12px;margin:8px 0 16px;max-width:400px;word-break:break-all">${escHtml(String(error?.message || error))}</div>
|
||||
<button onclick="location.hash='${hash}';location.reload()" style="padding:6px 20px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);cursor:pointer;font-size:13px">重新加载</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')
|
||||
}
|
||||
|
||||
export function getCurrentRoute() {
|
||||
return window.location.hash.slice(1) || _defaultRoute
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user