mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-11 18:10:41 +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:
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -351,7 +351,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clawpanel"
|
||||
version = "0.11.4"
|
||||
version = "0.11.5"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "clawpanel"
|
||||
version = "0.11.4"
|
||||
version = "0.11.5"
|
||||
edition = "2021"
|
||||
description = "ClawPanel - OpenClaw 可视化管理面板"
|
||||
authors = ["qingchencloud"]
|
||||
|
||||
@@ -23,6 +23,7 @@ pub mod memory;
|
||||
pub mod messaging;
|
||||
pub mod pairing;
|
||||
pub mod service;
|
||||
pub mod skillhub;
|
||||
pub mod skills;
|
||||
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 std::collections::HashSet;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[allow(unused_imports)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
/// 列出所有 Skills 及其状态(openclaw skills list --json)
|
||||
/// 列出所有 Skills 及其状态(纯本地扫描,不依赖 CLI)
|
||||
#[tauri::command]
|
||||
pub async fn skills_list() -> Result<Value, String> {
|
||||
let output = tokio::time::timeout(
|
||||
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,
|
||||
}))),
|
||||
}
|
||||
scan_local_skills(None)
|
||||
}
|
||||
|
||||
/// 查看单个 Skill 详情(openclaw skills info <name> --json)
|
||||
/// 查看单个 Skill 详情(纯本地文件解析,不依赖 CLI)
|
||||
#[tauri::command]
|
||||
pub async fn skills_info(name: String) -> Result<Value, String> {
|
||||
let output = openclaw_command_async()
|
||||
.args(["skills", "info", &name, "--json"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("执行 openclaw 失败: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
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)
|
||||
scan_custom_skill_detail(&name)
|
||||
.ok_or_else(|| format!("Skill「{name}」不存在"))
|
||||
}
|
||||
|
||||
/// 检查 Skills 依赖状态(openclaw skills check --json)
|
||||
/// 检查 Skills 依赖状态(纯本地扫描)
|
||||
#[tauri::command]
|
||||
pub async fn skills_check() -> Result<Value, String> {
|
||||
let output = openclaw_command_async()
|
||||
.args(["skills", "check", "--json"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("执行 openclaw 失败: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("检查失败: {}", stderr.trim()));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
extract_json(&stdout).ok_or_else(|| "解析失败: 输出中未找到有效 JSON".to_string())
|
||||
let skills = scan_local_skill_entries()?;
|
||||
let total = skills.len();
|
||||
let ready = skills.iter().filter(|s| s.get("eligible").and_then(|v| v.as_bool()).unwrap_or(false)).count();
|
||||
let missing = total - ready;
|
||||
Ok(serde_json::json!({
|
||||
"total": total,
|
||||
"ready": ready,
|
||||
"missingDeps": missing,
|
||||
"skills": skills,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 安装 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]
|
||||
pub async fn skills_skillhub_check() -> Result<Value, String> {
|
||||
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", "--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 })),
|
||||
}
|
||||
pub async fn skillhub_search(query: String, limit: Option<u32>) -> Result<Value, String> {
|
||||
let items = super::skillhub::search(&query, limit.unwrap_or(20)).await?;
|
||||
Ok(serde_json::to_value(items).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// 安装 SkillHub CLI(从腾讯云 COS 下载)
|
||||
/// 获取全量技能索引(COS CDN,带内存缓存)
|
||||
#[tauri::command]
|
||||
pub async fn skills_skillhub_setup(cli_only: bool) -> Result<Value, String> {
|
||||
let path_env = super::enhanced_path();
|
||||
#[allow(unused_variables)]
|
||||
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() }))
|
||||
}
|
||||
pub async fn skillhub_index() -> Result<Value, String> {
|
||||
let items = super::skillhub::fetch_index().await?;
|
||||
Ok(serde_json::to_value(items).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// 从 SkillHub 安装 Skill(skillhub install <slug>)
|
||||
/// 从 SkillHub 安装 Skill(内置 HTTP 下载 + zip 解压)
|
||||
#[tauri::command]
|
||||
pub async fn skills_skillhub_install(slug: String) -> Result<Value, String> {
|
||||
let path_env = super::enhanced_path();
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
|
||||
pub async fn skillhub_install(slug: String) -> Result<Value, String> {
|
||||
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", "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()));
|
||||
}
|
||||
|
||||
let installed_path = super::skillhub::install(&slug, &skills_dir).await?;
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"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>/ 目录)
|
||||
#[tauri::command]
|
||||
pub async fn skills_uninstall(name: String) -> Result<Value, String> {
|
||||
@@ -915,30 +547,6 @@ fn scan_custom_skill_detail(name: &str) -> Option<Value> {
|
||||
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> {
|
||||
let mut skills = Vec::new();
|
||||
|
||||
|
||||
@@ -193,19 +193,17 @@ pub fn run() {
|
||||
messaging::save_agent_binding,
|
||||
messaging::delete_agent_binding,
|
||||
messaging::delete_agent_all_bindings,
|
||||
// Skills 管理(openclaw skills CLI)
|
||||
// Skills 管理
|
||||
skills::skills_list,
|
||||
skills::skills_info,
|
||||
skills::skills_check,
|
||||
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_validate,
|
||||
// SkillHub SDK(内置 HTTP,不依赖 CLI)
|
||||
skills::skillhub_search,
|
||||
skills::skillhub_index,
|
||||
skills::skillhub_install,
|
||||
// 前端热更新
|
||||
update::check_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",
|
||||
"productName": "ClawPanel",
|
||||
"version": "0.11.4",
|
||||
"version": "0.11.5",
|
||||
"identifier": "ai.openclaw.clawpanel",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
Reference in New Issue
Block a user