chore: release v0.11.5

feat: SkillHub skill store (SDK-based, no CLI dependency)
- Rust SDK (skillhub.rs): HTTP search, index fetch, zip download+extract
- Node.js SDK (skillhub-sdk.js): mirrors Rust SDK for Web/Docker mode
- Skills page: new "Store" tab with full index browse + client-side filter
- Remove 6 old CLI-dependent commands, add 3 SDK commands
- Migrate assistant.js skill tools from ClawHub CLI to SkillHub SDK
- Fix index decode error ({total,skills} wrapper vs bare array)
- Fix skill name display (API field 'name' vs 'display_name')
- Clean up 13 dead CSS rules from old skills hero/tips UI
This commit is contained in:
晴天
2026-04-07 03:25:26 +08:00
parent b57235e2a7
commit ad00ffef3d
20 changed files with 1244 additions and 851 deletions

View File

@@ -5,6 +5,23 @@
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
## [0.11.5] - 2026-04-07
### 新功能 (Features)
- **SkillHub 技能商店SDK 化)** — Skills 页面新增"搜索安装"商店 tab支持浏览全量索引、客户端实时过滤、一键安装底层从 CLI 调用全面迁移到内置 HTTP SDKRust + Node.js 双端),不再依赖 OpenClaw CLI 即可搜索和安装社区 Skill
### 改进 (Improvements)
- **Skills 命令层精简** — 移除 6 个旧 CLI 依赖命令,新增 3 个 SkillHub SDK 命令search / index / install本地扫描命令改为纯文件系统操作
- **AI 助手工具迁移** — 晴辰助手的 Skill 搜索/安装工具从 ClawHub CLI 调用迁移到 SkillHub SDK工具定义、系统提示、handler、显示标签全部更新
- **前端 CSS 清理** — 移除 13 条不再使用的旧 Skills 页面样式hero 展示区、tips 区域)
### 修复 (Fixes)
- **技能索引加载失败** — 修复 SkillHub 索引 API 返回 `{total, skills}` 包装对象导致 JSON 解码失败的问题Rust 端新增 `IndexResponse` 包装结构体Node.js 端提取 `.skills` 字段)
- **技能名称显示** — 修复商店列表因 API 字段名不匹配(`name` vs `display_name`)导致只显示 slug 的问题
## [0.11.4] - 2026-04-06 ## [0.11.4] - 2026-04-06
### 新功能 (Features) ### 新功能 (Features)

View File

@@ -0,0 +1,559 @@
# Skills 页面重构规划
> 目标:**完全去掉 CLI 依赖**,用 Rust `reqwest` 内置 SkillHub API 调用 + zip 解压,用户无需安装任何额外工具即可搜索/安装/管理 Skills。
---
## 一、现状分析
### 现有架构(问题)
```
┌─────────────┐ ┌───────────────────┐ ┌──────────────┐
│ skills.js │ ───→ │ skills.rs / dev- │ ───→ │ 外部 CLI 进程 │
│ (前端 UI) │ │ api.js (后端) │ │ │
└─────────────┘ └───────────────────┘ └──────┬───────┘
┌────────────┼────────────┐
▼ ▼ ▼
openclaw CLI skillhub CLI clawhub CLI
(skills list) (search/install) (npx)
```
**痛点清单:**
| # | 问题 | 影响 |
|---|------|------|
| 1 | **必须安装 OpenClaw CLI** 才能查看已安装 Skills 列表 | 新用户看到空白页或报错 |
| 2 | **必须安装 SkillHub CLI** 才能从国内源搜索/安装 | 额外安装步骤,需要 npm |
| 3 | **ClawHub 用 `npx -y clawhub`** 每次冷启动 ~10s | 体验差,且海外源限流 |
| 4 | CLI 输出是人类可读文本,需要**正则解析** | 脆弱CLI 版本更新就可能坏 |
| 5 | 前端有两个安装源下拉 + CLI 检测状态 UI | 复杂且令人困惑 |
| 6 | `skills_list` 依赖 CLI超时/失败才 fallback 本地扫描 | 不可靠,延迟高 |
### 可保留的部分
| 模块 | 保留? | 说明 |
|------|--------|------|
| `scan_local_skills()` / 本地扫描逻辑 | ✅ 保留 | 扫描 `~/.openclaw/skills/` 等目录,不依赖 CLI |
| `scan_single_skill()` | ✅ 保留 | 解析 SKILL.md frontmatter + package.json |
| `skills_uninstall()` | ✅ 保留 | 简单的 `rm -rf`,无 CLI 依赖 |
| `skills_validate()` | ✅ 保留 | 纯本地文件检查 |
| `skills_install_dep()` | ✅ 保留 | brew/npm/go/uv 本地包管理器 |
| 前端已安装 Tab 的分组渲染 | ✅ 保留 | eligible/missing/disabled/blocked 分组 |
| 前端过滤搜索 | ✅ 保留 | 实时 filter |
---
## 二、SkillHub API 协议
从 CLI 源码逆向得到的接口(腾讯云 COS + API 后端):
| 功能 | URL | 返回 |
|------|-----|------|
| **搜索** | `GET https://lightmake.site/api/v1/search?q={query}&limit={limit}` | `{ results: [{ slug, displayName, summary, version }] }` |
| **主下载** | `GET https://lightmake.site/api/v1/download?slug={slug}` | zip 二进制 |
| **COS 镜像下载** | `GET https://skillhub-1388575217.cos.ap-guangzhou.myqcloud.com/skills/{slug}.zip` | zip 二进制(国内加速) |
| **全量索引** | `GET https://skillhub-1388575217.cos.ap-guangzhou.myqcloud.com/skills.json` | JSON 数组 |
### 安装流程(内置化)
```
搜索 → 选择 Skill → 下载 zip → 解压到 ~/.openclaw/skills/{slug}/ → 完成
```
不需要任何 CLI 工具。
---
## 三、新架构设计SDK 模式 + 双平台)
关键设计:**抽出独立 SDK 层**Tauri 桌面端和 Web/Docker 端各实现一份,上层 API 接口一致,后续调整只改 SDK 即可。
```
┌──────────────────┐
│ skills.js (前端) │ 统一 UI不关心后端是 Rust 还是 Node
└────────┬─────────┘
│ invoke / fetch
┌──────────────────────────────────────────────────────────┐
│ Tauri 命令层 / dev-api 路由层 │
│ skills.rs (Tauri) dev-api.js (Web/Docker) │
│ ↓ 调用 ↓ 调用 │
│ skillhub.rs (Rust SDK) skillhub-sdk.js (Node SDK) │
└──────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────────────────┐
│ SkillHub API + COS │
│ 搜索: lightmake.site/api/v1/search │
│ 索引: cos.ap-guangzhou.myqcloud.com/skills.json │
│ 下载: cos.ap-guangzhou.myqcloud.com/skills/{slug}.zip │
│ 回退: lightmake.site/api/v1/download │
└──────────────────────────────────────────────────────────┘
```
### 为什么用 SDK 模式?
| 优势 | 说明 |
|------|------|
| **解耦** | SDK 只管 HTTP + zip不涉及 Tauri/Express 框架 |
| **双平台** | Rust SDK 给 Tauri 桌面端用Node SDK 给 Web/Docker 端用 |
| **易调整** | API 域名/路径/认证变了,只改 SDK 一个文件 |
| **可测试** | SDK 函数可单独写单元测试 |
| **可复用** | 未来其他模块要调 SkillHub 也直接引 SDK |
---
## 四、Rust SDK`src-tauri/src/commands/skillhub.rs`
独立模块,不依赖 Tauri 框架,纯 `reqwest` + `zip` + `serde`
### 4.1 数据结构
```rust
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillHubItem {
pub slug: String,
#[serde(alias = "displayName")]
pub display_name: Option<String>,
pub summary: Option<String>,
pub version: Option<String>,
}
#[derive(Debug, Deserialize)]
struct SearchResponse {
results: Vec<SkillHubItem>,
}
```
### 4.2 SDK 公开接口
```rust
/// 搜索 SkillHub
pub async fn search(query: &str, limit: u32) -> Result<Vec<SkillHubItem>, String>
/// 拉取全量索引(带 10 分钟内存缓存)
pub async fn fetch_index() -> Result<Vec<SkillHubItem>, String>
/// 下载并安装 Skillzip → 解压到 target_dir
pub async fn install(slug: &str, skills_dir: &Path) -> Result<PathBuf, String>
/// 仅下载 zip 字节COS 优先,回退主站)
pub async fn download_zip(slug: &str) -> Result<Vec<u8>, String>
```
### 4.3 下载策略
```rust
const COS_BASE: &str = "https://skillhub-1388575217.cos.ap-guangzhou.myqcloud.com";
const API_BASE: &str = "https://lightmake.site/api/v1";
pub async fn download_zip(slug: &str) -> Result<Vec<u8>, String> {
// 1. 优先 COS 镜像(国内 CDN毫秒级
let cos_url = format!("{}/skills/{}.zip", COS_BASE, slug);
if let Ok(resp) = client().get(&cos_url).send().await {
if resp.status().is_success() {
return resp.bytes().await
.map(|b| b.to_vec())
.map_err(|e| format!("COS 下载失败: {e}"));
}
}
// 2. 回退主站 API
let api_url = format!("{}/download?slug={}", API_BASE, slug);
let resp = client().get(&api_url).send().await
.map_err(|e| format!("主站下载失败: {e}"))?;
if !resp.status().is_success() {
return Err(format!("下载失败: HTTP {}", resp.status()));
}
resp.bytes().await
.map(|b| b.to_vec())
.map_err(|e| format!("读取下载内容失败: {e}"))
}
```
### 4.4 全量索引缓存
```rust
use std::sync::Mutex;
use std::time::{Duration, Instant};
use once_cell::sync::Lazy;
static INDEX_CACHE: Lazy<Mutex<Option<(Instant, Vec<SkillHubItem>)>>> =
Lazy::new(|| Mutex::new(None));
pub async fn fetch_index() -> Result<Vec<SkillHubItem>, String> {
// 命中缓存10 分钟有效)
if let Ok(guard) = INDEX_CACHE.lock() {
if let Some((ts, ref items)) = *guard {
if ts.elapsed() < Duration::from_secs(600) {
return Ok(items.clone());
}
}
}
// 拉取远程索引
let url = format!("{}/skills.json", COS_BASE);
let items: Vec<SkillHubItem> = client().get(&url).send().await
.map_err(|e| format!("拉取索引失败: {e}"))?
.json().await
.map_err(|e| format!("解析索引失败: {e}"))?;
// 写入缓存
if let Ok(mut guard) = INDEX_CACHE.lock() {
*guard = Some((Instant::now(), items.clone()));
}
Ok(items)
}
```
### 4.5 zip 解压
```rust
pub fn extract_zip(zip_bytes: &[u8], target_dir: &Path) -> Result<(), String> {
use std::io::Cursor;
use zip::ZipArchive;
if target_dir.exists() {
std::fs::remove_dir_all(target_dir)
.map_err(|e| format!("清理旧目录失败: {e}"))?;
}
std::fs::create_dir_all(target_dir)
.map_err(|e| format!("创建目录失败: {e}"))?;
let reader = Cursor::new(zip_bytes);
let mut archive = ZipArchive::new(reader)
.map_err(|e| format!("打开 zip 失败: {e}"))?;
for i in 0..archive.len() {
let mut file = archive.by_index(i)
.map_err(|e| format!("读取 zip 条目失败: {e}"))?;
let name = file.name().to_string();
// 安全检查:防止路径穿越
if name.contains("..") { continue; }
let out_path = target_dir.join(&name);
if file.is_dir() {
std::fs::create_dir_all(&out_path).ok();
} else {
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent).ok();
}
let mut outfile = std::fs::File::create(&out_path)
.map_err(|e| format!("创建文件失败 {name}: {e}"))?;
std::io::copy(&mut file, &mut outfile)
.map_err(|e| format!("写入文件失败 {name}: {e}"))?;
}
}
Ok(())
}
```
---
## 五、Node.js SDK`scripts/lib/skillhub-sdk.js`
给 Web/Docker 端用API 接口与 Rust SDK **完全对齐**
### 5.1 模块结构
```javascript
// scripts/lib/skillhub-sdk.js
const COS_BASE = 'https://skillhub-1388575217.cos.ap-guangzhou.myqcloud.com'
const API_BASE = 'https://lightmake.site/api/v1'
let _indexCache = null // { ts: Date.now(), items: [] }
const INDEX_TTL = 10 * 60 * 1000 // 10 分钟
module.exports = { search, fetchIndex, install, downloadZip }
```
### 5.2 SDK 公开接口
```javascript
/**
* 搜索 SkillHub
* @param {string} query
* @param {number} [limit=20]
* @returns {Promise<Array<{slug, displayName, summary, version}>>}
*/
async function search(query, limit = 20) {
const url = `${API_BASE}/search?q=${encodeURIComponent(query)}&limit=${limit}`
const resp = await fetch(url)
if (!resp.ok) throw new Error(`搜索失败: HTTP ${resp.status}`)
const data = await resp.json()
return data.results || []
}
/**
* 拉取全量索引(带缓存)
* @returns {Promise<Array<{slug, displayName, summary, version}>>}
*/
async function fetchIndex() {
if (_indexCache && Date.now() - _indexCache.ts < INDEX_TTL) {
return _indexCache.items
}
const resp = await fetch(`${COS_BASE}/skills.json`)
if (!resp.ok) throw new Error(`拉取索引失败: HTTP ${resp.status}`)
const items = await resp.json()
_indexCache = { ts: Date.now(), items }
return items
}
/**
* 下载 zipCOS 优先,回退主站)
* @param {string} slug
* @returns {Promise<Buffer>}
*/
async function downloadZip(slug) {
// COS 优先
try {
const resp = await fetch(`${COS_BASE}/skills/${slug}.zip`)
if (resp.ok) return Buffer.from(await resp.arrayBuffer())
} catch {}
// 回退主站
const resp = await fetch(`${API_BASE}/download?slug=${encodeURIComponent(slug)}`)
if (!resp.ok) throw new Error(`下载失败: HTTP ${resp.status}`)
return Buffer.from(await resp.arrayBuffer())
}
/**
* 下载并安装 Skill
* @param {string} slug
* @param {string} skillsDir - ~/.openclaw/skills/
* @returns {Promise<string>} 安装路径
*/
async function install(slug, skillsDir) {
const zipBuf = await downloadZip(slug)
const targetDir = path.join(skillsDir, slug)
// 清理旧目录
if (fs.existsSync(targetDir)) fs.rmSync(targetDir, { recursive: true, force: true })
fs.mkdirSync(targetDir, { recursive: true })
// 解压(用 Node.js 内置或 adm-zip
const AdmZip = require('adm-zip')
const zip = new AdmZip(zipBuf)
zip.extractAllTo(targetDir, true)
return targetDir
}
```
### 5.3 dev-api.js 集成
```javascript
const skillhub = require('./lib/skillhub-sdk')
// 路由处理器中直接调用 SDK
handlers = {
skillhub_search({ query, limit }) { return skillhub.search(query, limit) },
skillhub_index() { return skillhub.fetchIndex() },
skillhub_install({ slug }) { return skillhub.install(slug, SKILLS_DIR) },
// skills_list → 纯本地扫描(已有 scanLocalSkillsFallback
skills_list() { return scanLocalSkillsFallback() },
}
```
---
## 六、命令层改造
### 6.1 skills.rs — Tauri 命令层(薄包装)
改造后 `skills.rs` 只是 SDK 的 Tauri 命令薄包装:
```rust
mod skillhub; // SDK 模块
#[tauri::command]
pub async fn skillhub_search(query: String, limit: Option<u32>) -> Result<Value, String> {
let items = skillhub::search(&query, limit.unwrap_or(20)).await?;
Ok(serde_json::to_value(items).unwrap())
}
#[tauri::command]
pub async fn skillhub_index() -> Result<Value, String> {
let items = skillhub::fetch_index().await?;
Ok(serde_json::to_value(items).unwrap())
}
#[tauri::command]
pub async fn skillhub_install(slug: String) -> Result<Value, String> {
let skills_dir = super::openclaw_dir().join("skills");
let path = skillhub::install(&slug, &skills_dir).await?;
Ok(serde_json::json!({ "success": true, "slug": slug, "path": path.to_string_lossy() }))
}
// skills_list → 纯本地扫描(复用已有 scan_local_skills
#[tauri::command]
pub async fn skills_list() -> Result<Value, String> {
scan_local_skills(None) // 不再调 CLI
}
```
### 6.2 可删除的命令
| 命令 | 原因 |
|------|------|
| `skills_skillhub_check` | 不再需要检测 CLI |
| `skills_skillhub_setup` | 不再需要安装 CLI |
| `skills_skillhub_search` | 替换为 `skillhub_search`SDK |
| `skills_skillhub_install` | 替换为 `skillhub_install`SDK |
| `skills_clawhub_search` | 合并到 SkillHub |
| `skills_clawhub_install` | 合并到 SkillHub |
### 6.3 保留的命令
| 命令 | 说明 |
|------|------|
| `skills_list` | 改为纯本地扫描 |
| `skills_info` | 改为纯本地文件解析 |
| `skills_uninstall` | 不变(删目录) |
| `skills_validate` | 不变(本地文件检查) |
| `skills_install_dep` | 不变brew/npm/go/uv |
---
## 七、前端改造(`skills.js`
### 7.1 UI 简化
**删除:**
- 安装源下拉(`<select id="install-source-select">`)— 统一为 SkillHub
- SkillHub CLI 状态检测 / 安装按钮
- ClawHub 源相关 UI
- `checkSkillHubStatus()` / `switchInstallSource()` 等函数
**保留:**
- 两个 Tab"已安装" / "技能商店"
- 已安装 Tab 的分组渲染eligible/missing/disabled/blocked
- 实时过滤搜索
- Skill 卡片渲染
**新增:**
- 技能商店 Tab 改为**浏览模式**:默认加载全量索引(热门/推荐),支持搜索过滤
- 安装进度条/状态(下载中 → 解压中 → 完成)
- 已安装 Skill 的**更新检测**(比对本地版本 vs 索引版本)
### 7.2 新的"技能商店"Tab 布局
```
┌──────────────────────────────────────────────────┐
│ 🔍 搜索技能... [浏览 SkillHub] │
├──────────────────────────────────────────────────┤
│ │
│ 📦 weather ☀️ 天气查询 [安装] │
│ 📦 github 🐙 GitHub 操作 [安装] │
│ 📦 tavily 🔍 网页搜索 [安装] │
│ 📦 feishu-doc 📄 飞书文档 [安装] │
│ ... │
│ │
└──────────────────────────────────────────────────┘
```
- 页面进入时自动加载全量索引COS CDN国内毫秒级
- 搜索框实时过滤(客户端)+ 回车触发服务端搜索(更精准)
- 已安装的 Skill 显示"已安装"灰色标记,不显示安装按钮
### 7.3 API 调用映射
| 旧 API | 新 API | 说明 |
|--------|--------|------|
| `api.skillsList()` | `api.skillsList()` | 后端改为纯本地扫描 |
| `api.skillsSkillHubCheck()` | ❌ 删除 | 不再需要 |
| `api.skillsSkillHubSetup()` | ❌ 删除 | 不再需要 |
| `api.skillsSkillHubSearch(q)` | `api.skillhubSearch(q)` | 内置 HTTP 调用 |
| `api.skillsSkillHubInstall(slug)` | `api.skillhubInstall(slug)` | 内置下载+解压 |
| `api.skillsClawHubSearch(q)` | ❌ 删除 | 统一到 SkillHub |
| `api.skillsClawHubInstall(slug)` | ❌ 删除 | 统一到 SkillHub |
| — | `api.skillhubIndex()` | 新增:全量索引 |
---
## 八、i18n 改造
### 删除的 key
```
skillhubNeedCLI, skillhubNeedCLIHint, skillhubSetup,
skillhubInstalling, skillhubInstalled, skillhubInstallFailed,
sourceSkillHub, sourceClawHub, installCLI,
rateLimitClawHub, sourceLocalScanTimeout, sourceLocalScanParseFailed,
sourceLocalScanExecFailed, sourceLocalScan, sourceLocalScanNoCli, sourceCLI,
loadFailedHint (不再需要提示安装 OpenClaw)
```
### 新增/修改的 key
```
storeTitle: '技能商店' / 'Skill Store'
storeLoading: '正在加载技能索引...' / 'Loading skill index...'
storeLoadFailed: '加载技能索引失败' / 'Failed to load skill index'
downloading: '下载中...' / 'Downloading...'
extracting: '解压中...' / 'Extracting...'
updateAvailable: '可更新' / 'Update available'
update: '更新' / 'Update'
```
---
## 九、实施步骤
### Phase 1Rust SDK 模块(`skillhub.rs`
1. 新建 `src-tauri/src/commands/skillhub.rs`
2. 实现 `SkillHubItem` 数据结构
3. 实现 `search()``fetch_index()``download_zip()``install()``extract_zip()`
4. 实现全量索引内存缓存
5.`commands/mod.rs` 中声明 `pub mod skillhub`
6. `cargo check` 验证 SDK 模块编译通过
### Phase 2Node.js SDK 模块(`skillhub-sdk.js`
1. 新建 `scripts/lib/skillhub-sdk.js`
2. 实现 `search()``fetchIndex()``downloadZip()``install()`
3. 确认 `adm-zip` 已在 devDependencies或改用 Node 内置 `zlib` + `tar`
4. `node --check` 验证
### Phase 3命令层改造skills.rs + dev-api.js
1. `skills.rs`:新增 `skillhub_search``skillhub_index``skillhub_install` Tauri 命令(薄包装 SDK
2. `skills.rs`:改造 `skills_list` → 纯本地扫描,改造 `skills_info` → 纯本地解析
3. `skills.rs`:删除 6 个旧 CLI 命令
4. `lib.rs`:更新命令注册
5. `dev-api.js`:路由层接入 `skillhub-sdk.js`,删除旧 CLI 调用
6. `cargo check` + `node --check` 验证
### Phase 4前端 UI 重写skills.js + tauri-api.js
1. 更新 `tauri-api.js` API 映射(新增 3 个,删除 6 个)
2. 重写"技能商店"Tab — 默认加载全量索引,搜索过滤,一键安装
3. 简化已安装 Tab — 删除 CLI 状态提示和诊断信息
4. 删除 `switchInstallSource``checkSkillHubStatus``handleSkillHubSetup`
5. 添加安装进度反馈(下载中 → 解压中 → 完成)
### Phase 5i18n + 清理 + 验证
1. 更新 `locales/modules/skills.js`(删除旧 key新增商店 key
2. 清理 `assistant.js` 中的 skills 工具定义(如有需要)
3. `cargo check` + `npx vite build` 全量验证
4. 手动测试已安装 Tab + 技能商店 Tab
---
## 十、风险与兼容性
| 风险 | 缓解 |
|------|------|
| SkillHub API 不可用 | COS 镜像作为备选;全量索引可离线缓存 |
| zip 解压路径安全 | 校验 slug 无 `..`/`/`/`\`;解压时检查相对路径 |
| 已有用户的 Skills 目录结构不兼容 | 不变 — 仍然解压到 `~/.openclaw/skills/{slug}/` |
| `skills_list` 去掉 CLI 后丢失 bundled skills 信息 | `custom_skill_roots()` 已包含 bundled 路径推导 |
| `skills_install_dep` (brew/npm/go/uv) 仍需本地工具 | 保留 — 这是 Skill 运行时依赖,不是安装工具依赖 |
---
## 十一、预期效果
| 指标 | 改造前 | 改造后 |
|------|--------|--------|
| 首次使用需安装 | OpenClaw CLI + SkillHub CLI | **无需安装** |
| 搜索延迟 | ~3-10sCLI 冷启动) | **<1s**HTTP API |
| 安装延迟 | ~5-15sCLI 调用) | **~2-5s**(直接下载 zip |
| 前端代码复杂度 | 492 行(含双源切换/CLI 检测) | ~300 行(统一 UI |
| 后端 CLI 调用 | 8 个命令依赖外部 CLI | **0 个** |
| 用户认知负担 | 安装源选择 + CLI 状态 | 搜索框 + 安装按钮 |
| Web/Docker 端 | CLI 经常找不到或权限问题 | **内置 HTTP与桌面端体验一致** |

View File

@@ -34,7 +34,7 @@
"description": "OpenClaw AI Agent 可视化管理面板,基于 Tauri v2 的跨平台桌面应用。内置晴辰助手支持工具调用,晴辰云 AI 接口一键接入。支持仪表盘监控、多模型配置、消息渠道管理、内置 QQ 机器人、实时 AI 聊天、记忆管理、Agent 管理、网关配置、内网穿透等功能。支持 11 种语言。", "description": "OpenClaw AI Agent 可视化管理面板,基于 Tauri v2 的跨平台桌面应用。内置晴辰助手支持工具调用,晴辰云 AI 接口一键接入。支持仪表盘监控、多模型配置、消息渠道管理、内置 QQ 机器人、实时 AI 聊天、记忆管理、Agent 管理、网关配置、内网穿透等功能。支持 11 种语言。",
"url": "https://claw.qt.cool/", "url": "https://claw.qt.cool/",
"downloadUrl": "https://github.com/qingchencloud/clawpanel/releases/latest", "downloadUrl": "https://github.com/qingchencloud/clawpanel/releases/latest",
"softwareVersion": "0.11.4", "softwareVersion": "0.11.5",
"author": { "author": {
"@type": "Organization", "@type": "Organization",
"name": "晴辰云 QingchenCloud", "name": "晴辰云 QingchenCloud",
@@ -1155,7 +1155,7 @@
<div class="orb orb-2" style="top:auto;bottom:-100px"></div> <div class="orb orb-2" style="top:auto;bottom:-100px"></div>
<div class="container-sm" style="position:relative;z-index:10"> <div class="container-sm" style="position:relative;z-index:10">
<div class="section-header"> <div class="section-header">
<div class="reveal download-version"><span class="pulse"></span> <span id="dl-badge" data-i18n="dl.badge">v0.11.4 最新版</span></div> <div class="reveal download-version"><span class="pulse"></span> <span id="dl-badge" data-i18n="dl.badge">v0.11.5 最新版</span></div>
<h2 class="reveal section-title" data-i18n="dl.title"><span class="gradient-text">下载安装</span></h2> <h2 class="reveal section-title" data-i18n="dl.title"><span class="gradient-text">下载安装</span></h2>
<p class="reveal section-desc" data-i18n="dl.desc">选择你的操作系统,一键下载安装</p> <p class="reveal section-desc" data-i18n="dl.desc">选择你的操作系统,一键下载安装</p>
</div> </div>
@@ -1165,11 +1165,11 @@
<h3>macOS</h3> <h3>macOS</h3>
<p class="dl-desc" data-i18n="dl.mac.d">支持 Apple Silicon 和 Intel 芯片</p> <p class="dl-desc" data-i18n="dl.mac.d">支持 Apple Silicon 和 Intel 芯片</p>
<div class="dl-links"> <div class="dl-links">
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.11.4_aarch64.dmg" target="_blank" rel="noopener"> <a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.11.5_aarch64.dmg" target="_blank" rel="noopener">
Apple Silicon (M1/M2/M3/M4) Apple Silicon (M1/M2/M3/M4)
<span class="dl-format">.dmg</span> <span class="dl-format">.dmg</span>
</a> </a>
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.11.4_x64.dmg" target="_blank" rel="noopener"> <a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.11.5_x64.dmg" target="_blank" rel="noopener">
<span data-i18n="dl.mac.intel">Intel 芯片</span> <span data-i18n="dl.mac.intel">Intel 芯片</span>
<span class="dl-format">.dmg</span> <span class="dl-format">.dmg</span>
</a> </a>
@@ -1187,15 +1187,15 @@
<h3>Windows</h3> <h3>Windows</h3>
<p class="dl-desc" data-i18n="dl.win.d">支持 Windows 10 及以上版本</p> <p class="dl-desc" data-i18n="dl.win.d">支持 Windows 10 及以上版本</p>
<div class="dl-links"> <div class="dl-links">
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.11.4_x64-setup.exe" target="_blank" rel="noopener"> <a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.11.5_x64-setup.exe" target="_blank" rel="noopener">
<span data-i18n="dl.win.exe">安装程序</span> <span data-i18n="dl.win.exe">安装程序</span>
<span class="dl-format">.exe</span> <span class="dl-format">.exe</span>
</a> </a>
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.11.4_x64-setup-full.exe" target="_blank" rel="noopener"> <a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.11.5_x64-setup-full.exe" target="_blank" rel="noopener">
<span data-i18n="dl.win.full">完整包(含 WebView2</span> <span data-i18n="dl.win.full">完整包(含 WebView2</span>
<span class="dl-format">.exe</span> <span class="dl-format">.exe</span>
</a> </a>
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.11.4_x64_en-US.msi" target="_blank" rel="noopener"> <a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.11.5_x64_en-US.msi" target="_blank" rel="noopener">
<span data-i18n="dl.win.msi">MSI 安装包</span> <span data-i18n="dl.win.msi">MSI 安装包</span>
<span class="dl-format">.msi</span> <span class="dl-format">.msi</span>
</a> </a>
@@ -1206,11 +1206,11 @@
<h3>Linux</h3> <h3>Linux</h3>
<p class="dl-desc" data-i18n="dl.linux.d">支持主流 Linux 发行版</p> <p class="dl-desc" data-i18n="dl.linux.d">支持主流 Linux 发行版</p>
<div class="dl-links"> <div class="dl-links">
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.11.4_amd64.AppImage" target="_blank" rel="noopener"> <a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.11.5_amd64.AppImage" target="_blank" rel="noopener">
<span data-i18n="dl.linux.ai">通用版</span> <span data-i18n="dl.linux.ai">通用版</span>
<span class="dl-format">.AppImage</span> <span class="dl-format">.AppImage</span>
</a> </a>
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.11.4_amd64.deb" target="_blank" rel="noopener"> <a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.11.5_amd64.deb" target="_blank" rel="noopener">
Debian / Ubuntu Debian / Ubuntu
<span class="dl-format">.deb</span> <span class="dl-format">.deb</span>
</a> </a>

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "clawpanel", "name": "clawpanel",
"version": "0.11.4", "version": "0.11.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "clawpanel", "name": "clawpanel",
"version": "0.11.4", "version": "0.11.5",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.5.0", "@tauri-apps/api": "^2.5.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "clawpanel", "name": "clawpanel",
"version": "0.11.4", "version": "0.11.5",
"private": true, "private": true,
"description": "ClawPanel - OpenClaw 可视化管理面板,基于 Tauri v2 的跨平台桌面应用", "description": "ClawPanel - OpenClaw 可视化管理面板,基于 Tauri v2 的跨平台桌面应用",
"type": "module", "type": "module",

View File

@@ -12,6 +12,7 @@ import { fileURLToPath } from 'url'
import net from 'net' import net from 'net'
import http from 'http' import http from 'http'
import crypto from 'crypto' import crypto from 'crypto'
import * as skillhubSdk from './lib/skillhub-sdk.js'
const DOCKER_TASK_TIMEOUT_MS = 10 * 60 * 1000 const DOCKER_TASK_TIMEOUT_MS = 10 * 60 * 1000
const __dev_dirname = path.dirname(fileURLToPath(import.meta.url)) const __dev_dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -5348,38 +5349,23 @@ const handlers = {
return true return true
}, },
// Skills 管理(模拟 openclaw skills CLI JSON 输出 // Skills 管理(纯本地扫描,不依赖 CLI
skills_list() { skills_list() {
try { return scanLocalSkillsFallback()
const out = execOpenclawSync(['skills', 'list', '--json'], { encoding: 'utf8', timeout: 30000, cwd: homedir(), windowsHide: true }, '读取 Skills 列表失败')
return extractCliJson(out)
} catch (e) {
return scanLocalSkillsFallback(e)
}
}, },
skills_info({ name }) { skills_info({ name }) {
try { const n = String(name || '').trim()
const out = execOpenclawSync(['skills', 'info', String(name || '').trim(), '--json'], { encoding: 'utf8', timeout: 30000, cwd: homedir(), windowsHide: true }, '查看 Skill 详情失败') const fallback = scanLocalSkillsFallback().skills.find(skill => skill.name === n)
return extractCliJson(out) if (fallback) return fallback
} catch (e) { throw new Error(`Skill「${n}」不存在`)
const fallback = scanLocalSkillsFallback(e).skills.find(skill => skill.name === String(name || '').trim())
if (fallback) return fallback
throw new Error('查看详情失败: ' + (e.message || e))
}
}, },
skills_check() { skills_check() {
try { const data = scanLocalSkillsFallback()
const out = execOpenclawSync(['skills', 'check', '--json'], { encoding: 'utf8', timeout: 30000, cwd: homedir(), windowsHide: true }, '检查 Skills 依赖失败') return {
return extractCliJson(out) total: data.skills.length,
} catch (e) { ready: (data.eligible || []).length,
const fallback = scanLocalSkillsFallback(e) missingDeps: (data.missingRequirements || []).length,
return { skills: data.skills,
summary: fallback.summary,
eligible: fallback.eligible,
disabled: fallback.disabled,
blocked: fallback.blocked,
missingRequirements: fallback.missingRequirements,
}
} }
}, },
skills_install_dep({ kind, spec }) { skills_install_dep({ kind, spec }) {
@@ -5398,69 +5384,6 @@ const handlers = {
throw new Error(`安装失败: ${e.message || e}`) throw new Error(`安装失败: ${e.message || e}`)
} }
}, },
skills_skillhub_check() {
try {
const out = execSync('skillhub --cli-version', { encoding: 'utf8', timeout: 5000 })
return { installed: true, version: out.trim() }
} catch {
return { installed: false }
}
},
skills_skillhub_setup({ cliOnly }) {
const flag = cliOnly ? '--cli-only' : '--no-skills'
try {
const out = execSync(
`curl -fsSL https://skillhub-1388575217.cos.ap-guangzhou.myqcloud.com/install/install.sh | bash -s -- ${flag}`,
{ encoding: 'utf8', timeout: 120000 }
)
return { success: true, output: out.trim() }
} catch (e) {
throw new Error('SkillHub 安装失败: ' + (e.message || e))
}
},
skills_skillhub_search({ query }) {
const q = String(query || '').trim()
if (!q) return []
try {
const out = execSync(`skillhub search ${JSON.stringify(q)}`, { encoding: 'utf8', timeout: 30000 })
// 解析格式: [N] owner/repo/name 状态\n 统计 描述...
const lines = out.split('\n')
const items = []
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim()
if (!trimmed.startsWith('[')) continue
const bracketEnd = trimmed.indexOf(']')
if (bracketEnd < 0) continue
const afterBracket = trimmed.slice(bracketEnd + 1).trim()
const slug = (afterBracket.split(/\s/)[0] || '').trim()
if (!slug.includes('/')) continue
let desc = ''
if (i + 1 < lines.length) {
const next = lines[i + 1].trim()
const starIdx = next.indexOf('⭐')
if (starIdx >= 0) {
const afterStar = next.slice(starIdx + 2).trim()
desc = afterStar.replace(/^[\d.]+[kKmM]?\s*/, '').trim()
}
}
items.push({ slug, description: desc, source: 'skillhub' })
}
return items
} catch (e) {
throw new Error('搜索失败: ' + (e.message || e) + '。请先安装 SkillHub CLI')
}
},
skills_skillhub_install({ slug }) {
const skillsDir = path.join(OPENCLAW_DIR, 'skills')
if (!fs.existsSync(skillsDir)) fs.mkdirSync(skillsDir, { recursive: true })
try {
const out = execSync(`skillhub install ${JSON.stringify(slug)} --force`, { cwd: homedir(), encoding: 'utf8', timeout: 120000 })
return { success: true, slug, output: out.trim() }
} catch (e) {
throw new Error('安装失败: ' + (e.message || e) + '。请先安装 SkillHub CLI')
}
},
skills_uninstall({ name }) { skills_uninstall({ name }) {
if (!name || name.includes('..') || name.includes('/') || name.includes('\\')) throw new Error('无效的 Skill 名称') if (!name || name.includes('..') || name.includes('/') || name.includes('\\')) throw new Error('无效的 Skill 名称')
const skillDir = path.join(OPENCLAW_DIR, 'skills', name) const skillDir = path.join(OPENCLAW_DIR, 'skills', name)
@@ -5468,32 +5391,18 @@ const handlers = {
fs.rmSync(skillDir, { recursive: true, force: true }) fs.rmSync(skillDir, { recursive: true, force: true })
return { success: true, name } return { success: true, name }
}, },
skills_clawhub_search({ query }) { // SkillHub SDK内置 HTTP不依赖 CLI
const q = String(query || '').trim() async skillhub_search({ query, limit }) {
if (!q) return [] return await skillhubSdk.search(query, limit || 20)
try {
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.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))
}
}, },
skills_clawhub_install({ slug }) { async skillhub_index() {
return await skillhubSdk.fetchIndex()
},
async skillhub_install({ slug }) {
const skillsDir = path.join(OPENCLAW_DIR, 'skills') const skillsDir = path.join(OPENCLAW_DIR, 'skills')
if (!fs.existsSync(skillsDir)) fs.mkdirSync(skillsDir, { recursive: true }) if (!fs.existsSync(skillsDir)) fs.mkdirSync(skillsDir, { recursive: true })
try { const installedPath = await skillhubSdk.install(slug, skillsDir)
const out = execSync(`npx -y clawhub install ${JSON.stringify(slug)}`, { cwd: homedir(), encoding: 'utf8', timeout: 120000 }) return { success: true, slug, path: installedPath }
return { success: true, slug, output: out.trim() }
} catch (e) {
throw new Error('安装失败: ' + (e.message || e))
}
}, },
// 设备配对 + Gateway 握手 // 设备配对 + Gateway 握手

216
scripts/lib/skillhub-sdk.js Normal file
View File

@@ -0,0 +1,216 @@
/**
* SkillHub SDK — Node.js 版
* 纯 HTTP + zip 操作API 接口与 Rust SDK (skillhub.rs) 完全对齐。
* 供 dev-api.js Web/Docker 端调用。
*/
import fs from 'fs'
import path from 'path'
import { inflateRaw } from 'zlib'
import { promisify } from 'util'
const inflateRawAsync = promisify(inflateRaw)
const COS_BASE = 'https://skillhub-1388575217.cos.ap-guangzhou.myqcloud.com'
const API_BASE = 'https://lightmake.site/api/v1'
const INDEX_TTL = 10 * 60 * 1000 // 10 分钟缓存
let _indexCache = null // { ts: number, items: Array }
// ── 公开接口 ──────────────────────────────────────────────
/**
* 搜索 SkillHub
* @param {string} query
* @param {number} [limit=20]
* @returns {Promise<Array<{slug, displayName, summary, version}>>}
*/
export async function search(query, limit = 20) {
const q = (query || '').trim()
if (!q) return []
const url = `${API_BASE}/search?q=${encodeURIComponent(q)}&limit=${limit}`
const resp = await fetch(url, { signal: AbortSignal.timeout(15000) })
if (!resp.ok) throw new Error(`SkillHub 搜索失败: HTTP ${resp.status}`)
const data = await resp.json()
return data.results || []
}
/**
* 拉取全量索引(带 10 分钟内存缓存)
* @returns {Promise<Array<{slug, displayName, summary, version}>>}
*/
export async function fetchIndex() {
if (_indexCache && Date.now() - _indexCache.ts < INDEX_TTL) {
return _indexCache.items
}
const url = `${COS_BASE}/skills.json`
const resp = await fetch(url, { signal: AbortSignal.timeout(15000) })
if (!resp.ok) throw new Error(`拉取技能索引失败: HTTP ${resp.status}`)
const data = await resp.json()
const items = data.skills || data // 兼容 {total, skills} 包装和裸数组
_indexCache = { ts: Date.now(), items }
return items
}
/**
* 下载 Skill zipCOS 镜像优先,回退主站 API
* @param {string} slug
* @returns {Promise<Buffer>}
*/
export async function downloadZip(slug) {
// 1. 优先 COS 镜像(国内 CDN
try {
const resp = await fetch(`${COS_BASE}/skills/${slug}.zip`, {
signal: AbortSignal.timeout(30000)
})
if (resp.ok) return Buffer.from(await resp.arrayBuffer())
} catch { /* COS 失败,回退主站 */ }
// 2. 回退主站 API
const resp = await fetch(`${API_BASE}/download?slug=${encodeURIComponent(slug)}`, {
signal: AbortSignal.timeout(30000)
})
if (!resp.ok) throw new Error(`下载失败: HTTP ${resp.status}`)
return Buffer.from(await resp.arrayBuffer())
}
/**
* 下载并安装 Skillzip → 解压到 skillsDir/{slug}/
* @param {string} slug
* @param {string} skillsDir - 如 ~/.openclaw/skills/
* @returns {Promise<string>} 安装路径
*/
export async function install(slug, skillsDir) {
validateSlug(slug)
const targetDir = path.join(skillsDir, slug)
const zipBuf = await downloadZip(slug)
await extractZip(zipBuf, targetDir)
return targetDir
}
// ── 内部工具 ──────────────────────────────────────────────
function validateSlug(slug) {
if (!slug) throw new Error('Skill slug 不能为空')
if (slug.includes('..') || slug.includes('/') || slug.includes('\\')) {
throw new Error(`无效的 Skill slug: ${slug}`)
}
}
/**
* 纯 Node.js zip 解压(无外部依赖)
* 支持 Deflate (method 8) 和 Stored (method 0)
* @param {Buffer} zipBuf
* @param {string} targetDir
*/
async function extractZip(zipBuf, targetDir) {
// 清理旧目录
if (fs.existsSync(targetDir)) fs.rmSync(targetDir, { recursive: true, force: true })
fs.mkdirSync(targetDir, { recursive: true })
const entries = parseZipEntries(zipBuf)
if (!entries.length) throw new Error('zip 文件为空或无法解析')
// 检测单一根目录(常见打包方式),需要剥掉
const stripPrefix = detectSingleRootDir(entries)
for (const entry of entries) {
let name = entry.name
// 安全检查
if (name.includes('..')) continue
// 剥掉单一根目录
if (stripPrefix) {
if (!name.startsWith(stripPrefix)) continue
name = name.slice(stripPrefix.length)
if (!name) continue
}
const outPath = path.join(targetDir, name)
if (entry.isDir) {
fs.mkdirSync(outPath, { recursive: true })
} else {
const dir = path.dirname(outPath)
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
let data
if (entry.method === 0) {
// Stored
data = zipBuf.subarray(entry.dataOffset, entry.dataOffset + entry.compressedSize)
} else if (entry.method === 8) {
// Deflate
const compressed = zipBuf.subarray(entry.dataOffset, entry.dataOffset + entry.compressedSize)
data = await inflateRawAsync(compressed)
} else {
console.warn(`[skillhub-sdk] 跳过不支持的压缩方法 ${entry.method}: ${name}`)
continue
}
fs.writeFileSync(outPath, data)
}
}
}
/**
* 解析 zip 文件的 Local File Header 条目
* @param {Buffer} buf
* @returns {Array<{name, isDir, method, compressedSize, dataOffset}>}
*/
function parseZipEntries(buf) {
const entries = []
let offset = 0
const LOCAL_FILE_HEADER_SIG = 0x04034b50
while (offset + 30 <= buf.length) {
const sig = buf.readUInt32LE(offset)
if (sig !== LOCAL_FILE_HEADER_SIG) break
const method = buf.readUInt16LE(offset + 8)
const compressedSize = buf.readUInt32LE(offset + 18)
const uncompressedSize = buf.readUInt32LE(offset + 22)
const nameLen = buf.readUInt16LE(offset + 26)
const extraLen = buf.readUInt16LE(offset + 28)
const name = buf.subarray(offset + 30, offset + 30 + nameLen).toString('utf8')
const dataOffset = offset + 30 + nameLen + extraLen
entries.push({
name,
isDir: name.endsWith('/'),
method,
compressedSize,
uncompressedSize,
dataOffset,
})
// 处理 data descriptor (bit 3 of general purpose bit flag)
const gpFlag = buf.readUInt16LE(offset + 6)
let dataSize = compressedSize
if ((gpFlag & 0x08) && compressedSize === 0) {
// Data descriptor 跟在压缩数据后面,需要查找
// 简化处理:跳过这种情况(极少见)
break
}
offset = dataOffset + dataSize
}
return entries
}
/**
* 检测 zip 是否有单一顶层目录
* @param {Array<{name}>} entries
* @returns {string|null}
*/
function detectSingleRootDir(entries) {
let root = null
for (const entry of entries) {
const firstSeg = entry.name.split('/')[0]
if (!firstSeg) continue
const prefix = firstSeg + '/'
if (root === null) {
root = prefix
} else if (!entry.name.startsWith(root)) {
return null // 多个顶层目录
}
}
return root
}

2
src-tauri/Cargo.lock generated
View File

@@ -351,7 +351,7 @@ dependencies = [
[[package]] [[package]]
name = "clawpanel" name = "clawpanel"
version = "0.11.4" version = "0.11.5"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "clawpanel" name = "clawpanel"
version = "0.11.4" version = "0.11.5"
edition = "2021" edition = "2021"
description = "ClawPanel - OpenClaw 可视化管理面板" description = "ClawPanel - OpenClaw 可视化管理面板"
authors = ["qingchencloud"] authors = ["qingchencloud"]

View File

@@ -23,6 +23,7 @@ pub mod memory;
pub mod messaging; pub mod messaging;
pub mod pairing; pub mod pairing;
pub mod service; pub mod service;
pub mod skillhub;
pub mod skills; pub mod skills;
pub mod update; pub mod update;

View File

@@ -0,0 +1,260 @@
//! SkillHub SDK — 纯 HTTP + zip 操作,不依赖 Tauri 框架。
//! 供 skills.rs Tauri 命令层薄包装调用。
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, Instant};
const COS_BASE: &str = "https://skillhub-1388575217.cos.ap-guangzhou.myqcloud.com";
const API_BASE: &str = "https://lightmake.site/api/v1";
const INDEX_TTL: Duration = Duration::from_secs(600); // 10 分钟缓存
// ── 数据结构 ──────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillHubItem {
pub slug: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default, alias = "displayName")]
pub display_name: Option<String>,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default)]
pub categories: Option<Vec<String>>,
#[serde(default)]
pub homepage: Option<String>,
#[serde(default)]
pub downloads: Option<u64>,
#[serde(default)]
pub stars: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct SearchResponse {
#[serde(default)]
results: Vec<SkillHubItem>,
}
#[derive(Debug, Deserialize)]
struct IndexResponse {
#[serde(default)]
skills: Vec<SkillHubItem>,
}
// ── 全量索引缓存 ──────────────────────────────────────────
static INDEX_CACHE: Mutex<Option<(Instant, Vec<SkillHubItem>)>> = Mutex::new(None);
// ── HTTP 客户端 ──────────────────────────────────────────
fn client() -> Result<reqwest::Client, String> {
super::build_http_client(Duration::from_secs(30), Some("ClawPanel-SkillHub/1.0"))
}
// ── 公开接口 ──────────────────────────────────────────────
/// 搜索 SkillHub
pub async fn search(query: &str, limit: u32) -> Result<Vec<SkillHubItem>, String> {
let q = query.trim();
if q.is_empty() {
return Ok(vec![]);
}
let url = format!(
"{}/search?q={}&limit={}",
API_BASE,
urlencoding::encode(q),
limit
);
let resp = client()?
.get(&url)
.send()
.await
.map_err(|e| format!("SkillHub 搜索请求失败: {e}"))?;
if !resp.status().is_success() {
return Err(format!("SkillHub 搜索失败: HTTP {}", resp.status()));
}
let data: SearchResponse = resp
.json()
.await
.map_err(|e| format!("SkillHub 搜索结果解析失败: {e}"))?;
Ok(data.results)
}
/// 拉取全量索引(带 10 分钟内存缓存)
pub async fn fetch_index() -> Result<Vec<SkillHubItem>, String> {
// 命中缓存
if let Ok(guard) = INDEX_CACHE.lock() {
if let Some((ts, ref items)) = *guard {
if ts.elapsed() < INDEX_TTL {
return Ok(items.clone());
}
}
}
// 拉取远程索引
let url = format!("{}/skills.json", COS_BASE);
let resp = client()?
.get(&url)
.send()
.await
.map_err(|e| format!("拉取技能索引失败: {e}"))?;
if !resp.status().is_success() {
return Err(format!("拉取技能索引失败: HTTP {}", resp.status()));
}
let data: IndexResponse = resp
.json()
.await
.map_err(|e| format!("解析技能索引失败: {e}"))?;
let items = data.skills;
// 写入缓存
if let Ok(mut guard) = INDEX_CACHE.lock() {
*guard = Some((Instant::now(), items.clone()));
}
Ok(items)
}
/// 下载 Skill zipCOS 镜像优先,回退主站 API
pub async fn download_zip(slug: &str) -> Result<Vec<u8>, String> {
let c = client()?;
// 1. 优先 COS 镜像(国内 CDN
let cos_url = format!("{}/skills/{}.zip", COS_BASE, slug);
match c.get(&cos_url).send().await {
Ok(resp) if resp.status().is_success() => {
return resp
.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| format!("COS 下载读取失败: {e}"));
}
_ => {}
}
// 2. 回退主站 API
let api_url = format!("{}/download?slug={}", API_BASE, urlencoding::encode(slug));
let resp = c
.get(&api_url)
.send()
.await
.map_err(|e| format!("主站下载请求失败: {e}"))?;
if !resp.status().is_success() {
return Err(format!("下载失败: HTTP {}", resp.status()));
}
resp.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| format!("下载读取失败: {e}"))
}
/// 下载并安装 Skillzip → 解压到 skills_dir/{slug}/
pub async fn install(slug: &str, skills_dir: &Path) -> Result<PathBuf, String> {
validate_slug(slug)?;
let target_dir = skills_dir.join(slug);
let zip_bytes = download_zip(slug).await?;
extract_zip(&zip_bytes, &target_dir)?;
Ok(target_dir)
}
// ── 内部工具 ──────────────────────────────────────────────
/// 校验 slug 安全性
fn validate_slug(slug: &str) -> Result<(), String> {
if slug.is_empty() {
return Err("Skill slug 不能为空".into());
}
if slug.contains("..") || slug.contains('/') || slug.contains('\\') {
return Err(format!("无效的 Skill slug: {slug}"));
}
Ok(())
}
/// 将 zip 字节解压到目标目录
fn extract_zip(zip_bytes: &[u8], target_dir: &Path) -> Result<(), String> {
use std::io::Cursor;
use zip::ZipArchive;
// 清理旧目录
if target_dir.exists() {
std::fs::remove_dir_all(target_dir)
.map_err(|e| format!("清理旧目录失败: {e}"))?;
}
std::fs::create_dir_all(target_dir)
.map_err(|e| format!("创建目录失败: {e}"))?;
let reader = Cursor::new(zip_bytes);
let mut archive =
ZipArchive::new(reader).map_err(|e| format!("打开 zip 失败: {e}"))?;
// 收集所有文件名,检测是否都在同一个顶层目录下(常见的 zip 打包方式)
let names: Vec<String> = (0..archive.len())
.filter_map(|i| archive.by_index_raw(i).ok().map(|f| f.name().to_string()))
.collect();
let strip_prefix = detect_single_root_dir(&names);
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|e| format!("读取 zip 条目失败: {e}"))?;
let raw_name = file.name().to_string();
// 安全检查:防止路径穿越
if raw_name.contains("..") {
continue;
}
// 如果 zip 内有单一根目录,剥掉它
let relative = if let Some(ref prefix) = strip_prefix {
match raw_name.strip_prefix(prefix.as_str()) {
Some(rest) if !rest.is_empty() => rest.to_string(),
_ => continue, // 跳过根目录本身
}
} else {
raw_name.clone()
};
if relative.is_empty() {
continue;
}
let out_path = target_dir.join(&relative);
if file.is_dir() {
std::fs::create_dir_all(&out_path).ok();
} else {
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent).ok();
}
let mut outfile = std::fs::File::create(&out_path)
.map_err(|e| format!("创建文件失败 {relative}: {e}"))?;
std::io::copy(&mut file, &mut outfile)
.map_err(|e| format!("写入文件失败 {relative}: {e}"))?;
}
}
Ok(())
}
/// 检测 zip 是否有单一顶层目录(如 `skill-name/...`),返回要剥掉的前缀
fn detect_single_root_dir(names: &[String]) -> Option<String> {
let mut root: Option<String> = None;
for name in names {
let first_segment = name.split('/').next().unwrap_or("");
if first_segment.is_empty() {
continue;
}
match &root {
None => root = Some(format!("{}/", first_segment)),
Some(existing) => {
if !name.starts_with(existing.as_str()) {
return None; // 多个顶层目录
}
}
}
}
root
}

View File

@@ -1,118 +1,35 @@
use crate::utils::openclaw_command_async;
use serde_json::Value; use serde_json::Value;
use std::collections::HashSet;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[allow(unused_imports)] #[allow(unused_imports)]
use std::os::windows::process::CommandExt; use std::os::windows::process::CommandExt;
/// 列出所有 Skills 及其状态(openclaw skills list --json /// 列出所有 Skills 及其状态(纯本地扫描,不依赖 CLI
#[tauri::command] #[tauri::command]
pub async fn skills_list() -> Result<Value, String> { pub async fn skills_list() -> Result<Value, String> {
let output = tokio::time::timeout( scan_local_skills(None)
std::time::Duration::from_secs(15),
openclaw_command_async()
.args(["skills", "list", "--json"])
.output(),
)
.await;
match output {
Ok(Ok(o)) => {
let stdout = String::from_utf8_lossy(&o.stdout);
// CLI 可能在有 skill 缺依赖时返回非零退出码,但 JSON 输出仍然有效
// 优先尝试解析 JSON无论退出码
match extract_json(&stdout) {
Some(mut v) => {
if let Some(obj) = v.as_object_mut() {
obj.insert("cliAvailable".into(), Value::Bool(true));
obj.insert(
"diagnostic".into(),
serde_json::json!({
"status": "ok",
"message": "已使用 OpenClaw CLI 结果",
"exitCode": o.status.code().unwrap_or(0),
}),
);
}
merge_local_skills(v)
}
None => {
let stderr = String::from_utf8_lossy(&o.stderr);
eprintln!(
"[skills] CLI JSON 解析失败 (exit={})兜底扫描。stdout={} stderr={}",
o.status.code().unwrap_or(-1),
stdout.chars().take(200).collect::<String>(),
stderr.chars().take(200).collect::<String>()
);
scan_local_skills(Some(serde_json::json!({
"status": "parse-failed",
"message": "OpenClaw CLI 可执行,但返回结果未能解析为 JSON当前展示本地扫描结果",
"cliAvailable": true,
"exitCode": o.status.code().unwrap_or(-1),
"stderr": stderr.chars().take(200).collect::<String>(),
})))
}
}
}
Ok(Err(e)) => scan_local_skills(Some(serde_json::json!({
"status": "exec-failed",
"message": format!("调用 OpenClaw CLI 失败,当前展示本地扫描结果: {e}"),
"cliAvailable": false,
}))),
Err(_) => scan_local_skills(Some(serde_json::json!({
"status": "timeout",
"message": "OpenClaw CLI 调用超时,当前展示本地扫描结果",
"cliAvailable": true,
"timeoutSeconds": 15,
}))),
}
} }
/// 查看单个 Skill 详情(openclaw skills info <name> --json /// 查看单个 Skill 详情(纯本地文件解析,不依赖 CLI
#[tauri::command] #[tauri::command]
pub async fn skills_info(name: String) -> Result<Value, String> { pub async fn skills_info(name: String) -> Result<Value, String> {
let output = openclaw_command_async() scan_custom_skill_detail(&name)
.args(["skills", "info", &name, "--json"]) .ok_or_else(|| format!("Skill「{name}」不存在"))
.output()
.await
.map_err(|e| format!("执行 openclaw 失败: {e}"))?;
if !output.status.success() {
if let Some(local) = scan_custom_skill_detail(&name) {
return Ok(local);
}
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("获取详情失败: {}", stderr.trim()));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed =
extract_json(&stdout).ok_or_else(|| "解析详情失败: 输出中未找到有效 JSON".to_string())?;
if parsed.get("error").and_then(|v| v.as_str()) == Some("not found") {
if let Some(local) = scan_custom_skill_detail(&name) {
return Ok(local);
}
}
Ok(parsed)
} }
/// 检查 Skills 依赖状态(openclaw skills check --json /// 检查 Skills 依赖状态(纯本地扫描
#[tauri::command] #[tauri::command]
pub async fn skills_check() -> Result<Value, String> { pub async fn skills_check() -> Result<Value, String> {
let output = openclaw_command_async() let skills = scan_local_skill_entries()?;
.args(["skills", "check", "--json"]) let total = skills.len();
.output() let ready = skills.iter().filter(|s| s.get("eligible").and_then(|v| v.as_bool()).unwrap_or(false)).count();
.await let missing = total - ready;
.map_err(|e| format!("执行 openclaw 失败: {e}"))?; Ok(serde_json::json!({
"total": total,
if !output.status.success() { "ready": ready,
let stderr = String::from_utf8_lossy(&output.stderr); "missingDeps": missing,
return Err(format!("检查失败: {}", stderr.trim())); "skills": skills,
} }))
let stdout = String::from_utf8_lossy(&output.stdout);
extract_json(&stdout).ok_or_else(|| "解析失败: 输出中未找到有效 JSON".to_string())
} }
/// 安装 Skill 依赖(根据 install spec 执行 brew/npm/go/uv/download /// 安装 Skill 依赖(根据 install spec 执行 brew/npm/go/uv/download
@@ -189,320 +106,35 @@ pub async fn skills_install_dep(kind: String, spec: Value) -> Result<Value, Stri
})) }))
} }
/// 检测 SkillHub CLI 是否已安装 /// 搜索 SkillHub(内置 HTTP不依赖 CLI
#[tauri::command] #[tauri::command]
pub async fn skills_skillhub_check() -> Result<Value, String> { pub async fn skillhub_search(query: String, limit: Option<u32>) -> Result<Value, String> {
let path_env = super::enhanced_path(); let items = super::skillhub::search(&query, limit.unwrap_or(20)).await?;
#[cfg(target_os = "windows")] Ok(serde_json::to_value(items).unwrap_or_default())
let mut cmd = {
let mut c = tokio::process::Command::new("cmd");
c.args(["/c", "skillhub", "--version"]);
c.creation_flags(0x08000000);
c
};
#[cfg(not(target_os = "windows"))]
let mut cmd = {
let mut c = tokio::process::Command::new("skillhub");
c.arg("--version");
c
};
cmd.env("PATH", &path_env);
match cmd.output().await {
Ok(o) if o.status.success() => {
let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
Ok(serde_json::json!({ "installed": true, "version": ver }))
}
_ => Ok(serde_json::json!({ "installed": false })),
}
} }
/// 安装 SkillHub CLI从腾讯云 COS 下载 /// 获取全量技能索引COS CDN带内存缓存
#[tauri::command] #[tauri::command]
pub async fn skills_skillhub_setup(cli_only: bool) -> Result<Value, String> { pub async fn skillhub_index() -> Result<Value, String> {
let path_env = super::enhanced_path(); let items = super::skillhub::fetch_index().await?;
#[allow(unused_variables)] Ok(serde_json::to_value(items).unwrap_or_default())
let flag = if cli_only {
"--cli-only"
} else {
"--no-skills"
};
#[cfg(not(target_os = "windows"))]
{
let mut cmd = tokio::process::Command::new("bash");
cmd.args(["-c", &format!(
"curl -fsSL https://skillhub-1388575217.cos.ap-guangzhou.myqcloud.com/install/install.sh | bash -s -- {flag}"
)])
.env("PATH", &path_env);
super::apply_proxy_env_tokio(&mut cmd);
let output = cmd
.output()
.await
.map_err(|e| format!("执行安装脚本失败: {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!("SkillHub 安装失败: {}", stderr.trim()));
}
Ok(serde_json::json!({ "success": true, "output": stdout.trim() }))
}
#[cfg(target_os = "windows")]
{
// Windows: 通过 npm 全局安装 skillhub避免 bash/WSL 路径问题)
let mut cmd = tokio::process::Command::new("cmd");
cmd.args([
"/c",
"npm",
"install",
"-g",
"skillhub@latest",
"--registry",
"https://registry.npmmirror.com",
])
.env("PATH", &path_env);
super::apply_proxy_env_tokio(&mut cmd);
cmd.creation_flags(0x08000000);
let output = cmd
.output()
.await
.map_err(|e| format!("执行 npm install 失败: {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!("SkillHub CLI 安装失败: {}", stderr.trim()));
}
Ok(serde_json::json!({ "success": true, "output": stdout.trim() }))
}
} }
/// 从 SkillHub 安装 Skillskillhub install <slug> /// 从 SkillHub 安装 Skill内置 HTTP 下载 + zip 解压
#[tauri::command] #[tauri::command]
pub async fn skills_skillhub_install(slug: String) -> Result<Value, String> { pub async fn skillhub_install(slug: String) -> Result<Value, String> {
let path_env = super::enhanced_path();
let home = dirs::home_dir().unwrap_or_default();
let skills_dir = super::openclaw_dir().join("skills"); let skills_dir = super::openclaw_dir().join("skills");
if !skills_dir.exists() { if !skills_dir.exists() {
std::fs::create_dir_all(&skills_dir).map_err(|e| format!("创建 skills 目录失败: {e}"))?; std::fs::create_dir_all(&skills_dir).map_err(|e| format!("创建 skills 目录失败: {e}"))?;
} }
let installed_path = super::skillhub::install(&slug, &skills_dir).await?;
#[cfg(target_os = "windows")]
let mut cmd = {
let mut c = tokio::process::Command::new("cmd");
c.args(["/c", "skillhub", "install", &slug, "--force"]);
c.creation_flags(0x08000000);
c
};
#[cfg(not(target_os = "windows"))]
let mut cmd = {
let mut c = tokio::process::Command::new("skillhub");
c.args(["install", &slug, "--force"]);
c
};
cmd.env("PATH", &path_env).current_dir(&home);
super::apply_proxy_env_tokio(&mut cmd);
let output = cmd
.output()
.await
.map_err(|e| format!("执行 skillhub 失败: {e}。请先安装 SkillHub CLI"))?;
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!({ Ok(serde_json::json!({
"success": true, "success": true,
"slug": slug, "slug": slug,
"output": stdout.trim(), "path": installed_path.to_string_lossy(),
})) }))
} }
/// 从 SkillHub 搜索 Skillsskillhub search <query>
#[tauri::command]
pub async fn skills_skillhub_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();
#[cfg(target_os = "windows")]
let mut cmd = {
let mut c = tokio::process::Command::new("cmd");
c.args(["/c", "skillhub", "search", &q]);
c.creation_flags(0x08000000);
c
};
#[cfg(not(target_os = "windows"))]
let mut cmd = {
let mut c = tokio::process::Command::new("skillhub");
c.args(["search", &q]);
c
};
cmd.env("PATH", &path_env);
super::apply_proxy_env_tokio(&mut cmd);
let output = cmd
.output()
.await
.map_err(|e| format!("执行 skillhub 失败: {e}。请先安装 SkillHub CLI"))?;
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);
// skillhub search 实际输出格式:
// ──────────────── (分隔线)
// [1] openclaw/openclaw/feishu-doc 🛡️ Pass
// AI 85 ⬇ 33 ⭐ 248.7k Feishu document read/write opera...
// ──────────────── (分隔线)
// 序号和 slug 在同一行,描述在下一行
let lines: Vec<&str> = stdout.lines().collect();
let mut items: Vec<Value> = Vec::new();
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
// 找序号行:以 [数字] 开头,同一行包含 slugowner/repo/name
if !trimmed.starts_with('[') {
continue;
}
let bracket_end = match trimmed.find(']') {
Some(pos) => pos,
None => continue,
};
// 提取 ] 后面的内容
let after_bracket = trimmed[bracket_end + 1..].trim();
// slug 是第一个空格前的部分,且包含 /
let slug = after_bracket.split_whitespace().next().unwrap_or("").trim();
if !slug.contains('/') {
continue;
}
// 描述在下一行:跳过数字、⬇、⭐ 等统计信息,提取文字描述
let mut desc = String::new();
if i + 1 < lines.len() {
let next = lines[i + 1].trim();
// 找到第一个英文或中文字母开始的描述文字
// 格式: "AI 85 ⬇ 33 ⭐ 248.7k Feishu document..."
// 或: "⬇ 0 ⭐ 212.2k Feishu document..."
// 策略:找 ⭐ 后面的数字后的文字
if let Some(star_pos) = next.find('⭐') {
let after_star = &next[star_pos + '⭐'.len_utf8()..].trim_start();
// 跳过星标数字(如 "248.7k"
let after_num = after_star
.trim_start_matches(|c: char| {
c.is_ascii_digit()
|| c == '.'
|| c == 'k'
|| c == 'K'
|| c == 'm'
|| c == 'M'
})
.trim();
if !after_num.is_empty() {
desc = after_num.to_string();
}
}
}
items.push(serde_json::json!({
"slug": slug,
"description": desc,
"source": "skillhub"
}));
}
Ok(Value::Array(items))
}
/// 从 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();
#[cfg(target_os = "windows")]
let mut cmd = {
let mut c = tokio::process::Command::new("cmd");
c.args(["/c", "npx", "-y", "clawhub", "search", &q]);
c.creation_flags(0x08000000);
c
};
#[cfg(not(target_os = "windows"))]
let mut cmd = {
let mut c = tokio::process::Command::new("npx");
c.args(["-y", "clawhub", "search", &q]);
c
};
cmd.env("PATH", &path_env);
super::apply_proxy_env_tokio(&mut cmd);
let output = cmd
.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);
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))
}
/// 从 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();
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}"))?;
}
#[cfg(target_os = "windows")]
let mut cmd = {
let mut c = tokio::process::Command::new("cmd");
c.args(["/c", "npx", "-y", "clawhub", "install", &slug]);
c.creation_flags(0x08000000);
c
};
#[cfg(not(target_os = "windows"))]
let mut cmd = {
let mut c = tokio::process::Command::new("npx");
c.args(["-y", "clawhub", "install", &slug]);
c
};
cmd.env("PATH", &path_env).current_dir(&home);
super::apply_proxy_env_tokio(&mut cmd);
let output = cmd
.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() }))
}
/// 卸载 Skill删除 ~/.openclaw/skills/<name>/ 目录) /// 卸载 Skill删除 ~/.openclaw/skills/<name>/ 目录)
#[tauri::command] #[tauri::command]
pub async fn skills_uninstall(name: String) -> Result<Value, String> { pub async fn skills_uninstall(name: String) -> Result<Value, String> {
@@ -915,30 +547,6 @@ fn scan_custom_skill_detail(name: &str) -> Option<Value> {
None None
} }
fn merge_local_skills(mut data: Value) -> Result<Value, String> {
let local_skills = scan_local_skill_entries()?;
let Some(skills) = data.get_mut("skills").and_then(|v| v.as_array_mut()) else {
return Ok(data);
};
let mut existing = HashSet::new();
for item in skills.iter() {
if let Some(name) = item.get("name").and_then(|v| v.as_str()) {
existing.insert(name.to_string());
}
}
for skill in local_skills {
if let Some(name) = skill.get("name").and_then(|v| v.as_str()) {
if existing.insert(name.to_string()) {
skills.push(skill);
}
}
}
Ok(data)
}
fn scan_local_skill_entries() -> Result<Vec<Value>, String> { fn scan_local_skill_entries() -> Result<Vec<Value>, String> {
let mut skills = Vec::new(); let mut skills = Vec::new();

View File

@@ -193,19 +193,17 @@ pub fn run() {
messaging::save_agent_binding, messaging::save_agent_binding,
messaging::delete_agent_binding, messaging::delete_agent_binding,
messaging::delete_agent_all_bindings, messaging::delete_agent_all_bindings,
// Skills 管理openclaw skills CLI // Skills 管理
skills::skills_list, skills::skills_list,
skills::skills_info, skills::skills_info,
skills::skills_check, skills::skills_check,
skills::skills_install_dep, skills::skills_install_dep,
skills::skills_skillhub_check,
skills::skills_skillhub_setup,
skills::skills_skillhub_search,
skills::skills_skillhub_install,
skills::skills_clawhub_search,
skills::skills_clawhub_install,
skills::skills_uninstall, skills::skills_uninstall,
skills::skills_validate, skills::skills_validate,
// SkillHub SDK内置 HTTP不依赖 CLI
skills::skillhub_search,
skills::skillhub_index,
skills::skillhub_install,
// 前端热更新 // 前端热更新
update::check_frontend_update, update::check_frontend_update,
update::download_frontend_update, update::download_frontend_update,

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json", "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
"productName": "ClawPanel", "productName": "ClawPanel",
"version": "0.11.4", "version": "0.11.5",
"identifier": "ai.openclaw.clawpanel", "identifier": "ai.openclaw.clawpanel",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",

View File

@@ -321,18 +321,16 @@ export const api = {
assistantWebSearch: (query, maxResults) => invoke('assistant_web_search', { query, max_results: maxResults || 5 }), assistantWebSearch: (query, maxResults) => invoke('assistant_web_search', { query, max_results: maxResults || 5 }),
assistantFetchUrl: (url) => invoke('assistant_fetch_url', { url }), assistantFetchUrl: (url) => invoke('assistant_fetch_url', { url }),
// Skills 管理openclaw skills CLI // Skills 管理
skillsList: () => invoke('skills_list'), skillsList: () => invoke('skills_list'),
skillsInfo: (name) => invoke('skills_info', { name }), skillsInfo: (name) => invoke('skills_info', { name }),
skillsCheck: () => invoke('skills_check'), skillsCheck: () => invoke('skills_check'),
skillsInstallDep: (kind, spec) => invoke('skills_install_dep', { kind, spec }), skillsInstallDep: (kind, spec) => invoke('skills_install_dep', { kind, spec }),
skillsSkillHubCheck: () => invoke('skills_skillhub_check'),
skillsSkillHubSetup: (cliOnly = true) => invoke('skills_skillhub_setup', { cliOnly }),
skillsSkillHubSearch: (query) => invoke('skills_skillhub_search', { query }),
skillsSkillHubInstall: (slug) => invoke('skills_skillhub_install', { slug }),
skillsClawHubSearch: (query) => invoke('skills_clawhub_search', { query }),
skillsClawHubInstall: (slug) => invoke('skills_clawhub_install', { slug }),
skillsUninstall: (name) => invoke('skills_uninstall', { name }), skillsUninstall: (name) => invoke('skills_uninstall', { name }),
// SkillHub SDK内置 HTTP不依赖 CLI
skillhubSearch: (query, limit) => invoke('skillhub_search', { query, limit }),
skillhubIndex: () => invoke('skillhub_index'),
skillhubInstall: (slug) => invoke('skillhub_install', { slug }),
// 实例管理 // 实例管理
instanceList: () => cachedInvoke('instance_list', {}, 10000), instanceList: () => cachedInvoke('instance_list', {}, 10000),

View File

@@ -72,8 +72,8 @@ export default {
toolProcessList: _('进程列表', 'Process list', '處理程序列表'), toolProcessList: _('进程列表', 'Process list', '處理程序列表'),
toolWebSearch: _('网页搜索', 'Web search', '網頁搜尋'), toolWebSearch: _('网页搜索', 'Web search', '網頁搜尋'),
toolWebSearchDesc: _('搜索网页获取信息', 'Search the web for information', '搜尋網頁取得資訊'), toolWebSearchDesc: _('搜索网页获取信息', 'Search the web for information', '搜尋網頁取得資訊'),
toolClawHubSearch: _('搜索 ClawHub', 'Search ClawHub', '搜尋 ClawHub'), toolSkillHubSearch: _('搜索 SkillHub', 'Search SkillHub', '搜尋 SkillHub'),
toolClawHubInstall: _('安装 Skill', 'Install Skill', '安裝 Skill'), toolSkillHubInstall: _('安装 Skill', 'Install Skill', '安裝 Skill'),
toolInstallDep: _('安装依赖', 'Install dependency', '安裝依赖'), toolInstallDep: _('安装依赖', 'Install dependency', '安裝依赖'),
toolFileOps: _('文件操作', 'File Operations', '檔案操作'), toolFileOps: _('文件操作', 'File Operations', '檔案操作'),
toolFileOpsDesc: _('读写文件和目录', 'Read/write files and directories', '讀写檔案和目錄'), toolFileOpsDesc: _('读写文件和目录', 'Read/write files and directories', '讀写檔案和目錄'),

View File

@@ -46,35 +46,17 @@ export default {
installFailed: _('安装失败', 'Install failed', '安裝失敗', 'インストール失敗', '설치 실패', 'Cài đặt thất bại', 'Error al instalar', 'Falha ao instalar', 'Ошибка установки', 'Échec de l\'installation', 'Installation fehlgeschlagen'), installFailed: _('安装失败', 'Install failed', '安裝失敗', 'インストール失敗', '설치 실패', 'Cài đặt thất bại', 'Error al instalar', 'Falha ao instalar', 'Ошибка установки', 'Échec de l\'installation', 'Installation fehlgeschlagen'),
searchPlaceholder: _('搜索技能,如 weather / github / tavily', 'Search skills, e.g. weather / github / tavily', '搜尋技能,如 weather / github / tavily'), searchPlaceholder: _('搜索技能,如 weather / github / tavily', 'Search skills, e.g. weather / github / tavily', '搜尋技能,如 weather / github / tavily'),
search: _('搜索', 'Search', '搜尋', 'Skills を検索...', 'Skills 검색...', 'Tìm kiếm Skills...', 'Buscar Skills...', 'Pesquisar Skills...', 'Поиск Skills...', 'Rechercher Skills...', 'Skills suchen...'), search: _('搜索', 'Search', '搜尋', 'Skills を検索...', 'Skills 검색...', 'Tìm kiếm Skills...', 'Buscar Skills...', 'Pesquisar Skills...', 'Поиск Skills...', 'Rechercher Skills...', 'Skills suchen...'),
installCLI: _('安装 CLI', 'Install CLI', '安裝 CLI'),
browse: _('浏览', 'Browse', '瀏覽'), browse: _('浏览', 'Browse', '瀏覽'),
searchEmpty: _('输入关键词搜索社区 Skills然后一键安装', 'Enter keywords to search community Skills, then install with one click', '輸入關鍵詞搜尋社區 Skills然後一鍵安裝'), storeLoading: _('正在加载技能商店...', 'Loading skill store...', '正在載入技能商店...', 'スキルストアを読み込み中...', '스킬 스토어 로딩 중...'),
searchKeyword: _('输入关键词搜索社区 Skills', 'Enter keywords to search community Skills', '輸入關鍵詞搜尋社區 Skills'), storeLoadFailed: _('技能商店加载失败', 'Failed to load skill store', '技能商店載入失敗'),
searching: _('正在搜索...', 'Searching...', '正在搜尋...', '検索中...', '검색 중...'), searching: _('正在搜索...', 'Searching...', '正在搜尋...', '検索中...', '검색 중...'),
noResults: _('没有找到匹配的 Skill', 'No matching Skills found', '沒有找到匹配的 Skill', '一致するスキルなし', '일치하는 스킬 없음', 'Không có kết quả', 'Sin resultados', 'Sem resultados', 'Ничего не найдено', 'Aucun résultat', 'Keine Ergebnisse'), noResults: _('没有找到匹配的 Skill', 'No matching Skills found', '沒有找到匹配的 Skill', '一致するスキルなし', '일치하는 스킬 없음', 'Không có kết quả', 'Sin resultados', 'Sem resultados', 'Ничего не найдено', 'Aucun résultat', 'Keine Ergebnisse'),
install: _('安装', 'Install', '安裝', 'インストール', '설치', 'Cài đặt', 'Instalar', 'Instalar', 'Установить', 'Installer', 'Installieren'), install: _('安装', 'Install', '安裝', 'インストール', '설치', 'Cài đặt', 'Instalar', 'Instalar', 'Установить', 'Installer', 'Installieren'),
installed: _('已安装', 'Installed', '已安裝', 'インストール済み', '설치됨', 'Đã cài', 'Instalados', 'Instalados', 'Установленные', 'Installés', 'Installiert'), installed: _('已安装', 'Installed', '已安裝', 'インストール済み', '설치됨', 'Đã cài', 'Instalados', 'Instalados', 'Установленные', 'Installés', 'Installiert'),
searchFailed: _('搜索失败', 'Search failed', '搜尋失敗'), searchFailed: _('搜索失败', 'Search failed', '搜尋失敗'),
rateLimited: _('⚠️ 请求频率超限', '⚠️ Rate limited', '⚠️ 請求頻率超限'),
rateLimitClawHub: _('ClawHub 海外源限流,建议切换到 SkillHub国内加速', 'ClawHub rate limited, try switching to SkillHub (China accelerated)', 'ClawHub 海外源限流,建議切換到 SkillHub國內加速'),
rateLimitRetry: _('请稍后再试', 'Please try again later', '請稍后再試'),
skillhubNeedCLI: _('⚠️ 请先安装 SkillHub CLI', '⚠️ Please install SkillHub CLI first', '⚠️ 請先安裝 SkillHub CLI'),
skillhubNeedCLIHint: _('点击上方「安装 CLI」按钮或切换到 ClawHub 源搜索', 'Click "Install CLI" above, or switch to ClawHub source', '点擊上方「安裝 CLI」按鈕或切換到 ClawHub 源搜尋'),
skillhubSetup: _('一键安装 SkillHub CLI', 'Install SkillHub CLI', '一鍵安裝 SkillHub CLI'),
skillhubInstalling: _('正在安装 SkillHub CLI...', 'Installing SkillHub CLI...', '正在安裝 SkillHub CLI...'),
skillhubInstalled: _('SkillHub CLI 安装成功', 'SkillHub CLI installed', 'SkillHub CLI 安裝成功'),
skillhubInstallFailed: _('SkillHub CLI 安装失败', 'SkillHub CLI installation failed', 'SkillHub CLI 安裝失敗'),
confirmUninstall: _('确定卸载 Skill「{name}」?', 'Uninstall Skill "{name}"?', '確定卸載 Skill「{name}」?', 'スキル「{name}」をアンインストールしますか?', '스킬「{name}」을 제거하시겠습니까?'), confirmUninstall: _('确定卸载 Skill「{name}」?', 'Uninstall Skill "{name}"?', '確定卸載 Skill「{name}」?', 'スキル「{name}」をアンインストールしますか?', '스킬「{name}」을 제거하시겠습니까?'),
uninstalling: _('卸载中...', 'Uninstalling...', '卸載中...', 'アンインストール中...', '제거 중...'), uninstalling: _('卸载中...', 'Uninstalling...', '卸載中...', 'アンインストール中...', '제거 중...'),
uninstalled: _('已卸载 {name}', 'Uninstalled {name}', '已卸載 {name}'), uninstalled: _('已卸载 {name}', 'Uninstalled {name}', '已卸載 {name}'),
uninstallFailed: _('卸载失败', 'Uninstall failed', '卸載失敗', 'アンインストール失敗', '제거 실패'), uninstallFailed: _('卸载失败', 'Uninstall failed', '卸載失敗', 'アンインストール失敗', '제거 실패'),
skillInstalled: _('Skill {name} 安装成功', 'Skill {name} installed', 'Skill {name} 安裝成功'), skillInstalled: _('Skill {name} 安装成功', 'Skill {name} installed', 'Skill {name} 安裝成功'),
sourceSkillHub: _('SkillHub国内加速', 'SkillHub (China accelerated)', 'SkillHub國內加速'),
sourceClawHub: _('ClawHub原版海外', 'ClawHub (Original overseas)'),
sourceLocalScanTimeout: _('CLI 可用,但本次调用超时,当前显示本地扫描结果', 'CLI available but timed out, showing local scan results', 'CLI 可用,但本次呼叫逾時,目前顯示本地掃描結果'),
sourceLocalScanParseFailed: _('CLI 可用,但返回结果解析失败,当前显示本地扫描结果', 'CLI available but output parse failed, showing local scan results', 'CLI 可用,但返回結果解析失敗,目前顯示本地掃描結果'),
sourceLocalScanExecFailed: _('CLI 调用失败,当前显示本地扫描结果', 'CLI execution failed, showing local scan results', 'CLI 呼叫失敗,目前顯示本地掃描結果'),
sourceLocalScan: _('当前显示本地扫描结果', 'Showing local scan results', '目前顯示本地掃描結果'),
sourceLocalScanNoCli: _('CLI 不可用,当前显示本地扫描结果', 'CLI not available, showing local scan results', 'CLI 不可用,目前顯示本地掃描結果'),
sourceCLI: _('当前已使用 OpenClaw CLI 结果', 'Using OpenClaw CLI results', '目前已使用 OpenClaw CLI 結果'),
} }

View File

@@ -153,7 +153,7 @@ ${personality}
- openclaw skills info <name> — 查看某个 Skill 详情 - openclaw skills info <name> — 查看某个 Skill 详情
- openclaw skills check — 检查所有 Skills 的依赖是否满足 - openclaw skills check — 检查所有 Skills 的依赖是否满足
- Skill 依赖安装: 根据 install spec 执行 brew/npm/go/uv 安装缺少的命令行工具 - Skill 依赖安装: 根据 install spec 执行 brew/npm/go/uv 安装缺少的命令行工具
- ClawHub (clawhub.com): 社区 Skill 市场,可搜索和安装新 Skill - SkillHub: 技能商店,可搜索和安装新 Skill内置 HTTP不依赖 CLI
- Skills 目录: 捆绑 Skills 在 openclaw 安装包内,自定义 Skills 通常位于 ~/.openclaw/skills/<name>/ 或 ~/.claude/skills/<name>/ - Skills 目录: 捆绑 Skills 在 openclaw 安装包内,自定义 Skills 通常位于 ~/.openclaw/skills/<name>/ 或 ~/.claude/skills/<name>/
### 聊天与调试 ### 聊天与调试
@@ -432,8 +432,8 @@ const TOOL_DEFS = {
{ {
type: 'function', type: 'function',
function: { function: {
name: 'skills_clawhub_search', name: 'skillhub_search',
description: '在 ClawHub 社区市场中搜索 Skills。返回匹配的 Skill 列表slug 和描述)。', description: '在 SkillHub 技能商店中搜索 Skills。返回匹配的 Skill 列表slug 和描述)。',
parameters: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
@@ -446,12 +446,12 @@ const TOOL_DEFS = {
{ {
type: 'function', type: 'function',
function: { function: {
name: 'skills_clawhub_install', name: 'skillhub_install',
description: '从 ClawHub 社区市场安装一个 Skill 到本地自定义 Skills 目录(通常为 ~/.openclaw/skills/ 或 ~/.claude/skills/)。', description: '从 SkillHub 技能商店安装一个 Skill 到本地自定义 Skills 目录(通常为 ~/.openclaw/skills/ 或 ~/.claude/skills/)。',
parameters: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
slug: { type: 'string', description: 'ClawHub 上的 Skill slug名称标识' }, slug: { type: 'string', description: 'SkillHub 上的 Skill slug名称标识' },
}, },
required: ['slug'], required: ['slug'],
}, },
@@ -507,7 +507,7 @@ const TOOL_DEFS = {
// 危险工具(需要用户确认) // 危险工具(需要用户确认)
const INTERACTIVE_TOOLS = new Set(['ask_user']) // 交互式工具,不走 confirmToolCall const INTERACTIVE_TOOLS = new Set(['ask_user']) // 交互式工具,不走 confirmToolCall
const DANGEROUS_TOOLS = new Set(['run_command', 'write_file', 'skills_install_dep', 'skills_clawhub_install']) const DANGEROUS_TOOLS = new Set(['run_command', 'write_file', 'skills_install_dep', 'skillhub_install'])
// 安全围栏:极端危险命令模式(任何模式都必须确认,包括无限模式) // 安全围栏:极端危险命令模式(任何模式都必须确认,包括无限模式)
const CRITICAL_PATTERNS = [ const CRITICAL_PATTERNS = [
@@ -710,7 +710,7 @@ const BUILTIN_SKILLS = [
注意: 注意:
- 安装依赖可能需要特定的包管理器brew 仅限 macOSWindows 用 npm/go 等) - 安装依赖可能需要特定的包管理器brew 仅限 macOSWindows 用 npm/go 等)
- 先调用 get_system_info 判断操作系统,过滤出适合当前平台的安装选项 - 先调用 get_system_info 判断操作系统,过滤出适合当前平台的安装选项
- 如果用户想从 ClawHub 搜索安装新 Skill使用 skills_clawhub_search 和 skills_clawhub_install`, - 如果用户想从 SkillHub 搜索安装新 Skill使用 skillhub_search 和 skillhub_install`,
}, },
] ]
@@ -742,7 +742,7 @@ function getEnabledTools() {
// Skills 管理工具:始终启用(规划模式下排除安装操作) // Skills 管理工具:始终启用(规划模式下排除安装操作)
if (mode.readOnly) { if (mode.readOnly) {
tools.push(...TOOL_DEFS.skills.filter(td => !['skills_install_dep', 'skills_clawhub_install'].includes(td.function.name))) tools.push(...TOOL_DEFS.skills.filter(td => !['skills_install_dep', 'skillhub_install'].includes(td.function.name)))
} else { } else {
tools.push(...TOOL_DEFS.skills) tools.push(...TOOL_DEFS.skills)
} }
@@ -1880,14 +1880,14 @@ async function executeTool(name, args) {
const result = await api.skillsInstallDep(args.kind, args.spec) const result = await api.skillsInstallDep(args.kind, args.spec)
return result?.success ? `${t('assistant.toolInstallSuccess')}\n${result.output || ''}` : t('assistant.toolInstallFail') return result?.success ? `${t('assistant.toolInstallSuccess')}\n${result.output || ''}` : t('assistant.toolInstallFail')
} }
case 'skills_clawhub_search': { case 'skillhub_search': {
const items = await api.skillsClawHubSearch(args.query) const items = await api.skillhubSearch(args.query)
if (!items?.length) return t('assistant.toolNoSkillFound') if (!items?.length) return t('assistant.toolNoSkillFound')
return items.map(i => `- **${i.slug}**: ${i.description || t('assistant.toolNoDesc')}`).join('\n') return items.map(i => `- **${i.slug}**: ${i.description || i.summary || t('assistant.toolNoDesc')}`).join('\n')
} }
case 'skills_clawhub_install': { case 'skillhub_install': {
const result = await api.skillsClawHubInstall(args.slug) const result = await api.skillhubInstall(args.slug)
return result?.success ? `Skill "${args.slug}" ${t('assistant.toolInstallSuccess')}\n${result.output || ''}` : t('assistant.toolInstallFail') return result?.success ? `Skill "${args.slug}" ${t('assistant.toolInstallSuccess')}\n${result.path || ''}` : t('assistant.toolInstallFail')
} }
default: default:
return `${t('assistant.toolUnknown')}: ${name}` return `${t('assistant.toolUnknown')}: ${name}`
@@ -2275,8 +2275,8 @@ function renderToolBlocks(toolHistory) {
// ask_user 工具不显示在工具块中(它有自己的交互卡片) // ask_user 工具不显示在工具块中(它有自己的交互卡片)
if (tc.name === 'ask_user') return '' 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), 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 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), skillhub_search: icon('search', 14), skillhub_install: icon('download', 14) }[tc.name] || icon('wrench', 14)
const label = { run_command: t('assistant.toolRunCmd'), read_file: t('assistant.toolReadFile'), write_file: t('assistant.toolWriteFile'), list_directory: t('assistant.toolListDir'), get_system_info: t('assistant.toolSysInfo'), list_processes: t('assistant.toolProcessList'), check_port: t('assistant.toolCheckPort'), skills_list: t('assistant.toolSkillsList'), skills_info: t('assistant.toolSkillInfo'), skills_check: t('assistant.toolSkillsCheck'), skills_install_dep: t('assistant.toolInstallDep'), skills_clawhub_search: t('assistant.toolClawHubSearch'), skills_clawhub_install: t('assistant.toolClawHubInstall') }[tc.name] || tc.name const label = { run_command: t('assistant.toolRunCmd'), read_file: t('assistant.toolReadFile'), write_file: t('assistant.toolWriteFile'), list_directory: t('assistant.toolListDir'), get_system_info: t('assistant.toolSysInfo'), list_processes: t('assistant.toolProcessList'), check_port: t('assistant.toolCheckPort'), skills_list: t('assistant.toolSkillsList'), skills_info: t('assistant.toolSkillInfo'), skills_check: t('assistant.toolSkillsCheck'), skills_install_dep: t('assistant.toolInstallDep'), skillhub_search: t('assistant.toolSkillHubSearch'), skillhub_install: t('assistant.toolSkillHubInstall') }[tc.name] || tc.name
const argsStr = tc.name === 'run_command' ? escHtml(tc.args.command || '') const argsStr = tc.name === 'run_command' ? escHtml(tc.args.command || '')
: tc.name === 'read_file' ? escHtml(tc.args.path || '') : tc.name === 'read_file' ? escHtml(tc.args.path || '')
: tc.name === 'write_file' ? escHtml(tc.args.path || '') : tc.name === 'write_file' ? escHtml(tc.args.path || '')
@@ -2286,8 +2286,8 @@ function renderToolBlocks(toolHistory) {
: tc.name === 'check_port' ? escHtml(String(tc.args.port || '')) : tc.name === 'check_port' ? escHtml(String(tc.args.port || ''))
: tc.name === 'skills_info' ? escHtml(tc.args.name || '') : 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_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 === 'skillhub_search' ? escHtml(tc.args.query || '')
: tc.name === 'skills_clawhub_install' ? escHtml(tc.args.slug || '') : tc.name === 'skillhub_install' ? escHtml(tc.args.slug || '')
: ['skills_list', 'skills_check'].includes(tc.name) ? '' : ['skills_list', 'skills_check'].includes(tc.name) ? ''
: escHtml(JSON.stringify(tc.args)) : escHtml(JSON.stringify(tc.args))

View File

@@ -1,6 +1,6 @@
/** /**
* Skills 页面 * Skills 页面
* 基于 openclaw skills CLI按状态分组展示所有 Skills * 本地扫描已安装 Skills + SkillHub SDK 技能商店
*/ */
import { api } from '../lib/tauri-api.js' import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js' import { toast } from '../components/toast.js'
@@ -30,20 +30,12 @@ export async function render() {
</div> </div>
<div id="skills-tab-store" class="config-section" style="display:none"> <div id="skills-tab-store" class="config-section" style="display:none">
<div class="clawhub-toolbar" style="margin-bottom:var(--space-sm)"> <div class="clawhub-toolbar" style="margin-bottom:var(--space-sm)">
<select class="form-input" id="install-source-select" style="width:auto;min-width:160px"> <input class="input clawhub-search-input" id="skill-store-search" placeholder="${t('skills.searchPlaceholder')}" type="text" style="flex:1">
<option value="skillhub">${t('skills.sourceSkillHub')}</option> <button class="btn btn-primary btn-sm" data-action="store-search">${t('skills.search')}</button>
<option value="clawhub">${t('skills.sourceClawHub')}</option> <a class="btn btn-secondary btn-sm" href="https://skillhub.tencent.com" target="_blank" rel="noopener">${t('skills.browse')}</a>
</select>
<input class="input clawhub-search-input" id="skill-install-search" placeholder="${t('skills.searchPlaceholder')}" type="text" style="flex:1">
<button class="btn btn-primary btn-sm" data-action="install-source-search">${t('skills.search')}</button>
<button class="btn btn-secondary btn-sm" data-action="skillhub-setup" id="btn-skillhub-setup" style="display:none">${t('skills.installCLI')}</button>
<a class="btn btn-secondary btn-sm" id="btn-browse-source" href="https://skillhub.tencent.com" target="_blank" rel="noopener">${t('skills.browse')}</a>
</div> </div>
<div class="form-hint" id="store-hint" style="margin-bottom:var(--space-sm);display:flex;align-items:center;gap:var(--space-xs)"> <div id="store-results" class="clawhub-list" style="max-height:calc(100vh - 300px);overflow-y:auto">
<span id="skillhub-status"></span> <div class="form-hint" style="padding:var(--space-xl);text-align:center">${t('skills.storeLoading')}</div>
</div>
<div id="install-source-results" class="clawhub-list" style="max-height:calc(100vh - 320px);overflow-y:auto">
<div class="clawhub-empty" style="padding:var(--space-xl);text-align:center">${t('skills.searchEmpty')}</div>
</div> </div>
</div> </div>
` `
@@ -87,22 +79,11 @@ function renderSkills(el, data) {
const blocked = skills.filter(s => s.blockedByAllowlist && !s.disabled) const blocked = skills.filter(s => s.blockedByAllowlist && !s.disabled)
const summary = t('skills.summaryDetail', { eligible: eligible.length, missing: missing.length, disabled: disabled.length }) const summary = t('skills.summaryDetail', { eligible: eligible.length, missing: missing.length, disabled: disabled.length })
let sourceHint = ''
if (source === 'local-scan') {
if (cliDiag?.status === 'timeout') sourceHint = t('skills.sourceLocalScanTimeout')
else if (cliDiag?.status === 'parse-failed') sourceHint = t('skills.sourceLocalScanParseFailed')
else if (cliDiag?.status === 'exec-failed') sourceHint = t('skills.sourceLocalScanExecFailed')
else sourceHint = cliAvailable ? t('skills.sourceLocalScan') : t('skills.sourceLocalScanNoCli')
} else if (cliAvailable) {
sourceHint = t('skills.sourceCLI')
}
el.innerHTML = ` el.innerHTML = `
<div class="clawhub-toolbar"> <div class="clawhub-toolbar">
<input class="input clawhub-search-input" id="skill-filter-input" placeholder="${t('skills.filterPlaceholder')}" type="text"> <input class="input clawhub-search-input" id="skill-filter-input" placeholder="${t('skills.filterPlaceholder')}" type="text">
<button class="btn btn-secondary btn-sm" data-action="skill-retry">${t('skills.refresh')}</button> <button class="btn btn-secondary btn-sm" data-action="skill-retry">${t('skills.refresh')}</button>
<a class="btn btn-secondary btn-sm" href="https://clawhub.ai/skills" target="_blank" rel="noopener">ClawHub</a>
${sourceHint ? `<span class="form-hint" style="margin-left:auto;color:${source === 'local-scan' ? 'var(--warning)' : 'var(--text-tertiary)'}">${esc(sourceHint)}</span>` : ''}
</div> </div>
<div class="skills-summary" style="margin-bottom:var(--space-lg);color:var(--text-secondary);font-size:var(--font-size-sm)"> <div class="skills-summary" style="margin-bottom:var(--space-lg);color:var(--text-secondary);font-size:var(--font-size-sm)">
@@ -279,76 +260,98 @@ async function handleInstallDep(page, btn) {
} }
} }
// ===== 统一源搜索/安装系统 ===== // ===== 技能商店SkillHub SDK=====
let _installSource = 'skillhub' // 当前选中的安装源 let _storeIndex = null // 缓存的全量索引
let _skillhubInstalled = false // SkillHub CLI 是否已安装 let _installedNames = new Set() // 已安装的 skill 名称
function getInstallSource() { return _installSource } async function loadStore(page) {
const results = page.querySelector('#store-results')
async function handleSourceSearch(page) { if (!results) return
const input = page.querySelector('#skill-install-search') results.innerHTML = `<div class="form-hint" style="padding:var(--space-xl);text-align:center">${t('skills.storeLoading')}</div>`
const results = page.querySelector('#install-source-results')
if (!input || !results) return
const q = input.value.trim()
if (!q) { results.innerHTML = `<div class="clawhub-empty">${t('skills.searchKeyword')}</div>`; return }
const source = getInstallSource()
// SkillHub 未安装时友好提示(先实时检测一次,避免竞态误判)
if (source === 'skillhub' && !_skillhubInstalled) {
try {
const info = await api.skillsSkillHubCheck()
_skillhubInstalled = !!info.installed
} catch { /* ignore */ }
}
if (source === 'skillhub' && !_skillhubInstalled) {
results.innerHTML = `<div style="padding:var(--space-lg);text-align:center">
<div style="color:var(--warning);margin-bottom:8px">${t('skills.skillhubNeedCLI')}</div>
<div class="form-hint" style="margin-bottom:12px">${t('skills.skillhubNeedCLIHint')}</div>
<button class="btn btn-primary btn-sm" data-action="skillhub-setup">${t('skills.skillhubSetup')}</button>
</div>`
return
}
results.innerHTML = `<div class="form-hint">${t('skills.searching')}</div>`
try { try {
const items = source === 'skillhub' ? await api.skillsSkillHubSearch(q) : await api.skillsClawHubSearch(q) _storeIndex = await api.skillhubIndex()
if (!items?.length) { results.innerHTML = `<div class="clawhub-empty">${t('skills.noResults')}</div>`; return } // 获取已安装列表用于标记
const installAction = source === 'skillhub' ? 'source-install-skillhub' : 'source-install-clawhub' try {
results.innerHTML = items.map(item => ` const data = await api.skillsList()
<div class="clawhub-item"> _installedNames = new Set((data?.skills || []).map(s => s.name))
<div class="clawhub-item-main"> } catch { _installedNames = new Set() }
<div class="clawhub-item-title">${esc(item.slug || item.name || '')}</div> renderStoreItems(results, _storeIndex)
<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="${installAction}" data-slug="${esc(item.slug || item.name || '')}">${t('skills.install')}</button>
</div>
</div>
`).join('')
} catch (e) { } catch (e) {
const errMsg = String(e?.message || e) results.innerHTML = `<div style="color:var(--error);padding:var(--space-lg);text-align:center">${t('skills.storeLoadFailed')}: ${esc(e?.message || e)}</div>`
const isRateLimit = /rate.?limit|429|too many/i.test(errMsg)
if (isRateLimit) {
results.innerHTML = `<div style="padding:var(--space-lg);text-align:center">
<div style="color:var(--warning);margin-bottom:8px">${t('skills.rateLimited')}</div>
<div class="form-hint">${source === 'clawhub' ? t('skills.rateLimitClawHub') : t('skills.rateLimitRetry')}</div>
</div>`
} else {
results.innerHTML = `<div style="color:var(--error);padding:var(--space-sm)">${t('skills.searchFailed')}: ${esc(errMsg)}</div>`
}
} }
} }
async function handleSourceInstall(page, btn, source) { function renderStoreItems(el, items) {
if (!items?.length) {
el.innerHTML = `<div class="clawhub-empty" style="padding:var(--space-xl);text-align:center">${t('skills.noResults')}</div>`
return
}
el.innerHTML = items.map(item => {
const slug = item.slug || ''
const name = item.display_name || item.displayName || item.name || slug
const desc = item.summary || item.description || ''
const installed = _installedNames.has(slug)
return `
<div class="clawhub-item store-item" data-slug="${esc(slug)}" data-name="${esc(name)}" data-desc="${esc(desc)}">
<div class="clawhub-item-main">
<div class="clawhub-item-title">📦 ${esc(name)}</div>
<div class="clawhub-item-desc">${esc(desc)}</div>
${item.version ? `<div class="clawhub-item-meta">v${esc(item.version)}${item.author ? ` · ${esc(item.author)}` : ''}</div>` : ''}
</div>
<div class="clawhub-item-actions">
${installed
? `<span class="clawhub-badge installed">${t('skills.installed')}</span>`
: `<button class="btn btn-primary btn-sm" data-action="store-install" data-slug="${esc(slug)}">${t('skills.install')}</button>`
}
</div>
</div>
`
}).join('')
}
async function handleStoreSearch(page) {
const input = page.querySelector('#skill-store-search')
const results = page.querySelector('#store-results')
if (!input || !results) return
const q = input.value.trim().toLowerCase()
if (!q && _storeIndex) {
renderStoreItems(results, _storeIndex)
return
}
if (!q) return
// 客户端过滤已有索引
if (_storeIndex) {
const filtered = _storeIndex.filter(item => {
const slug = (item.slug || '').toLowerCase()
const name = (item.display_name || item.displayName || '').toLowerCase()
const desc = (item.summary || item.description || '').toLowerCase()
const tags = (item.tags || []).join(' ').toLowerCase()
return slug.includes(q) || name.includes(q) || desc.includes(q) || tags.includes(q)
})
renderStoreItems(results, filtered)
return
}
// 没有索引时走服务端搜索
results.innerHTML = `<div class="form-hint" style="padding:var(--space-sm)">${t('skills.searching')}</div>`
try {
const items = await api.skillhubSearch(input.value.trim())
renderStoreItems(results, items)
} catch (e) {
results.innerHTML = `<div style="color:var(--error);padding:var(--space-sm)">${t('skills.searchFailed')}: ${esc(e?.message || e)}</div>`
}
}
async function handleStoreInstall(page, btn) {
const slug = btn.dataset.slug const slug = btn.dataset.slug
btn.disabled = true btn.disabled = true
btn.textContent = t('skills.installing') btn.textContent = t('skills.installing')
try { try {
if (source === 'skillhub') await api.skillsSkillHubInstall(slug) await api.skillhubInstall(slug)
else await api.skillsClawHubInstall(slug)
toast(t('skills.skillInstalled', { name: slug }), 'success') toast(t('skills.skillInstalled', { name: slug }), 'success')
btn.textContent = t('skills.installed') btn.textContent = t('skills.installed')
btn.classList.remove('btn-primary') btn.classList.remove('btn-primary')
btn.classList.add('btn-secondary') btn.classList.add('btn-secondary')
// 后台刷新已安装列表(不阻塞 UI _installedNames.add(slug)
loadSkills(page).catch(() => {}) loadSkills(page).catch(() => {})
} catch (e) { } catch (e) {
toast(`${t('skills.installFailed')}: ${e?.message || e}`, 'error') toast(`${t('skills.installFailed')}: ${e?.message || e}`, 'error')
@@ -374,57 +377,6 @@ async function handleSkillUninstall(page, btn) {
} }
} }
async function handleSkillHubSetup(page) {
const statusEl = page.querySelector('#skillhub-status')
if (statusEl) statusEl.textContent = t('skills.skillhubInstalling')
try {
await api.skillsSkillHubSetup(true)
_skillhubInstalled = true
toast(t('skills.skillhubInstalled'), 'success')
if (statusEl) statusEl.textContent = '✅'
// 隐藏安装按钮
const setupBtn = page.querySelector('#btn-skillhub-setup')
if (setupBtn) setupBtn.style.display = 'none'
} catch (e) {
toast(`${t('skills.skillhubInstallFailed')}: ${e?.message || e}`, 'error')
if (statusEl) statusEl.textContent = '❌'
}
}
async function checkSkillHubStatus(page) {
const statusEl = page.querySelector('#skillhub-status')
const setupBtn = page.querySelector('#btn-skillhub-setup')
if (!statusEl) return
try {
const info = await api.skillsSkillHubCheck()
_skillhubInstalled = !!info.installed
if (info.installed) {
statusEl.innerHTML = `<span style="color:var(--success)">✅ v${info.version}</span>`
if (setupBtn) setupBtn.style.display = 'none'
} else {
statusEl.innerHTML = `<span style="color:var(--warning)">${t('skills.skillhubNeedCLI')}</span>`
if (setupBtn && _installSource === 'skillhub') setupBtn.style.display = ''
}
} catch {
statusEl.textContent = ''
}
}
function switchInstallSource(page, source) {
_installSource = source
const results = page.querySelector('#install-source-results')
const setupBtn = page.querySelector('#btn-skillhub-setup')
const browseBtn = page.querySelector('#btn-browse-source')
if (results) results.innerHTML = `<div class="clawhub-empty">${t('skills.searchKeyword')}</div>`
if (source === 'skillhub') {
if (browseBtn) browseBtn.href = 'https://skillhub.tencent.com'
checkSkillHubStatus(page)
} else {
if (setupBtn) setupBtn.style.display = 'none'
if (browseBtn) browseBtn.href = 'https://clawhub.ai/skills'
}
}
function bindEvents(page) { function bindEvents(page) {
// 主 Tab 切换(已安装 / 搜索安装) // 主 Tab 切换(已安装 / 搜索安装)
page.querySelectorAll('#skills-main-tabs .tab').forEach(tab => { page.querySelectorAll('#skills-main-tabs .tab').forEach(tab => {
@@ -434,17 +386,11 @@ function bindEvents(page) {
const key = tab.dataset.mainTab const key = tab.dataset.mainTab
page.querySelector('#skills-tab-installed').style.display = key === 'installed' ? '' : 'none' page.querySelector('#skills-tab-installed').style.display = key === 'installed' ? '' : 'none'
page.querySelector('#skills-tab-store').style.display = key === 'store' ? '' : 'none' page.querySelector('#skills-tab-store').style.display = key === 'store' ? '' : 'none'
// 切到商店 tab 时检测 SkillHub 状态 // 切到商店 tab 时加载全量索引
if (key === 'store') checkSkillHubStatus(page) if (key === 'store') loadStore(page)
} }
}) })
// 安装源下拉切换
const sourceSelect = page.querySelector('#install-source-select')
if (sourceSelect) {
sourceSelect.onchange = () => switchInstallSource(page, sourceSelect.value)
}
page.addEventListener('click', async (e) => { page.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]') const btn = e.target.closest('[data-action]')
if (!btn) return if (!btn) return
@@ -458,17 +404,11 @@ function bindEvents(page) {
case 'skill-install-dep': case 'skill-install-dep':
await handleInstallDep(page, btn) await handleInstallDep(page, btn)
break break
case 'install-source-search': case 'store-search':
await handleSourceSearch(page) await handleStoreSearch(page)
break break
case 'source-install-skillhub': case 'store-install':
await handleSourceInstall(page, btn, 'skillhub') await handleStoreInstall(page, btn)
break
case 'source-install-clawhub':
await handleSourceInstall(page, btn, 'clawhub')
break
case 'skillhub-setup':
await handleSkillHubSetup(page)
break break
case 'skill-uninstall': case 'skill-uninstall':
await handleSkillUninstall(page, btn) await handleSkillUninstall(page, btn)
@@ -484,9 +424,9 @@ function bindEvents(page) {
}) })
page.addEventListener('keydown', async (e) => { page.addEventListener('keydown', async (e) => {
if (e.key === 'Enter' && e.target?.id === 'skill-install-search') { if (e.key === 'Enter' && e.target?.id === 'skill-store-search') {
e.preventDefault() e.preventDefault()
await handleSourceSearch(page) await handleStoreSearch(page)
} }
}) })
} }

View File

@@ -493,100 +493,6 @@
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }
.skills-hero-panel {
position: relative;
overflow: hidden;
}
.skills-hero-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-md);
}
.skill-hero-card {
position: relative;
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: var(--space-lg);
background:
radial-gradient(circle at top right, rgba(99, 102, 241, 0.12), transparent 32%),
linear-gradient(180deg, rgba(255,255,255,0.02), transparent),
var(--bg-card);
box-shadow: 0 12px 30px rgba(0,0,0,0.12);
}
.skill-hero-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-md);
margin-bottom: var(--space-md);
}
.skill-hero-title {
font-size: var(--font-size-lg);
font-weight: 700;
margin-bottom: 4px;
}
.skill-hero-meta {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
.skill-hero-badges {
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
justify-content: flex-end;
}
.clawhub-badge.hot {
background: rgba(99, 102, 241, 0.14);
color: #6366f1;
}
.skill-hero-desc {
color: var(--text-secondary);
line-height: 1.6;
min-height: 48px;
}
.skill-hero-actions {
margin-top: var(--space-lg);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
flex-wrap: wrap;
}
.skill-hero-installed {
font-size: var(--font-size-sm);
color: var(--text-tertiary);
}
.skills-tips-panel {
background: linear-gradient(180deg, rgba(99, 102, 241, 0.06), transparent), var(--bg-secondary);
}
.skills-tip-list {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.skills-tip-item {
color: var(--text-secondary);
line-height: 1.7;
padding: var(--space-sm) 0;
border-bottom: 1px dashed var(--border-secondary);
}
.skills-tip-item:last-child {
border-bottom: none;
}
.skills-loading-panel, .skills-loading-panel,
.skills-load-error { .skills-load-error {
@@ -597,8 +503,7 @@
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.clawhub-grid, .clawhub-grid {
.skills-hero-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }