mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-31 21:29:59 +08:00
feat: v0.9.2 — SkillHub双源技能管理、消息渠道多Agent绑定、模型配置优化、白屏安全网等
This commit is contained in:
@@ -4,29 +4,86 @@ use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
|
||||
/// 获取 agent 列表
|
||||
/// 获取 agent 列表(直接读 openclaw.json,不走 CLI,毫秒级响应)
|
||||
#[tauri::command]
|
||||
pub async fn list_agents() -> Result<Value, String> {
|
||||
let output = openclaw_command_async()
|
||||
.args(["agents", "list", "--json"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
"OpenClaw CLI 未找到,请确认已安装并重启 ClawPanel。\n如果使用 nvm 安装,请从终端启动 ClawPanel。".to_string()
|
||||
} else {
|
||||
format!("执行失败: {e}")
|
||||
}
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("获取 Agent 列表失败: {stderr}"));
|
||||
let config_path = super::openclaw_dir().join("openclaw.json");
|
||||
if !config_path.exists() {
|
||||
return Err("openclaw.json 不存在,请先安装 OpenClaw".to_string());
|
||||
}
|
||||
let content = fs::read_to_string(&config_path).map_err(|e| format!("读取配置失败: {e}"))?;
|
||||
let config: Value =
|
||||
serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {e}"))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
crate::commands::skills::extract_json_pub(&stdout)
|
||||
.ok_or_else(|| "解析 JSON 失败: 输出中未找到有效 JSON".to_string())
|
||||
let agents_list = config
|
||||
.get("agents")
|
||||
.and_then(|a| a.get("list"))
|
||||
.and_then(|l| l.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
// 补全 main agent 的 workspace(config 中可能没有显式指定)
|
||||
let default_workspace = config
|
||||
.get("agents")
|
||||
.and_then(|a| a.get("defaults"))
|
||||
.and_then(|d| d.get("workspace"))
|
||||
.and_then(|w| w.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
super::openclaw_dir()
|
||||
.join("workspace")
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
});
|
||||
|
||||
let enriched: Vec<Value> = agents_list
|
||||
.into_iter()
|
||||
.map(|mut agent| {
|
||||
let id = agent
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
// 补全 workspace 路径
|
||||
if agent.get("workspace").and_then(|w| w.as_str()).is_none()
|
||||
|| agent.get("workspace").and_then(|w| w.as_str()) == Some("")
|
||||
{
|
||||
if id == "main" {
|
||||
agent.as_object_mut().map(|o| {
|
||||
o.insert(
|
||||
"workspace".to_string(),
|
||||
Value::String(default_workspace.clone()),
|
||||
)
|
||||
});
|
||||
} else {
|
||||
let ws = super::openclaw_dir()
|
||||
.join("agents")
|
||||
.join(&id)
|
||||
.join("workspace")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
agent
|
||||
.as_object_mut()
|
||||
.map(|o| o.insert("workspace".to_string(), Value::String(ws)));
|
||||
}
|
||||
}
|
||||
// 补全 identityName 用于前端显示
|
||||
let identity_name = agent
|
||||
.get("identity")
|
||||
.and_then(|i| i.get("name"))
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if !identity_name.is_empty() {
|
||||
agent
|
||||
.as_object_mut()
|
||||
.map(|o| o.insert("identityName".to_string(), Value::String(identity_name)));
|
||||
}
|
||||
agent
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Value::Array(enriched))
|
||||
}
|
||||
|
||||
/// 创建新 agent
|
||||
|
||||
@@ -31,13 +31,26 @@ impl Drop for GuardianPause {
|
||||
|
||||
/// 预设 npm 源列表
|
||||
const DEFAULT_REGISTRY: &str = "https://registry.npmmirror.com";
|
||||
const GIT_HTTPS_REWRITES: [&str; 6] = [
|
||||
"ssh://git@github.com/",
|
||||
"ssh://git@github.com",
|
||||
"ssh://git@://github.com/",
|
||||
"git@github.com:",
|
||||
"git://github.com/",
|
||||
"git+ssh://git@github.com/",
|
||||
/// (target_https_prefix, from_pattern) pairs for Git HTTPS rewriting.
|
||||
/// Each entry maps a non-HTTPS Git URL pattern to the corresponding HTTPS URL.
|
||||
const GIT_HTTPS_REWRITES: &[(&str, &str)] = &[
|
||||
// github.com
|
||||
("https://github.com/", "ssh://git@github.com/"),
|
||||
("https://github.com/", "ssh://git@github.com"),
|
||||
("https://github.com/", "ssh://git@://github.com/"),
|
||||
("https://github.com/", "git@github.com:"),
|
||||
("https://github.com/", "git://github.com/"),
|
||||
("https://github.com/", "git+ssh://git@github.com/"),
|
||||
// gitlab.com
|
||||
("https://gitlab.com/", "ssh://git@gitlab.com/"),
|
||||
("https://gitlab.com/", "git@gitlab.com:"),
|
||||
("https://gitlab.com/", "git://gitlab.com/"),
|
||||
("https://gitlab.com/", "git+ssh://git@gitlab.com/"),
|
||||
// bitbucket.org
|
||||
("https://bitbucket.org/", "ssh://git@bitbucket.org/"),
|
||||
("https://bitbucket.org/", "git@bitbucket.org:"),
|
||||
("https://bitbucket.org/", "git://bitbucket.org/"),
|
||||
("https://bitbucket.org/", "git+ssh://git@bitbucket.org/"),
|
||||
];
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
@@ -114,27 +127,23 @@ fn recommended_version_for(source: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
fn configure_git_https_rules() -> usize {
|
||||
let mut unset = Command::new("git");
|
||||
unset.args([
|
||||
"config",
|
||||
"--global",
|
||||
"--unset-all",
|
||||
"url.https://github.com/.insteadOf",
|
||||
]);
|
||||
#[cfg(target_os = "windows")]
|
||||
unset.creation_flags(0x08000000);
|
||||
let _ = unset.output();
|
||||
// Collect unique target prefixes to unset old rules
|
||||
let targets: std::collections::HashSet<&str> =
|
||||
GIT_HTTPS_REWRITES.iter().map(|(t, _)| *t).collect();
|
||||
for target in &targets {
|
||||
let key = format!("url.{target}.insteadOf");
|
||||
let mut unset = Command::new("git");
|
||||
unset.args(["config", "--global", "--unset-all", &key]);
|
||||
#[cfg(target_os = "windows")]
|
||||
unset.creation_flags(0x08000000);
|
||||
let _ = unset.output();
|
||||
}
|
||||
|
||||
let mut success = 0;
|
||||
for from in GIT_HTTPS_REWRITES {
|
||||
for (target, from) in GIT_HTTPS_REWRITES {
|
||||
let key = format!("url.{target}.insteadOf");
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.args([
|
||||
"config",
|
||||
"--global",
|
||||
"--add",
|
||||
"url.https://github.com/.insteadOf",
|
||||
from,
|
||||
]);
|
||||
cmd.args(["config", "--global", "--add", &key, from]);
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.creation_flags(0x08000000);
|
||||
if cmd.output().map(|o| o.status.success()).unwrap_or(false) {
|
||||
@@ -153,12 +162,12 @@ fn apply_git_install_env(cmd: &mut Command) {
|
||||
)
|
||||
.env("GIT_ALLOW_PROTOCOL", "https:http:file");
|
||||
cmd.env("GIT_CONFIG_COUNT", GIT_HTTPS_REWRITES.len().to_string());
|
||||
for (idx, from) in GIT_HTTPS_REWRITES.iter().enumerate() {
|
||||
for (idx, (target, from)) in GIT_HTTPS_REWRITES.iter().enumerate() {
|
||||
cmd.env(
|
||||
format!("GIT_CONFIG_KEY_{idx}"),
|
||||
"url.https://github.com/.insteadOf",
|
||||
format!("url.{target}.insteadOf"),
|
||||
)
|
||||
.env(format!("GIT_CONFIG_VALUE_{idx}"), from);
|
||||
.env(format!("GIT_CONFIG_VALUE_{idx}"), *from);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
use crate::utils::openclaw_command_async;
|
||||
/// 记忆文件管理命令
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// 缓存 agent workspace 路径,避免每次操作都调 CLI(Windows 上 spawn Node.js 进程很慢)
|
||||
static WORKSPACE_CACHE: std::sync::LazyLock<Mutex<WorkspaceCache>> =
|
||||
std::sync::LazyLock::new(|| Mutex::new(WorkspaceCache::default()));
|
||||
|
||||
#[derive(Default)]
|
||||
struct WorkspaceCache {
|
||||
map: HashMap<String, PathBuf>,
|
||||
fetched_at: u64,
|
||||
}
|
||||
|
||||
impl WorkspaceCache {
|
||||
fn is_fresh(&self) -> bool {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
now - self.fetched_at < 60 // 60 秒 TTL
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查路径是否包含不安全字符(目录遍历、绝对路径等)
|
||||
fn is_unsafe_path(path: &str) -> bool {
|
||||
@@ -13,40 +34,82 @@ fn is_unsafe_path(path: &str) -> bool {
|
||||
|| (path.len() >= 2 && path.as_bytes()[1] == b':') // Windows 绝对路径 C:\
|
||||
}
|
||||
|
||||
/// 根据 agent_id 获取 workspace 路径(异步版本)
|
||||
/// 调用 openclaw agents list --json 解析
|
||||
/// 根据 agent_id 获取 workspace 路径(直接读 openclaw.json,带缓存)
|
||||
/// 不再调用 CLI,毫秒级响应
|
||||
async fn agent_workspace(agent_id: &str) -> Result<PathBuf, String> {
|
||||
let output = openclaw_command_async()
|
||||
.args(["agents", "list", "--json"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
"OpenClaw CLI 未找到,请确认已安装并重启 ClawPanel。\n如果使用 nvm 安装,请从终端启动 ClawPanel。".to_string()
|
||||
} else {
|
||||
format!("执行 openclaw 失败: {e}")
|
||||
// 先查缓存
|
||||
{
|
||||
let cache = WORKSPACE_CACHE.lock().unwrap();
|
||||
if cache.is_fresh() {
|
||||
if let Some(ws) = cache.map.get(agent_id) {
|
||||
return Ok(ws.clone());
|
||||
}
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err("获取 Agent 列表失败".into());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let agents: serde_json::Value = crate::commands::skills::extract_json_pub(&stdout)
|
||||
.ok_or_else(|| "解析 JSON 失败: 输出中未找到有效 JSON".to_string())?;
|
||||
|
||||
if let Some(arr) = agents.as_array() {
|
||||
for a in arr {
|
||||
if a.get("id").and_then(|v| v.as_str()) == Some(agent_id) {
|
||||
if let Some(ws) = a.get("workspace").and_then(|v| v.as_str()) {
|
||||
return Ok(PathBuf::from(ws));
|
||||
}
|
||||
if !cache.map.is_empty() {
|
||||
return Err(format!("Agent「{agent_id}」不存在或无 workspace"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("Agent「{agent_id}」不存在或无 workspace"))
|
||||
// 缓存过期或为空,从 openclaw.json 读取
|
||||
let config_path = super::openclaw_dir().join("openclaw.json");
|
||||
let content =
|
||||
fs::read_to_string(&config_path).map_err(|e| format!("读取 openclaw.json 失败: {e}"))?;
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {e}"))?;
|
||||
|
||||
let default_workspace = config
|
||||
.get("agents")
|
||||
.and_then(|a| a.get("defaults"))
|
||||
.and_then(|d| d.get("workspace"))
|
||||
.and_then(|w| w.as_str())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| super::openclaw_dir().join("workspace"));
|
||||
|
||||
let mut new_map = HashMap::new();
|
||||
// main agent 使用默认 workspace
|
||||
new_map.insert("main".to_string(), default_workspace);
|
||||
|
||||
if let Some(arr) = config
|
||||
.get("agents")
|
||||
.and_then(|a| a.get("list"))
|
||||
.and_then(|l| l.as_array())
|
||||
{
|
||||
for a in arr {
|
||||
let id = a.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let ws = a
|
||||
.get("workspace")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
if id == "main" {
|
||||
super::openclaw_dir().join("workspace")
|
||||
} else {
|
||||
super::openclaw_dir()
|
||||
.join("agents")
|
||||
.join(id)
|
||||
.join("workspace")
|
||||
}
|
||||
});
|
||||
new_map.insert(id.to_string(), ws);
|
||||
}
|
||||
}
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let result = new_map.get(agent_id).cloned();
|
||||
{
|
||||
let mut cache = WORKSPACE_CACHE.lock().unwrap();
|
||||
cache.map = new_map;
|
||||
cache.fetched_at = now;
|
||||
}
|
||||
|
||||
result.ok_or_else(|| format!("Agent「{agent_id}」不存在或无 workspace"))
|
||||
}
|
||||
|
||||
async fn memory_dir_for_agent(agent_id: &str, category: &str) -> Result<PathBuf, String> {
|
||||
|
||||
@@ -190,10 +190,12 @@ pub async fn read_platform_config(platform: String) -> Result<Value, String> {
|
||||
|
||||
/// 保存平台配置到 openclaw.json
|
||||
/// 前端传入的是表单字段,后端负责转换成 OpenClaw 要求的结构
|
||||
/// account_id: 可选,指定时写入 channels.<platform>.accounts.<account_id>(多账号模式)
|
||||
#[tauri::command]
|
||||
pub async fn save_messaging_platform(
|
||||
platform: String,
|
||||
form: Value,
|
||||
account_id: Option<String>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<Value, String> {
|
||||
let mut cfg = super::config::load_openclaw_json()?;
|
||||
@@ -338,7 +340,6 @@ pub async fn save_messaging_platform(
|
||||
entry.insert("enabled".into(), Value::Bool(true));
|
||||
entry.insert("connectionMode".into(), Value::String("websocket".into()));
|
||||
|
||||
// 域名(默认 feishu,国际版选 lark)
|
||||
let domain = form_obj
|
||||
.get("domain")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -349,7 +350,23 @@ pub async fn save_messaging_platform(
|
||||
entry.insert("domain".into(), Value::String(domain));
|
||||
}
|
||||
|
||||
channels_map.insert("feishu".into(), Value::Object(entry));
|
||||
// 多账号模式:写入 channels.feishu.accounts.<account_id>
|
||||
if let Some(ref acct) = account_id {
|
||||
if !acct.is_empty() {
|
||||
let feishu = channels_map
|
||||
.entry("feishu")
|
||||
.or_insert_with(|| json!({ "enabled": true }));
|
||||
let feishu_obj = feishu.as_object_mut().ok_or("feishu 节点格式错误")?;
|
||||
feishu_obj.entry("enabled").or_insert(Value::Bool(true));
|
||||
let accounts = feishu_obj.entry("accounts").or_insert_with(|| json!({}));
|
||||
let accounts_obj = accounts.as_object_mut().ok_or("accounts 格式错误")?;
|
||||
accounts_obj.insert(acct.clone(), Value::Object(entry));
|
||||
} else {
|
||||
channels_map.insert("feishu".into(), Value::Object(entry));
|
||||
}
|
||||
} else {
|
||||
channels_map.insert("feishu".into(), Value::Object(entry));
|
||||
}
|
||||
ensure_plugin_allowed(&mut cfg, "feishu")?;
|
||||
let _ = cleanup_legacy_plugin_backup_dir("feishu");
|
||||
}
|
||||
|
||||
@@ -337,6 +337,13 @@ fn build_enhanced_path() -> String {
|
||||
}
|
||||
}
|
||||
}
|
||||
// NVM_SYMLINK 环境变量(nvm-windows 的活跃版本符号链接,如 D:\nodejs)
|
||||
if let Ok(nvm_symlink) = std::env::var("NVM_SYMLINK") {
|
||||
let symlink_path = std::path::Path::new(&nvm_symlink);
|
||||
if symlink_path.is_dir() {
|
||||
extra.push(nvm_symlink.clone());
|
||||
}
|
||||
}
|
||||
// NVM_HOME 环境变量(用户可能自定义了 nvm 安装目录)
|
||||
if let Ok(nvm_home) = std::env::var("NVM_HOME") {
|
||||
let nvm_path = std::path::Path::new(&nvm_home);
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::os::windows::process::CommandExt;
|
||||
#[tauri::command]
|
||||
pub async fn skills_list() -> Result<Value, String> {
|
||||
let output = openclaw_command_async()
|
||||
.args(["skills", "list", "--json", "--verbose"])
|
||||
.args(["skills", "list", "--json"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
@@ -137,29 +137,122 @@ pub async fn skills_install_dep(kind: String, spec: Value) -> Result<Value, Stri
|
||||
}))
|
||||
}
|
||||
|
||||
/// 从 ClawHub 安装 Skill(npx clawhub install <slug>)
|
||||
/// 检测 SkillHub CLI 是否已安装
|
||||
#[tauri::command]
|
||||
pub async fn skills_clawhub_install(slug: String) -> Result<Value, String> {
|
||||
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 })),
|
||||
}
|
||||
}
|
||||
|
||||
/// 安装 SkillHub CLI(从腾讯云 COS 下载)
|
||||
#[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() }))
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 SkillHub 安装 Skill(skillhub install <slug>)
|
||||
#[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();
|
||||
|
||||
// 确保 skills 目录存在
|
||||
let skills_dir = super::openclaw_dir().join("skills");
|
||||
if !skills_dir.exists() {
|
||||
std::fs::create_dir_all(&skills_dir).map_err(|e| format!("创建 skills 目录失败: {e}"))?;
|
||||
}
|
||||
|
||||
let mut cmd = tokio::process::Command::new("npx");
|
||||
cmd.args(["-y", "clawhub", "install", &slug])
|
||||
.env("PATH", &path_env)
|
||||
.current_dir(&home);
|
||||
super::apply_proxy_env_tokio(&mut cmd);
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.creation_flags(0x08000000);
|
||||
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!("执行 clawhub 失败: {e}"))?;
|
||||
.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();
|
||||
@@ -175,25 +268,34 @@ pub async fn skills_clawhub_install(slug: String) -> Result<Value, String> {
|
||||
}))
|
||||
}
|
||||
|
||||
/// 从 ClawHub 搜索 Skills(npx clawhub search <query>)
|
||||
/// 从 SkillHub 搜索 Skills(skillhub search <query>)
|
||||
#[tauri::command]
|
||||
pub async fn skills_clawhub_search(query: String) -> Result<Value, String> {
|
||||
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();
|
||||
let mut cmd = tokio::process::Command::new("npx");
|
||||
cmd.args(["-y", "clawhub", "search", &q])
|
||||
.env("PATH", &path_env);
|
||||
super::apply_proxy_env_tokio(&mut cmd);
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.creation_flags(0x08000000);
|
||||
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!("执行 clawhub 失败: {e}"))?;
|
||||
.map_err(|e| format!("执行 skillhub 失败: {e}。请先安装 SkillHub CLI"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
@@ -202,7 +304,102 @@ pub async fn skills_clawhub_search(query: String) -> Result<Value, String> {
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// clawhub search 输出是文本行,每行一个 skill
|
||||
// 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())
|
||||
@@ -211,18 +408,63 @@ pub async fn skills_clawhub_search(query: String) -> Result<Value, String> {
|
||||
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"
|
||||
})
|
||||
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> {
|
||||
if name.is_empty() || name.contains("..") || name.contains('/') || name.contains('\\') {
|
||||
return Err("无效的 Skill 名称".to_string());
|
||||
}
|
||||
let skills_dir = super::openclaw_dir().join("skills").join(&name);
|
||||
if !skills_dir.exists() {
|
||||
return Err(format!("Skill「{name}」不存在"));
|
||||
}
|
||||
std::fs::remove_dir_all(&skills_dir).map_err(|e| format!("删除失败: {e}"))?;
|
||||
Ok(serde_json::json!({ "success": true, "name": name }))
|
||||
}
|
||||
|
||||
/// Public wrapper for extract_json, used by config.rs get_status_summary
|
||||
pub fn extract_json_pub(text: &str) -> Option<Value> {
|
||||
extract_json(text)
|
||||
|
||||
@@ -165,8 +165,13 @@ pub fn run() {
|
||||
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,
|
||||
// 前端热更新
|
||||
update::check_frontend_update,
|
||||
update::download_frontend_update,
|
||||
|
||||
Reference in New Issue
Block a user