mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-06 20:02:49 +08:00
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:
17
CHANGELOG.md
17
CHANGELOG.md
@@ -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 SDK(Rust + 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)
|
||||||
|
|||||||
559
docs/dev/skills-refactor-plan.md
Normal file
559
docs/dev/skills-refactor-plan.md
Normal 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>
|
||||||
|
|
||||||
|
/// 下载并安装 Skill(zip → 解压到 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载 zip(COS 优先,回退主站)
|
||||||
|
* @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 1:Rust 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 2:Node.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 5:i18n + 清理 + 验证
|
||||||
|
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-10s(CLI 冷启动) | **<1s**(HTTP API) |
|
||||||
|
| 安装延迟 | ~5-15s(CLI 调用) | **~2-5s**(直接下载 zip) |
|
||||||
|
| 前端代码复杂度 | 492 行(含双源切换/CLI 检测) | ~300 行(统一 UI) |
|
||||||
|
| 后端 CLI 调用 | 8 个命令依赖外部 CLI | **0 个** |
|
||||||
|
| 用户认知负担 | 安装源选择 + CLI 状态 | 搜索框 + 安装按钮 |
|
||||||
|
| Web/Docker 端 | CLI 经常找不到或权限问题 | **内置 HTTP,与桌面端体验一致** |
|
||||||
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
216
scripts/lib/skillhub-sdk.js
Normal 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 zip(COS 镜像优先,回退主站 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载并安装 Skill:zip → 解压到 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
2
src-tauri/Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
260
src-tauri/src/commands/skillhub.rs
Normal file
260
src-tauri/src/commands/skillhub.rs
Normal 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 zip(COS 镜像优先,回退主站 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}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载并安装 Skill:zip → 解压到 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
|
||||||
|
}
|
||||||
@@ -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 安装 Skill(skillhub 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 搜索 Skills(skillhub 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();
|
|
||||||
// 找序号行:以 [数字] 开头,同一行包含 slug(owner/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 搜索 Skills(npx clawhub search <query>)— 原版海外源
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn skills_clawhub_search(query: String) -> Result<Value, String> {
|
|
||||||
let q = query.trim().to_string();
|
|
||||||
if q.is_empty() {
|
|
||||||
return Ok(Value::Array(vec![]));
|
|
||||||
}
|
|
||||||
let path_env = super::enhanced_path();
|
|
||||||
#[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 安装 Skill(npx clawhub install <slug>)— 原版海外源
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn skills_clawhub_install(slug: String) -> Result<Value, String> {
|
|
||||||
let path_env = super::enhanced_path();
|
|
||||||
let home = dirs::home_dir().unwrap_or_default();
|
|
||||||
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();
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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', '讀写檔案和目錄'),
|
||||||
|
|||||||
@@ -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 結果'),
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 仅限 macOS,Windows 用 npm/go 等)
|
- 安装依赖可能需要特定的包管理器(brew 仅限 macOS,Windows 用 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))
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user