diff --git a/README.md b/README.md
index 642d97a..bf86ac1 100644
--- a/README.md
+++ b/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 反向代理等)
diff --git a/docs/docker-deploy.md b/docs/docker-deploy.md
index a60f7cc..36a65f1 100644
--- a/docs/docker-deploy.md
+++ b/docs/docker-deploy.md
@@ -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"]
```
构建并运行:
diff --git a/docs/index.html b/docs/index.html
index f16293b..77b0898 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -991,6 +991,13 @@
.dmg
+
diff --git a/docs/linux-deploy.md b/docs/linux-deploy.md
index 57dd3fc..af2cbbc 100644
--- a/docs/linux-deploy.md
+++ b/docs/linux-deploy.md
@@ -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。
diff --git a/docs/update/latest.json b/docs/update/latest.json
new file mode 100644
index 0000000..d6708c4
--- /dev/null
+++ b/docs/update/latest.json
@@ -0,0 +1,9 @@
+{
+ "version": "0.6.0",
+ "minAppVersion": "0.6.0",
+ "hash": "",
+ "url": "",
+ "size": 0,
+ "changelog": "",
+ "releasedAt": ""
+}
diff --git a/package.json b/package.json
index 49761cb..d37b1d5 100644
--- a/package.json
+++ b/package.json
@@ -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"
},
diff --git a/scripts/dev-api.js b/scripts/dev-api.js
index 295f4b1..d454272 100644
--- a/scripts/dev-api.js
+++ b/scripts/dev-api.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() {
diff --git a/scripts/linux-deploy.sh b/scripts/linux-deploy.sh
index 2693a5c..059e8d2 100644
--- a/scripts/linux-deploy.sh
+++ b/scripts/linux-deploy.sh
@@ -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
diff --git a/scripts/serve.js b/scripts/serve.js
new file mode 100644
index 0000000..3408ea3
--- /dev/null
+++ b/scripts/serve.js
@@ -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
监听地址 (默认: 0.0.0.0)
+ --port, -p 监听端口 (默认: 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) })
diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs
index cc18e8d..231ba16 100644
--- a/src-tauri/src/commands/config.rs
+++ b/src-tauri/src/commands/config.rs
@@ -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 {
+pub async fn list_openclaw_versions(source: String) -> Result, 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 = obj.keys().cloned().collect();
+ // 按版本号排序(新版本在前)
+ vers.sort_by(|a, b| {
+ let pa: Vec = a.split(|c: char| !c.is_ascii_digit()).filter_map(|s| s.parse().ok()).collect();
+ let pb: Vec = 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) -> Result {
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 Result {
+ 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 {
diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs
index 8e68093..1738d32 100644
--- a/src-tauri/src/commands/mod.rs
+++ b/src-tauri/src/commands/mod.rs
@@ -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 {
diff --git a/src-tauri/src/commands/skills.rs b/src-tauri/src/commands/skills.rs
new file mode 100644
index 0000000..131c82f
--- /dev/null
+++ b/src-tauri/src/commands/skills.rs
@@ -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 {
+ 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 --json)
+#[tauri::command]
+pub async fn skills_info(name: String) -> Result {
+ 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 {
+ 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 {
+ 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 )
+#[tauri::command]
+pub async fn skills_clawhub_install(slug: String) -> Result {
+ 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 )
+#[tauri::command]
+pub async fn skills_clawhub_search(query: String) -> Result {
+ 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 = 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 {
+ 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()
+}
diff --git a/src-tauri/src/commands/update.rs b/src-tauri/src/commands/update.rs
new file mode 100644
index 0000000..7c7e5fe
--- /dev/null
+++ b/src-tauri/src/commands/update.rs
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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",
+ }
+}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 22bc8a3..c1da545 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -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 失败");
diff --git a/src/components/modal.js b/src/components/modal.js
index c360e31..2873499 100644
--- a/src/components/modal.js
+++ b/src/components/modal.js
@@ -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 = `
-
升级 OpenClaw
+
${title || '升级 OpenClaw'}
准备中...
@@ -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?.() },
}
}
diff --git a/src/components/sidebar.js b/src/components/sidebar.js
index 243a624..08af03f 100644
--- a/src/components/sidebar.js
+++ b/src/components/sidebar.js
@@ -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: '
',
assistant: '
',
security: '
',
+ skills: '
',
debug: '
',
}
diff --git a/src/lib/icons.js b/src/lib/icons.js
index cb23c39..baa0c4d 100644
--- a/src/lib/icons.js
+++ b/src/lib/icons.js
@@ -27,6 +27,8 @@ const PATHS = {
'file-text': '
',
'file-plain': '
',
'package': '
',
+ 'box': '
',
+ 'trash': '
',
'terminal': '
',
'edit': '
',
'folder': '
',
diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js
index dc0ed0b..6a0b0c3 100644
--- a/src/lib/tauri-api.js
+++ b/src/lib/tauri-api.js
@@ -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 }),
diff --git a/src/main.js b/src/main.js
index 133836c..600280d 100644
--- a/src/main.js
+++ b/src/main.js
@@ -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'))
diff --git a/src/pages/about.js b/src/pages/about.js
index e144893..be1c313 100644
--- a/src/pages/about.js
+++ b/src/pages/about.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 = '
检查更新中...'
- 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 = `
新版本: ${info.latest} 下载更新`
- } else {
- panelCard.innerHTML = '
已是最新'
- }
- }).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 = '
仓库未公开,发布后可自动检测'
- } else {
- panelCard.innerHTML = '
检查更新失败'
- }
- })
+ 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 = `
@@ -100,12 +87,17 @@ async function loadData(page) {
${panelUpdateHtml}
-
+
${version.current || '未安装'}
-
@@ -115,46 +107,41 @@ async function loadData(page) {
`
- // 绑定升级按钮
- 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 = `
+
+
${isInstalled ? '切换版本' : '安装 OpenClaw'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+ 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 = `当前:
${currentVersion.source === 'official' ? '原版' : '汉化版'} ${currentVersion.current} →
${targetSource === 'official' ? '原版' : '汉化版'} ${targetVer}`
+ 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 = `
${currentVersion.current} → ${targetVer}`
+ confirmBtn.disabled = false
+ } else {
+ confirmBtn.textContent = '降级'
+ hintEl.innerHTML = `
${currentVersion.current} → ${targetVer}`
+ confirmBtn.disabled = false
+ }
+ }
+
+ async function loadVersions(source) {
+ select.innerHTML = '
'
+ confirmBtn.disabled = true
+ hintEl.textContent = ''
+ try {
+ if (!versionsCache[source]) {
+ versionsCache[source] = await api.listOpenclawVersions(source)
+ }
+ const versions = versionsCache[source]
+ if (!versions.length) {
+ select.innerHTML = '
'
+ return
+ }
+ select.innerHTML = versions.map(v => {
+ const isCurrent = isInstalled && v === currentVersion.current && source === (currentVersion.source === 'official' ? 'official' : 'chinese')
+ return `
`
+ }).join('')
+ updateHint()
+ } catch (e) {
+ select.innerHTML = `
`
+ }
+ }
+
+ 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 = `
+
v${ver} 已就绪
+
+
+ `
+ 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 = `
+
新版本: v${ver}
+ ${changelog ? `
${changelog}` : ''}
+
+
完整安装包
+ `
+ 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 = '
需要更新完整安装包 下载'
+ } else {
+ meta.innerHTML = '
已是最新'
+ }
+ } 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 = '
暂无法检查更新'
+ } else {
+ meta.innerHTML = '
检查更新失败'
+ }
+ }
+}
+
function compareVersions(a, b) {
const pa = a.split('.').map(Number)
const pb = b.split('.').map(Number)
diff --git a/src/pages/assistant.js b/src/pages/assistant.js
index b53af3e..5494a0e 100644
--- a/src/pages/assistant.js
+++ b/src/pages/assistant.js
@@ -134,6 +134,14 @@ ${personality}
- openclaw gateway install — 安装 Gateway 为系统服务
- openclaw gateway uninstall — 卸载 Gateway 系统服务
+### Skills 管理
+- openclaw skills list — 列出所有 Skills 及其状态
+- openclaw skills info
— 查看某个 Skill 详情
+- openclaw skills check — 检查所有 Skills 的依赖是否满足
+- Skill 依赖安装: 根据 install spec 执行 brew/npm/go/uv 安装缺少的命令行工具
+- ClawHub (clawhub.com): 社区 Skill 市场,可搜索和安装新 Skill
+- Skills 目录: 捆绑 Skills 在 openclaw 安装包内,自定义 Skills 放在 ~/.openclaw/skills//
+
### 聊天与调试
- 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 ``
}
@@ -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 `
- ${icon} ${label} ${argsStr} ${statusLabel}
+ ${tcIcon} ${label} ${argsStr} ${statusLabel}
${escHtml(resultPreview)}
`
}).join('')
diff --git a/src/pages/skills.js b/src/pages/skills.js
index 4ccf1e6..e076f34 100644
--- a/src/pages/skills.js
+++ b/src/pages/skills.js
@@ -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, '"')
+ return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"')
}
export async function render() {
@@ -25,275 +18,329 @@ export async function render() {
page.innerHTML = `
`
-
bindEvents(page)
loadSkills(page)
return page
}
-function wait(ms) {
- return new Promise(resolve => setTimeout(resolve, ms))
-}
-
-function withTimeout(promise, ms, label = '请求') {
- return Promise.race([
- promise,
- new Promise((_, reject) => {
- setTimeout(() => reject(new Error(`${label}超时(>${Math.round(ms / 1000)}s)`)), ms)
- }),
- ])
-}
-
-function setLoadingHint(page, text = '正在加载 Skills...') {
+async function loadSkills(page) {
const el = page.querySelector('#skills-content')
if (!el) return
- el.innerHTML = `
-
-
-
${escapeHtml(text)}
-
- `
-}
+ const seq = ++_loadSeq
-function renderLoadError(el, message, canAutoRetry) {
- el.innerHTML = `
-
-
加载失败:${escapeHtml(message)}
-
${canAutoRetry ? '正在自动重试...' : '你可以手动重试'}
-
-
-
-
- `
-}
-
-async function loadSkills(page, query = '', options = {}) {
- const el = page.querySelector('#skills-content')
- if (!el) return
-
- const silent = !!options.silent
- const retryCount = options.retryCount || 0
- const requestId = ++skillsLoadSeq
-
- if (!silent) {
- setLoadingHint(page, retryCount > 0 ? `正在重试加载(第 ${retryCount + 1} 次)...` : '正在加载 Skills...')
- }
+ el.innerHTML = ``
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 = `
+
加载失败: ${esc(e?.message || e)}
+
请确认 OpenClaw 已安装并可用
+
+
`
}
}
-function renderSkillItems(items, installedSet) {
- if (!items.length) return '暂无内容
'
- return items.map(item => `
-
-
-
${escapeHtml(item.displayName || item.slug)}
-
${escapeHtml(item.slug)}${item.author ? ` · @${escapeHtml(item.author)}` : ''}${item.downloadsText ? ` · ${escapeHtml(item.downloadsText)}` : ''}
-
${escapeHtml(item.summary || '暂无摘要,可点击查看详情')}
-
-
-
- ${installedSet.has(item.slug)
- ? '已安装'
- : ``}
-
-
- `).join('')
-}
+function 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 '暂无推荐内容
'
- return `
-
- ${items.map(item => `
-
-
-
-
${escapeHtml(item.displayName || item.slug)}
-
${escapeHtml(item.slug)}${item.author ? ` · @${escapeHtml(item.author)}` : ''}
-
-
- ${item.downloadsText ? `${escapeHtml(item.downloadsText)}` : ''}
- ${installedSet.has(item.slug) ? '已安装' : ''}
-
-
-
${escapeHtml(item.summary || '暂无摘要')}
-
-
- ${installedSet.has(item.slug)
- ? '已在本地可用'
- : ``}
-
-
- `).join('')}
-
- `
-}
-
-function renderSkills(el, state) {
- const installed = state.installed || []
- const trending = state.trending || []
- const results = state.results || []
- const installedSet = new Set(installed.map(x => x.slug))
+ const summary = `${eligible.length} 可用 / ${missing.length} 缺依赖 / ${disabled.length} 已禁用`
el.innerHTML = `
-
-
热门推荐
-
- ${renderTrendingCards(trending, installedSet)}
-
+
+ 共 ${skills.length} 个 Skills: ${summary}
-
-
-
已安装 Skills
-
+ ${eligible.length ? `
+
+
✓ 可用 (${eligible.length})
+
+ ${eligible.map(s => renderSkillCard(s, 'eligible')).join('')}
-
-
使用提示
-
-
默认推荐:首屏展示 ClawHub 热门技能,方便直接浏览
-
搜索:输入关键词后会调用 ClawHub CLI 实时搜索
-
安装:安装受外部服务限流影响,失败时可稍后重试
-
-
-
+
` : ''}
-
-
搜索结果
-
` : ''}
+
+ ${disabled.length ? `
+
+
⏸ 已禁用 (${disabled.length})
+
+ ${disabled.map(s => renderSkillCard(s, 'disabled')).join('')}
+
+
` : ''}
+
+ ${blocked.length ? `
+
+
🚫 白名单阻止 (${blocked.length})
+
+ ${blocked.map(s => renderSkillCard(s, 'blocked')).join('')}
+
+
` : ''}
+
+ ${!skills.length ? `
+
+
+
未检测到任何 Skills
+
请确认 OpenClaw 已正确安装。Skills 随 OpenClaw 捆绑提供,也可自定义放置在 ~/.openclaw/skills/ 目录下。
+
+
` : ''}
+
+
+
从 ClawHub 安装新 Skill
+
+
+
+
+
+
+
+
+
关于 Skills
+
+
捆绑 Skills:随 OpenClaw 安装包自带,无需额外安装
+
自定义 Skills:将 SKILL.md 放入 ~/.openclaw/skills/<name>/ 目录即可
+
依赖检查:某些 Skills 需要特定命令行工具(如 gh、curl)才能使用
+
+
+
+ `
+
+ // 实时过滤
+ 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 = '
可用'
+ else if (status === 'missing') statusBadge = '
缺依赖'
+ else if (status === 'disabled') statusBadge = '
已禁用'
+ else if (status === 'blocked') statusBadge = '
已阻止'
+
+ let missingHtml = ''
+ if (missingBins.length) missingHtml += `
缺少命令: ${missingBins.map(b => `${esc(b)}`).join(', ')}
`
+ if (missingEnv.length) missingHtml += `
缺少环境变量: ${missingEnv.map(e => `${esc(e)}`).join(', ')} — 需在系统环境变量中配置
`
+ if (missingConfig.length) missingHtml += `
缺少配置: ${missingConfig.map(c => `${esc(c)}`).join(', ')} — 需在 openclaw.json 中配置
`
+
+ let installHtml = ''
+ if (status === 'missing') {
+ if (installOpts.length) {
+ installHtml = `
${installOpts.map(opt =>
+ ``
+ ).join('')}
`
+ } else if (missingBins.length && !missingEnv.length && !missingConfig.length) {
+ installHtml = `
无自动安装选项,请手动安装: ${missingBins.map(b => `brew install ${esc(b)} 或 npm i -g ${esc(b)}`).join(' / ')}
`
+ }
+ }
+
+ return `
+
+
+
${emoji} ${esc(name)}
+
+
${esc(desc)}
+ ${missingHtml}
+ ${installHtml}
+
+
+
+ ${statusBadge}
+
+
`
}
-async function handleInspect(page, slug) {
+async function handleInfo(page, name) {
const detail = page.querySelector('#skill-detail-area')
if (!detail) return
- detail.innerHTML = '
正在加载 Skill 详情...
'
+ detail.innerHTML = '
正在加载详情...
'
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 += `
需要命令: ${reqs.bins.map(b => {
+ const ok = !(miss.bins || []).includes(b)
+ return `${ok ? '✓' : '✗'} ${esc(b)}`
+ }).join(' ')}
`
+ }
+ if (reqs.env?.length) {
+ reqsHtml += `
环境变量: ${reqs.env.map(e => {
+ const ok = !(miss.env || []).includes(e)
+ return `${ok ? '✓' : '✗'} ${esc(e)}`
+ }).join(' ')}
`
+ }
+
detail.innerHTML = `
-
${escapeHtml(skill.displayName || slug)}
-
slug: ${escapeHtml(skill.slug || slug)} · 作者: @${escapeHtml(owner.handle || 'unknown')} · 版本: ${escapeHtml(version.version || 'latest')}
-
${escapeHtml(skill.summary || '暂无摘要')}
-
-
下载 ${escapeHtml(skill?.stats?.downloads ?? '-')}
-
当前安装 ${escapeHtml(skill?.stats?.installsCurrent ?? '-')}
-
Star ${escapeHtml(skill?.stats?.stars ?? '-')}
+
${esc(s.emoji || '📦')} ${esc(s.name || name)}
+
+
${esc(s.description || '')}
+ ${reqsHtml}
+ ${(s.install || []).length && !s.eligible ? `
安装选项: ${s.install.map(i => `→ ${esc(i.label)}`).join(' ')}
` : ''}
`
} catch (e) {
- detail.innerHTML = `
加载详情失败: ${escapeHtml(e.message || e)}
`
+ detail.innerHTML = `
加载详情失败: ${esc(e?.message || e)}
`
}
}
-async function handleInstall(page, slug) {
- const btn = page.querySelector(`[data-action="skill-install"][data-slug="${slug}"]`)
- if (btn) {
- btn.disabled = true
- btn.textContent = '安装中...'
- }
+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 = '
输入关键词搜索 ClawHub 社区 Skills
'; return }
+ results.innerHTML = '
正在搜索...
'
+ try {
+ const items = await api.skillsClawHubSearch(q)
+ if (!items?.length) { results.innerHTML = '
没有找到匹配的 Skill
'; return }
+ results.innerHTML = items.map(item => `
+
+
+
${esc(item.slug || item.name || '')}
+
${esc(item.description || item.summary || '')}
+
+
+
+
+
+ `).join('')
+ } catch (e) {
+ results.innerHTML = `
搜索失败: ${esc(e?.message || e)}
`
+ }
+}
+
+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)
}
})
}
diff --git a/src/router.js b/src/router.js
index 66dae7d..c4a4170 100644
--- a/src/router.js
+++ b/src/router.js
@@ -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 = `
+
+
+
+
+
页面加载失败
+
${escHtml(String(error?.message || error))}
+
+
+ `
+}
+
+function escHtml(s) {
+ return s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"')
+}
+
export function getCurrentRoute() {
return window.location.hash.slice(1) || _defaultRoute
}