feat: 飞书官方插件迁移 + 配对审批 + Gateway防卡死 + 微信升级修复 + 更新检测修复

- 飞书渠道从 @openclaw/feishu 迁移到 @larksuite/openclaw-lark 官方插件
- 保存飞书配置时自动禁用旧 feishu 插件,防止新旧插件冲突
- 所有主要渠道(飞书/Telegram/Discord/Slack)启用配对审批UI
- gateway_command 增加20s超时,超时后force-kill+fresh start
- 全平台启动前端口占用检查,防止Guardian无限拉起
- Linux gateway_command 补齐 Duration 导入和 cleanup_zombie 实现
- Guardian自动守护在Tauri桌面端也启用,轮询间隔30s→15s
- 微信渠道:升级操作不再弹出扫码二维码,按钮文案区分安装/升级
- 版本更新检测:CI不再将minAppVersion写死为当前版本
- 部署脚本增强OpenClaw检测,支持已安装的官方版
- 日间/夜间模式圆形扩散切换动画(View Transitions API)
- API错误信息完整展示(429限流等),URL自动转可点击链接
- 第三方API接入引导优化:移除内置密钥,引导式流程
- 修复全平台 Clippy 警告(strip_prefix/dead_code/unnecessary_unwrap等)
- Rust代码格式化修复(cargo fmt)
- toast组件支持HTML内容渲染
- Rust后端test_model返回详细错误信息
This commit is contained in:
晴天
2026-03-23 20:37:48 +08:00
parent dccb4b4dbf
commit 3687e26d5d
50 changed files with 8055 additions and 2715 deletions

View File

@@ -1,9 +1,95 @@
/// Agent 管理命令 — 列表/改名直接读写 openclaw.json创建/删除走 CLI需要创建 workspace 等文件)
use crate::utils::openclaw_command_async;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fs;
use std::io::Write;
/// Workspace 状态信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceStatus {
/// 路径是否存在
pub exists: bool,
/// 是否为软链接
pub is_symlink: bool,
/// 软链接指向的目标路径(如果是软链接)
pub symlink_target: Option<String>,
/// 软链接目标是否有效(仅当 is_symlink=true 时有意义)
pub symlink_valid: bool,
/// 是否有读取权限
pub readable: bool,
}
/// Workspace 状态检测结果(包含状态和警告信息)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceCheckResult {
pub status: WorkspaceStatus,
pub warning: Option<String>,
}
/// 检测 workspace 路径的状态
/// 使用 symlink_metadata 而非 metadata避免跟随软链接
fn check_workspace_status(path: &std::path::Path) -> WorkspaceCheckResult {
let mut status = WorkspaceStatus {
exists: false,
is_symlink: false,
symlink_target: None,
symlink_valid: false,
readable: true,
};
let mut warning = None;
// 使用 symlink_metadata 不会跟随软链接,能正确检测软链接本身的状态
match std::fs::symlink_metadata(path) {
Ok(meta) => {
status.exists = true;
status.is_symlink = meta.file_type().is_symlink();
if status.is_symlink {
// 软链接:获取目标路径
match std::fs::read_link(path) {
Ok(target) => {
status.symlink_target = Some(target.to_string_lossy().to_string());
// 检查软链接目标是否存在
match std::fs::metadata(path) {
Ok(_) => status.symlink_valid = true,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
status.symlink_valid = false;
warning = Some("软链接目标不存在".to_string());
}
Err(e) => {
status.symlink_valid = false;
warning = Some(format!("无法访问软链接目标: {}", e));
}
}
}
Err(e) => {
warning = Some(format!("无法读取软链接目标: {}", e));
}
}
} else {
// 普通目录:验证读取权限
match std::fs::read_dir(path) {
Ok(_) => status.readable = true,
Err(e) => {
status.readable = false;
warning = Some(format!("权限不足: {}", e));
}
}
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
warning = Some("工作目录不存在".to_string());
}
Err(e) => {
status.readable = false;
warning = Some(format!("无法访问路径: {}", e));
}
}
WorkspaceCheckResult { status, warning }
}
/// 获取 agent 列表(直接读 openclaw.json不走 CLI毫秒级响应
#[tauri::command]
pub async fn list_agents() -> Result<Value, String> {
@@ -83,6 +169,28 @@ pub async fn list_agents() -> Result<Value, String> {
.map(|o| o.insert("workspace".to_string(), Value::String(ws)));
}
}
// 检测 workspace 状态
if let Some(ws_str) = agent.get("workspace").and_then(|w| w.as_str()) {
let ws_path = std::path::Path::new(ws_str);
let check_result = check_workspace_status(ws_path);
// 添加 workspaceStatus 字段
agent.as_object_mut().map(|o| {
o.insert(
"workspaceStatus".to_string(),
serde_json::to_value(&check_result.status).unwrap_or(Value::Null),
)
});
// 添加警告信息
if let Some(w) = check_result.warning {
agent
.as_object_mut()
.map(|o| o.insert("workspaceWarning".to_string(), Value::String(w)));
}
}
// 补全 identityName 用于前端显示
let identity_name = agent
.get("identity")
@@ -105,6 +213,7 @@ pub async fn list_agents() -> Result<Value, String> {
/// 创建新 agent优先走 CLI失败则直接写 openclaw.json 兜底)
#[tauri::command]
pub async fn add_agent(
app: tauri::AppHandle,
name: String,
model: String,
workspace: Option<String>,
@@ -117,6 +226,18 @@ pub async fn add_agent(
.join("workspace"),
};
// 验证 workspace 路径有效性
let ws_check = check_workspace_status(&ws);
if let Some(ref warning) = ws_check.warning {
eprintln!("[agent] Workspace 警告: {}", warning);
}
if ws_check.status.is_symlink && !ws_check.status.symlink_valid {
return Err(format!(
"指定的 workspace 是软链接,但目标不存在: {}",
ws_check.status.symlink_target.as_deref().unwrap_or("未知")
));
}
let mut args = vec![
"agents".to_string(),
"add".to_string(),
@@ -152,21 +273,48 @@ pub async fn add_agent(
false
}
Err(_) => {
eprintln!("[agent] CLI 超时 (15s)");
eprintln!("[agent] CLI 超时 (15s),可能是 OpenClaw 未响应");
false
}
};
if !cli_ok {
// 兜底:直接写 openclaw.json
add_agent_to_config(&name, &model, &ws)?;
if let Err(e) = add_agent_to_config(&name, &model, &ws) {
return Err(format!(
"CLI 创建超时且配置写入失败: {}\n请尝试手动运行: openclaw agents add {} --workspace {}",
e,
name,
ws.to_string_lossy()
));
}
}
// 确保 workspace 目录存在
if !ws.exists() {
let _ = fs::create_dir_all(&ws);
if let Err(e) = fs::create_dir_all(&ws) {
eprintln!("[agent] 创建 workspace 目录失败: {e}");
}
}
// 验证步骤
let agents = list_agents().await?;
let created = agents.as_array().and_then(|arr| {
arr.iter()
.find(|a| a.get("id").and_then(|v| v.as_str()) == Some(&name))
});
if created.is_none() {
eprintln!("[agent] 警告: Agent 创建后未在列表中出现");
}
if !ws.exists() {
eprintln!("[agent] 警告: Agent workspace 目录未创建");
}
// 触发 Gateway 重载使新 agent 生效
let _ = super::config::do_reload_gateway(&app).await;
list_agents().await
}
@@ -229,7 +377,7 @@ fn add_agent_to_config(id: &str, model: &str, workspace: &std::path::Path) -> Re
/// 删除 agent直接操作 openclaw.json + 删除 agent 目录,不走 CLI
#[tauri::command]
pub async fn delete_agent(id: String) -> Result<String, String> {
pub async fn delete_agent(app: tauri::AppHandle, id: String) -> Result<String, String> {
if id == "main" {
return Err("不能删除默认 Agent".into());
}
@@ -265,15 +413,21 @@ pub async fn delete_agent(id: String) -> Result<String, String> {
// 2. 删除 agent 目录workspace + sessions 等)
let agent_dir = super::openclaw_dir().join("agents").join(&id);
if agent_dir.exists() {
let _ = fs::remove_dir_all(&agent_dir);
if let Err(e) = fs::remove_dir_all(&agent_dir) {
eprintln!("[agent] 删除 agent 目录失败: {e},不影响配置删除");
}
}
// 3. 触发 Gateway 重载
let _ = super::config::do_reload_gateway(&app).await;
Ok("已删除".into())
}
/// 更新 agent 身份信息
#[tauri::command]
pub fn update_agent_identity(
pub async fn update_agent_identity(
app: tauri::AppHandle,
id: String,
name: Option<String>,
emoji: Option<String>,
@@ -333,7 +487,9 @@ pub fn update_agent_identity(
});
let json = serde_json::to_string_pretty(&config).map_err(|e| format!("序列化失败: {e}"))?;
fs::write(&path, json).map_err(|e| format!("写入配置失败: {e}"))?;
if let Err(e) = fs::write(&path, json) {
return Err(format!("写入配置失败: {e},请检查文件权限"));
}
// 删除 IDENTITY.md 文件,让配置文件生效
if let Some(ws_str) = workspace_path {
@@ -343,6 +499,9 @@ pub fn update_agent_identity(
}
}
// 触发 Gateway 重载使配置生效
let _ = super::config::do_reload_gateway(&app).await;
Ok("已更新".into())
}
@@ -400,7 +559,11 @@ fn collect_dir_to_zip(
/// 更新 agent 模型配置
#[tauri::command]
pub fn update_agent_model(id: String, model: String) -> Result<String, String> {
pub async fn update_agent_model(
app: tauri::AppHandle,
id: String,
model: String,
) -> Result<String, String> {
let path = super::openclaw_dir().join("openclaw.json");
let content = fs::read_to_string(&path).map_err(|e| format!("读取配置失败: {e}"))?;
let mut config: Value =
@@ -424,7 +587,12 @@ pub fn update_agent_model(id: String, model: String) -> Result<String, String> {
.insert("model".to_string(), model_obj);
let json = serde_json::to_string_pretty(&config).map_err(|e| format!("序列化失败: {e}"))?;
fs::write(&path, json).map_err(|e| format!("写入配置失败: {e}"))?;
if let Err(e) = fs::write(&path, json) {
return Err(format!("写入配置失败: {e},请检查文件权限"));
}
// 触发 Gateway 重载使配置生效
let _ = super::config::do_reload_gateway(&app).await;
Ok("已更新".into())
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,12 @@ use std::path::PathBuf;
use std::sync::RwLock;
use std::time::Duration;
/// 缓存 gateway 端口避免频繁读文件5秒有效期
static GATEWAY_PORT_CACHE: std::sync::LazyLock<std::sync::Mutex<(u16, std::time::Instant)>> =
std::sync::LazyLock::new(|| {
std::sync::Mutex::new((18789, std::time::Instant::now() - Duration::from_secs(60)))
});
pub mod agent;
pub mod assistant;
pub mod config;
@@ -39,6 +45,41 @@ pub fn openclaw_dir() -> PathBuf {
default_openclaw_dir()
}
/// Gateway 监听端口:读取 `openclaw.json` 的 `gateway.port`,缺省 **18789**。
/// 与面板「Gateway 配置」、服务状态检测netstat / TCP / launchctl 兜底)共用同一来源,
/// 并尊重 `clawpanel.json` 中的 `openclawDir` 自定义配置目录。
pub fn gateway_listen_port() -> u16 {
// 5秒内返回缓存值避免服务状态检测时频繁读文件
if let Ok(cache) = GATEWAY_PORT_CACHE.lock() {
if cache.1.elapsed() < Duration::from_secs(5) {
return cache.0;
}
}
let port = read_gateway_port_from_config();
if let Ok(mut cache) = GATEWAY_PORT_CACHE.lock() {
*cache = (port, std::time::Instant::now());
}
port
}
fn read_gateway_port_from_config() -> u16 {
let config_path = openclaw_dir().join("openclaw.json");
if let Ok(content) = std::fs::read_to_string(&config_path) {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(port) = val
.get("gateway")
.and_then(|g| g.get("port"))
.and_then(|p| p.as_u64())
{
if port > 0 && port < 65536 {
return port as u16;
}
}
}
}
18789
}
fn panel_config_path() -> PathBuf {
// ClawPanel 自身配置始终在默认目录,不随 openclawDir 变化
default_openclaw_dir().join("clawpanel.json")
@@ -341,16 +382,28 @@ fn build_enhanced_path() -> String {
let mut extra: Vec<String> = vec![];
// 1. NVM_SYMLINKnvm-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());
}
// 如果是符号链接,尝试读取其实际指向的目标
#[cfg(target_os = "windows")]
if symlink_path.is_symlink() {
if let Ok(target) = std::fs::read_link(symlink_path) {
if target.is_dir() {
extra.push(target.to_string_lossy().to_string());
}
}
}
}
// 2. NVM_HOME用户自定义 nvm 安装目录)
if let Ok(nvm_home) = std::env::var("NVM_HOME") {
let nvm_path = std::path::Path::new(&nvm_home);
if nvm_path.is_dir() {
// 扫描所有已安装的版本目录
if let Ok(entries) = std::fs::read_dir(nvm_path) {
for entry in entries.flatten() {
let p = entry.path();
@@ -359,13 +412,34 @@ fn build_enhanced_path() -> String {
}
}
}
// 尝试从 settings.json 读取当前激活版本
let settings_path = nvm_path.join("settings.json");
if settings_path.exists() {
if let Ok(content) = std::fs::read_to_string(&settings_path) {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
// settings.json 中有 "path" 字段指向当前版本
if let Some(current_version) = json.get("path").and_then(|v| v.as_str())
{
let version_path = nvm_path.join(current_version);
if version_path.is_dir() {
// 将当前激活版本移到更高优先级
let version_bin = version_path.to_string_lossy().to_string();
if !extra.contains(&version_bin) {
extra.insert(0, version_bin);
}
}
}
}
}
}
}
}
// 3. %APPDATA%\nvmnvm-windows 默认安装目录)
if !appdata.is_empty() {
extra.push(format!(r"{}\nvm", appdata));
let nvm_dir = std::path::Path::new(&appdata).join("nvm");
if nvm_dir.is_dir() {
// 扫描所有已安装的版本
if let Ok(entries) = std::fs::read_dir(&nvm_dir) {
for entry in entries.flatten() {
let p = entry.path();
@@ -374,10 +448,35 @@ fn build_enhanced_path() -> String {
}
}
}
// 尝试从 settings.json 读取当前激活版本
let settings_path = nvm_dir.join("settings.json");
if settings_path.exists() {
if let Ok(content) = std::fs::read_to_string(&settings_path) {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(current_version) = json.get("path").and_then(|v| v.as_str())
{
let version_path = nvm_dir.join(current_version);
if version_path.is_dir() {
let version_bin = version_path.to_string_lossy().to_string();
if !extra.contains(&version_bin) {
extra.insert(0, version_bin);
}
}
}
}
}
}
}
}
// 4. volta
extra.push(format!(r"{}\.volta\bin", home.display()));
// volta 的活跃版本
let volta_bin = std::path::Path::new(&home).join(".volta/bin");
if volta_bin.is_dir() && !extra.contains(&volta_bin.to_string_lossy().to_string()) {
extra.insert(0, volta_bin.to_string_lossy().to_string());
}
// 5. fnm
if !localappdata.is_empty() {
extra.push(format!(r"{}\fnm_multishells", localappdata));
@@ -388,30 +487,53 @@ fn build_enhanced_path() -> String {
.unwrap_or_else(|| std::path::Path::new(&appdata).join("fnm"));
let fnm_versions = fnm_base.join("node-versions");
if fnm_versions.is_dir() {
// 尝试找到 fnm 的当前活跃版本
let fnm_current = fnm_base.join("current");
if fnm_current.is_dir() {
let current_inst = fnm_current.join("installation");
if current_inst.is_dir()
&& current_inst.join("node.exe").exists()
&& !extra.contains(&current_inst.to_string_lossy().to_string())
{
extra.insert(0, current_inst.to_string_lossy().to_string());
}
}
// 扫描所有版本
if let Ok(entries) = std::fs::read_dir(&fnm_versions) {
for entry in entries.flatten() {
let inst = entry.path().join("installation");
if inst.is_dir() && inst.join("node.exe").exists() {
extra.push(inst.to_string_lossy().to_string());
let inst_str = inst.to_string_lossy().to_string();
if !extra.contains(&inst_str) {
extra.push(inst_str);
}
}
}
}
}
// 6. npm 全局openclaw.cmd 通常在这里)
if !appdata.is_empty() {
extra.push(format!(r"{}\npm", appdata));
}
// 7. 系统默认 Node.js 安装路径(优先级最低)
extra.push(format!(r"{}\nodejs", pf));
extra.push(format!(r"{}\nodejs", pf86));
if !localappdata.is_empty() {
extra.push(format!(r"{}\Programs\nodejs", localappdata));
}
// 8. 扫描常见盘符下的 Node 安装(用户可能装在 D:\、F:\ 等)
for drive in &["C", "D", "E", "F"] {
extra.push(format!(r"{}:\nodejs", drive));
extra.push(format!(r"{}:\Node", drive));
extra.push(format!(r"{}:\Program Files\nodejs", drive));
// 常见 AI/Dev 工具目录
extra.push(format!(r"{}:\AI\Node", drive));
extra.push(format!(r"{}:\AI\nodejs", drive));
extra.push(format!(r"{}:\Dev\nodejs", drive));
extra.push(format!(r"{}:\Tools\nodejs", drive));
}
let mut parts: Vec<&str> = vec![];
@@ -419,9 +541,10 @@ fn build_enhanced_path() -> String {
if let Some(ref cp) = custom_path {
parts.push(cp.as_str());
}
// 然后是默认扫描到的路径
// 然后是默认扫描到的路径(去重)
let mut seen = std::collections::HashSet::new();
for p in &extra {
if std::path::Path::new(p).exists() {
if std::path::Path::new(p).exists() && seen.insert(p.clone()) {
parts.push(p.as_str());
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
use crate::utils::openclaw_command_async;
use serde_json::Value;
use std::collections::HashSet;
#[cfg(target_os = "windows")]
#[allow(unused_imports)]
@@ -22,7 +23,20 @@ pub async fn skills_list() -> Result<Value, String> {
// CLI 可能在有 skill 缺依赖时返回非零退出码,但 JSON 输出仍然有效
// 优先尝试解析 JSON无论退出码
match extract_json(&stdout) {
Some(v) => Ok(v),
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!(
@@ -31,14 +45,27 @@ pub async fn skills_list() -> Result<Value, String> {
stdout.chars().take(200).collect::<String>(),
stderr.chars().take(200).collect::<String>()
);
scan_local_skills()
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>(),
})))
}
}
}
_ => {
// CLI 不可用或超时,兜底扫描本地 skills 目录
scan_local_skills()
}
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,
}))),
}
}
@@ -52,12 +79,22 @@ pub async fn skills_info(name: String) -> Result<Value, String> {
.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);
extract_json(&stdout).ok_or_else(|| "解析详情失败: 输出中未找到有效 JSON".to_string())
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
@@ -472,7 +509,8 @@ 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);
let skills_dir =
resolve_custom_skill_dir(&name).ok_or_else(|| format!("Skill「{name}」不存在"))?;
if !skills_dir.exists() {
return Err(format!("Skill「{name}」不存在"));
}
@@ -480,26 +518,240 @@ pub async fn skills_uninstall(name: String) -> Result<Value, String> {
Ok(serde_json::json!({ "success": true, "name": name }))
}
/// 验证 Skill 配置是否正确
#[tauri::command]
pub async fn skills_validate(name: String) -> Result<Value, String> {
if name.is_empty() || name.contains("..") || name.contains('/') || name.contains('\\') {
return Err("无效的 Skill 名称".to_string());
}
let skill_dir =
resolve_custom_skill_dir(&name).ok_or_else(|| format!("Skill「{name}」不存在"))?;
if !skill_dir.exists() {
return Err(format!("Skill「{name}」不存在"));
}
let skill_md = skill_dir.join("SKILL.md");
let package_json = skill_dir.join("package.json");
let mut issues: Vec<Value> = Vec::new();
let mut warnings: Vec<Value> = Vec::new();
let mut passed: Vec<String> = Vec::new();
// 1. 检查 SKILL.md 是否存在
if !skill_md.exists() {
issues.push(serde_json::json!({
"level": "error",
"code": "MISSING_SKILL_MD",
"message": "缺少 SKILL.md 文件",
"suggestion": "创建 SKILL.md 文件,包含 skill 的描述和使用说明"
}));
} else {
passed.push("SKILL.md 存在".to_string());
// 2. 检查 SKILL.md frontmatter 格式
if let Some(frontmatter) = parse_skill_frontmatter(&skill_md) {
// 检查必要字段
let required_fields = ["description", "fullPath"];
for field in &required_fields {
if !frontmatter
.get(*field)
.and_then(|v| v.as_str())
.map(|s| !s.is_empty())
.unwrap_or(false)
{
issues.push(serde_json::json!({
"level": "error",
"code": "MISSING_REQUIRED_FIELD",
"message": format!("SKILL.md frontmatter 缺少必要字段: {}", field),
"field": field,
"suggestion": format!("在 frontmatter 中添加 {}: <值>", field)
}));
} else {
passed.push(format!("frontmatter.{} 字段存在且非空", field));
}
}
// 检查 fullPath 格式(应该是绝对路径或 ~ 开头)
if let Some(fp) = frontmatter.get("fullPath").and_then(|v| v.as_str()) {
// Windows 路径以盘符开头(如 C:\Unix 以 / 或 ~ 或 . 开头
let is_valid_path = fp.starts_with('/')
|| fp.starts_with('~')
|| fp.starts_with('.')
|| (fp.len() >= 3
&& fp.as_bytes()[1] == b':'
&& (fp.as_bytes()[2] == b'\\' || fp.as_bytes()[2] == b'/'));
if !is_valid_path {
warnings.push(serde_json::json!({
"level": "warning",
"code": "INVALID_FULLPATH_FORMAT",
"message": format!("fullPath 格式可能不正确: {}", fp),
"suggestion": "建议使用绝对路径或 ~ 开头"
}));
}
}
} else {
issues.push(serde_json::json!({
"level": "error",
"code": "INVALID_FRONTMATTER",
"message": "SKILL.md frontmatter 格式不正确",
"suggestion": "确保 frontmatter 以 --- 开头和结尾,包含正确的 YAML 格式"
}));
}
// 3. 检查 SKILL.md 内容(非 frontmatter 部分)
if let Ok(content) = std::fs::read_to_string(&skill_md) {
// 检查是否有空内容
let body = content
.split("---")
.skip(2) // 跳过 frontmatter
.collect::<Vec<_>>()
.join("---")
.trim()
.to_string();
if body.len() < 10 {
warnings.push(serde_json::json!({
"level": "warning",
"code": "EMPTY_SKILL_CONTENT",
"message": "SKILL.md 正文内容为空或过短",
"suggestion": "添加 skill 的使用说明、功能描述等详细内容"
}));
} else {
passed.push("SKILL.md 正文内容完整".to_string());
}
}
}
// 4. 检查 package.json
if !package_json.exists() {
warnings.push(serde_json::json!({
"level": "warning",
"code": "MISSING_PACKAGE_JSON",
"message": "缺少 package.json 文件",
"suggestion": "可选:创建 package.json 以便管理 npm 依赖"
}));
} else {
passed.push("package.json 存在".to_string());
// 5. 解析并验证 package.json
if let Ok(pkg_content) = std::fs::read_to_string(&package_json) {
if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&pkg_content) {
// 检查 name 字段
if let Some(pkg_name) = pkg.get("name").and_then(|v| v.as_str()) {
if pkg_name != name {
warnings.push(serde_json::json!({
"level": "warning",
"code": "NAME_MISMATCH",
"message": format!("package.json 中的 name '{}' 与目录名 '{}' 不一致", pkg_name, name),
"suggestion": "确保 package.json 的 name 字段与 skill 目录名一致"
}));
} else {
passed.push("package.json.name 与目录名一致".to_string());
}
}
// 检查 dependencies 和 node_modules
if let Some(deps) = pkg.get("dependencies").and_then(|v| v.as_object()) {
let deps_count = deps.len();
passed.push(format!("package.json 声明了 {} 个依赖", deps_count));
// 检查 node_modules
let node_modules = skill_dir.join("node_modules");
if node_modules.exists() {
let missing = detect_missing_dependencies(
&deps.keys().cloned().collect::<Vec<_>>(),
&skill_dir,
);
if !missing.is_empty() {
warnings.push(serde_json::json!({
"level": "warning",
"code": "MISSING_NPM_DEPS",
"message": format!("缺少 {} 个 npm 依赖: {}", missing.len(), missing.join(", ")),
"missingDeps": missing,
"suggestion": "运行 npm install 安装依赖"
}));
} else {
passed.push("所有 npm 依赖已安装".to_string());
}
} else if deps_count > 0 {
issues.push(serde_json::json!({
"level": "error",
"code": "NODE_MODULES_MISSING",
"message": "package.json 声明了依赖但 node_modules 不存在",
"suggestion": "运行 npm install 安装依赖"
}));
}
}
} else {
issues.push(serde_json::json!({
"level": "error",
"code": "INVALID_PACKAGE_JSON",
"message": "package.json 格式不正确",
"suggestion": "确保 package.json 是有效的 JSON 格式"
}));
}
}
}
// 6. 检查常见的不应该存在的文件
let unnecessary_files = ["README.md", "README.txt", "readme.md"];
for file in unnecessary_files {
let file_path = skill_dir.join(file);
if file_path.exists() {
warnings.push(serde_json::json!({
"level": "warning",
"code": "UNNECESSARY_FILE",
"message": format!("发现不必要的文件: {}", file),
"suggestion": "Skill 文档应放在 SKILL.md 中,删除 README.md"
}));
}
}
// 汇总结果
let has_errors = !issues.is_empty();
let is_valid = !has_errors;
Ok(serde_json::json!({
"name": name,
"valid": is_valid,
"summary": {
"errors": issues.len(),
"warnings": warnings.len(),
"passed": passed.len()
},
"issues": issues,
"warnings": warnings,
"passed": passed,
"validatedAt": chrono::Utc::now().to_rfc3339()
}))
}
/// Public wrapper for extract_json, used by config.rs get_status_summary
pub fn extract_json_pub(text: &str) -> Option<Value> {
extract_json(text)
}
/// Extract the first valid JSON object or array from a string that may contain
/// non-JSON lines (Node.js warnings, npm update prompts, etc.)
/// non-JSON lines (Node.js warnings, npm update prompts, ANSI codes, etc.)
fn extract_json(text: &str) -> Option<Value> {
// Pre-processing: clean up common CLI output artifacts
let cleaned = clean_cli_output(text);
// Try parsing the whole string first (fast path)
if let Ok(v) = serde_json::from_str::<Value>(text) {
if let Ok(v) = serde_json::from_str::<Value>(&cleaned) {
return Some(v);
}
// Find the first '{' or '[' and try parsing from there
for (i, ch) in text.char_indices() {
for (i, ch) in cleaned.char_indices() {
if ch == '{' || ch == '[' {
if let Ok(v) = serde_json::from_str::<Value>(&text[i..]) {
// Try direct parsing first
if let Ok(v) = serde_json::from_str::<Value>(&cleaned[i..]) {
return Some(v);
}
// Try with a streaming deserializer to handle trailing content
let mut de = serde_json::Deserializer::from_str(&text[i..]).into_iter::<Value>();
let mut de = serde_json::Deserializer::from_str(&cleaned[i..]).into_iter::<Value>();
if let Some(Ok(v)) = de.next() {
return Some(v);
}
@@ -508,71 +760,447 @@ fn extract_json(text: &str) -> Option<Value> {
None
}
/// CLI 不可用时的兜底:扫描 ~/.openclaw/skills 目录
fn scan_local_skills() -> Result<Value, String> {
let skills_dir = super::openclaw_dir().join("skills");
if !skills_dir.exists() {
/// Clean up CLI output by removing common non-JSON artifacts:
/// - ANSI escape sequences (color codes)
/// - npm/node progress bars
/// - Multiple leading/trailing whitespace
/// - Debug log prefixes
fn clean_cli_output(text: &str) -> String {
let mut result = text.to_string();
// 1. Remove ANSI escape sequences
// Common patterns: \x1b[...m, \x1b[...;...m, ESC[...m
let ansi_regex = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap();
result = ansi_regex.replace_all(&result, "").to_string();
// 2. Remove npm/node progress bar characters
// Pattern: ████░░░░░░ 50% | some info
let progress_regex = regex::Regex::new(r"[█▓▒░│┼┤├┬┴]+[│].*?\r?\n").unwrap();
result = progress_regex.replace_all(&result, "").to_string();
// 3. Remove lines that are purely ANSI cursor control sequences
// Like \r (carriage return for overwriting), \x1b[?25l (hide cursor), etc.
let cursor_regex = regex::Regex::new(r"\x1b\[[?][0-9]+[a-zA-Z]").unwrap();
result = cursor_regex.replace_all(&result, "").to_string();
// 4. Remove "Download" / "Installing" progress prefixes common in npm
let npm_progress_regex = regex::Regex::new(r"^\s*(added|removed|changed|up to date)?\s*\d+\s*(package)?s?\s*(in\s+\d+s)?\s*(✓|✔|:)?\s*\r?$").unwrap();
result = npm_progress_regex.replace_all(&result, "").to_string();
// 5. Normalize line endings and remove empty lines at the start
let lines: Vec<&str> = result
.lines()
.map(|l| l.trim_end_matches(['\r', '\n']))
.collect();
// Skip leading empty/whitespace-only lines
let start_idx = lines.iter().position(|l| !l.trim().is_empty()).unwrap_or(0);
let relevant_lines = &lines[start_idx..];
// 6. Find the first line that starts JSON (fast path)
for line in relevant_lines {
let trimmed = line.trim();
if trimmed.starts_with('{') || trimmed.starts_with('[') {
return trimmed.to_string();
}
}
// 7. Otherwise, rejoin and let extract_json handle it
result
.lines()
.map(|l| l.trim())
.collect::<Vec<_>>()
.join("\n")
}
fn custom_skill_roots() -> Vec<(std::path::PathBuf, &'static str)> {
let mut roots = vec![(super::openclaw_dir().join("skills"), "OpenClaw 自定义")];
if let Some(home) = dirs::home_dir() {
let claude_skills = home.join(".claude").join("skills");
if !roots.iter().any(|(dir, _)| dir == &claude_skills) {
roots.push((claude_skills, "Claude 自定义"));
}
}
roots
}
fn resolve_custom_skill_dir(name: &str) -> Option<std::path::PathBuf> {
custom_skill_roots()
.into_iter()
.map(|(root, _)| root.join(name))
.find(|path| path.exists())
}
fn scan_custom_skill_detail(name: &str) -> Option<Value> {
for (root, source_label) in custom_skill_roots() {
let skill_path = root.join(name);
if !skill_path.exists() {
continue;
}
let base = scan_single_skill(&skill_path, name);
let missing_deps = base
.get("missingDeps")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let eligible = base.get("ready").and_then(|v| v.as_bool()).unwrap_or(false);
let mut detail = serde_json::json!({
"name": name,
"description": base.get("description").cloned().unwrap_or(Value::String(String::new())),
"emoji": base.get("emoji").cloned().unwrap_or(Value::String("🧩".to_string())),
"eligible": eligible,
"disabled": false,
"blockedByAllowlist": false,
"source": source_label,
"bundled": false,
"filePath": skill_path.to_string_lossy().to_string(),
"homepage": base.get("homepage").cloned().unwrap_or(Value::Null),
"version": base.get("version").cloned().unwrap_or(Value::Null),
"author": base.get("author").cloned().unwrap_or(Value::Null),
"dependencies": base.get("dependencies").cloned().unwrap_or(Value::Array(vec![])),
"missingDeps": Value::Array(missing_deps.clone()),
"missing": {
"bins": [],
"anyBins": [],
"env": [],
"config": [],
"os": []
},
"requirements": {
"bins": [],
"env": [],
"config": []
},
"install": []
});
if let Some(full_path) = base.get("fullPath").cloned() {
detail["fullPath"] = full_path;
}
return Some(detail);
}
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();
for (skills_dir, source_label) in custom_skill_roots() {
if !skills_dir.exists() {
continue;
}
let entries = std::fs::read_dir(&skills_dir).map_err(|e| {
format!(
"读取 Skills 目录失败 ({}): {e}",
skills_dir.to_string_lossy()
)
})?;
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_dir() && !file_type.is_symlink() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
let base = scan_single_skill(&entry.path(), &name);
let eligible = base.get("ready").and_then(|v| v.as_bool()).unwrap_or(false);
let mut item = serde_json::json!({
"name": name,
"description": base.get("description").cloned().unwrap_or(Value::String(String::new())),
"emoji": base.get("emoji").cloned().unwrap_or(Value::String("🧩".to_string())),
"eligible": eligible,
"disabled": false,
"blockedByAllowlist": false,
"source": source_label,
"bundled": false,
"filePath": entry.path().to_string_lossy().to_string(),
"homepage": base.get("homepage").cloned().unwrap_or(Value::Null),
"missing": {
"bins": [],
"anyBins": [],
"env": [],
"config": [],
"os": []
},
"missingDeps": base.get("missingDeps").cloned().unwrap_or(Value::Array(vec![])),
"install": []
});
if let Some(full_path) = base.get("fullPath").cloned() {
item["fullPath"] = full_path;
}
skills.push(item);
}
}
skills.sort_by(|a, b| {
let an = a.get("name").and_then(|v| v.as_str()).unwrap_or("");
let bn = b.get("name").and_then(|v| v.as_str()).unwrap_or("");
an.cmp(bn)
});
Ok(skills)
}
/// CLI 不可用或当前结果不可用时的兜底:扫描本地自定义 Skills 目录(含 ~/.openclaw/skills 与 ~/.claude/skills
fn scan_local_skills(cli_diagnostic: Option<Value>) -> Result<Value, String> {
let roots = custom_skill_roots();
let scanned_roots: Vec<String> = roots
.iter()
.map(|(dir, label)| format!("{}: {}", label, dir.to_string_lossy()))
.collect();
let skills = scan_local_skill_entries()?;
let cli_available = cli_diagnostic
.as_ref()
.and_then(|v| v.get("cliAvailable"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
if skills.is_empty() {
return Ok(serde_json::json!({
"skills": [],
"source": "local-scan",
"cliAvailable": false
"cliAvailable": cli_available,
"diagnostic": {
"status": cli_diagnostic.as_ref().and_then(|v| v.get("status")).and_then(|v| v.as_str()).unwrap_or("no-skills-dir"),
"message": "未在本地自定义目录中发现 Skills",
"scannedRoots": scanned_roots,
"cli": cli_diagnostic
}
}));
}
let mut skills = Vec::new();
if let Ok(entries) = std::fs::read_dir(&skills_dir) {
for entry in entries.flatten() {
let ft = match entry.file_type() {
Ok(ft) => ft,
Err(_) => continue,
};
if !ft.is_dir() && !ft.is_symlink() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
let skill_md = entry.path().join("SKILL.md");
let description = if skill_md.exists() {
// 尝试从 SKILL.md 的 frontmatter 中提取 description
parse_skill_description(&skill_md)
} else {
String::new()
};
skills.push(serde_json::json!({
"name": name,
"description": description,
"source": "managed",
"eligible": true,
"bundled": false,
"filePath": skill_md.to_string_lossy(),
}));
}
}
// 统计信息
let total = skills.len();
let ready_count = skills
.iter()
.filter(|s| s.get("eligible").and_then(|v| v.as_bool()).unwrap_or(false))
.count();
let missing_deps_count = skills
.iter()
.filter(|s| !s.get("eligible").and_then(|v| v.as_bool()).unwrap_or(false))
.count();
Ok(serde_json::json!({
"skills": skills,
"source": "local-scan",
"cliAvailable": false
"cliAvailable": cli_available,
"summary": {
"total": total,
"ready": ready_count,
"missingDeps": missing_deps_count,
},
"diagnostic": {
"status": cli_diagnostic.as_ref().and_then(|v| v.get("status")).and_then(|v| v.as_str()).unwrap_or("scanned"),
"scannedAt": chrono::Utc::now().to_rfc3339(),
"scannedRoots": scanned_roots,
"cli": cli_diagnostic
}
}))
}
/// 从 SKILL.md 的 YAML frontmatter 中提取 description
fn parse_skill_description(path: &std::path::Path) -> String {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return String::new(),
};
// frontmatter 格式: ---\n...\n---
if !content.starts_with("---") {
return String::new();
}
if let Some(end) = content[3..].find("---") {
let fm = &content[3..3 + end];
for line in fm.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("description:") {
return rest.trim().trim_matches('"').trim_matches('\'').to_string();
/// 扫描单个 Skill 的详细信息
fn scan_single_skill(skill_path: &std::path::Path, name: &str) -> Value {
let mut result = serde_json::json!({
"name": name,
"source": "managed",
"bundled": false,
"filePath": skill_path.to_string_lossy(),
"ready": false,
"missingDeps": [],
"installedDeps": [],
});
// 1. 检查必要文件
let skill_md = skill_path.join("SKILL.md");
let package_json = skill_path.join("package.json");
let has_skill_md = skill_md.exists();
let has_package_json = package_json.exists();
result["hasSkillMd"] = Value::Bool(has_skill_md);
result["hasPackageJson"] = Value::Bool(has_package_json);
// 2. 解析 package.json 获取更多信息
if has_package_json {
if let Ok(pkg_content) = std::fs::read_to_string(&package_json) {
if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&pkg_content) {
// 提取基本信息
if let Some(version) = pkg.get("version").and_then(|v| v.as_str()) {
result["version"] = Value::String(version.to_string());
}
if let Some(author) = pkg.get("author").and_then(|v| {
v.as_str().or_else(|| {
v.as_object()
.and_then(|o| o.get("name").and_then(|n| n.as_str()))
})
}) {
result["author"] = Value::String(author.to_string());
}
if let Some(desc) = pkg.get("description").and_then(|v| v.as_str()) {
result["description"] = Value::String(desc.to_string());
}
if let Some(homepage) = pkg.get("homepage").and_then(|v| v.as_str()) {
result["homepage"] = Value::String(homepage.to_string());
}
// 提取 dependencies
if let Some(deps) = pkg.get("dependencies").and_then(|v| v.as_object()) {
let deps_list: Vec<String> = deps.keys().cloned().collect();
result["dependencies"] =
Value::Array(deps_list.iter().map(|s| Value::String(s.clone())).collect());
// 检测缺少的依赖(简化版:通过检查 node_modules
let missing_deps = detect_missing_dependencies(&deps_list, skill_path);
result["missingDeps"] = Value::Array(
missing_deps
.iter()
.map(|s| Value::String(s.clone()))
.collect(),
);
result["installedDeps"] = Value::Array(
deps_list
.iter()
.filter(|d| !missing_deps.contains(d))
.map(|s| Value::String(s.clone()))
.collect(),
);
}
// 提取 scripts可能包含 install 后处理等)
if let Some(scripts) = pkg.get("scripts").and_then(|v| v.as_object()) {
let script_names: Vec<String> = scripts.keys().cloned().collect();
result["scripts"] = Value::Array(
script_names
.iter()
.map(|s| Value::String(s.clone()))
.collect(),
);
}
}
}
}
String::new()
// 3. 从 SKILL.md frontmatter 提取额外信息
if has_skill_md {
if let Some(frontmatter) = parse_skill_frontmatter(&skill_md) {
// 覆盖或补充 descriptionSKILL.md 的 description 更权威)
if let Some(desc) = frontmatter.get("description").and_then(|v| v.as_str()) {
result["description"] = Value::String(desc.to_string());
}
if let Some(full_path) = frontmatter.get("fullPath").and_then(|v| v.as_str()) {
result["fullPath"] = Value::String(full_path.to_string());
}
}
}
// 4. 判断 ready 状态
// Skill ready 需要1) 有 SKILL.md 2) 没有缺少依赖 3) 依赖已安装
let has_all_deps = result["missingDeps"]
.as_array()
.map(|a| a.is_empty())
.unwrap_or(true);
let has_essential_files = has_skill_md;
result["ready"] = Value::Bool(has_essential_files && has_all_deps);
// 5. 检测是否有 node_modulesnpm 包已安装)
let node_modules = skill_path.join("node_modules");
result["nodeModulesInstalled"] = Value::Bool(node_modules.exists());
result
}
/// 检测缺少的依赖
fn detect_missing_dependencies(deps: &[String], skill_path: &std::path::Path) -> Vec<String> {
let node_modules = skill_path.join("node_modules");
if !node_modules.exists() {
// node_modules 不存在,所有依赖都算缺失
return deps.to_vec();
}
let mut missing = Vec::new();
for dep in deps {
let dep_path = node_modules.join(dep);
// 检查依赖目录或 @scope/package 格式
if !dep_path.exists() {
// 可能是 @scope/package 格式,直接检查目录
missing.push(dep.clone());
}
}
missing
}
/// 解析 SKILL.md frontmatter返回键值对
fn parse_skill_frontmatter(path: &std::path::Path) -> Option<Value> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return None,
};
// frontmatter 格式: ---\n...\n---
if !content.starts_with("---") {
return None;
}
let after_first = content[3..].find("---")?;
let fm_content = &content[3..3 + after_first];
let mut fm_map = serde_json::Map::new();
for line in fm_content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || !trimmed.contains(':') {
continue;
}
if let Some(colon_pos) = trimmed.find(':') {
let key = trimmed[..colon_pos].trim().to_string();
let value = trimmed[colon_pos + 1..].trim();
// 处理引号包裹的值
let clean_value = value.trim_matches('"').trim_matches('\'').trim();
if !key.is_empty() && !clean_value.is_empty() {
fm_map.insert(key, Value::String(clean_value.to_string()));
}
}
}
if fm_map.is_empty() {
None
} else {
Some(Value::Object(fm_map))
}
}

View File

@@ -64,6 +64,7 @@ pub fn run() {
// 配置
config::read_openclaw_config,
config::write_openclaw_config,
config::validate_openclaw_config,
config::read_mcp_config,
config::write_mcp_config,
config::get_version_info,
@@ -160,10 +161,20 @@ pub fn run() {
messaging::remove_messaging_platform,
messaging::toggle_messaging_platform,
messaging::verify_bot_token,
messaging::diagnose_channel,
messaging::repair_qqbot_channel_setup,
messaging::list_configured_platforms,
messaging::get_channel_plugin_status,
messaging::install_channel_plugin,
messaging::install_qqbot_plugin,
messaging::run_channel_action,
messaging::check_weixin_plugin_status,
// Agent 渠道绑定管理
messaging::get_agent_bindings,
messaging::list_all_bindings,
messaging::save_agent_binding,
messaging::delete_agent_binding,
messaging::delete_agent_all_bindings,
// Skills 管理openclaw skills CLI
skills::skills_list,
skills::skills_info,
@@ -176,6 +187,7 @@ pub fn run() {
skills::skills_clawhub_search,
skills::skills_clawhub_install,
skills::skills_uninstall,
skills::skills_validate,
// 前端热更新
update::check_frontend_update,
update::download_frontend_update,