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:
晴天
2026-03-08 01:46:27 +08:00
parent dbc2aa8a61
commit 02e1ef6b14
23 changed files with 1892 additions and 381 deletions

View File

@@ -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 反向代理等)

View File

@@ -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"]
```
构建并运行:

View File

@@ -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>

View File

@@ -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
View File

@@ -0,0 +1,9 @@
{
"version": "0.6.0",
"minAppVersion": "0.6.0",
"hash": "",
"url": "",
"size": 0,
"changelog": "",
"releasedAt": ""
}

View File

@@ -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"
},

View File

@@ -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() {

View File

@@ -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
View 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) })

View File

@@ -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)
}
/// 卸载 OpenClawnpm 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> {

View File

@@ -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 {

View 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 安装 Skillnpx 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 搜索 Skillsnpx 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()
}

View 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")
}
/// 更新清单 URLGitHub 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",
}
}

View File

@@ -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 失败");

View File

@@ -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?.() },
}
}

View File

@@ -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>',
}

View File

@@ -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"/>',

View File

@@ -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 }),

View File

@@ -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'))

View File

@@ -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)

View File

@@ -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 需要 formulanode 需要 packagego 需要 moduleuv 需要 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 仅限 macOSWindows 用 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('')

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
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/&lt;name&gt;/</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)
}
})
}

View File

@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')
}
export function getCurrentRoute() {
return window.location.hash.slice(1) || _defaultRoute
}