chore: release v0.9.7

This commit is contained in:
晴天
2026-03-21 04:08:41 +08:00
parent 1a18e3c644
commit 6494cf6551
22 changed files with 716 additions and 96 deletions

2
src-tauri/Cargo.lock generated
View File

@@ -340,7 +340,7 @@ dependencies = [
[[package]]
name = "clawpanel"
version = "0.9.6"
version = "0.9.7"
dependencies = [
"base64 0.22.1",
"chrono",

View File

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

View File

@@ -102,7 +102,7 @@ pub async fn list_agents() -> Result<Value, String> {
Ok(Value::Array(enriched))
}
/// 创建新 agent走 CLI自动创建 workspace/sessions 等文件
/// 创建新 agent优先走 CLI失败则直接写 openclaw.json 兜底
#[tauri::command]
pub async fn add_agent(
name: String,
@@ -128,29 +128,105 @@ pub async fn add_agent(
if !model.is_empty() {
args.push("--model".to_string());
args.push(model);
args.push(model.clone());
}
let output = openclaw_command_async()
.args(&args)
.output()
.await
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
"OpenClaw CLI 未找到,请确认已安装并重启 ClawPanel。".to_string()
} else {
format!("执行失败: {e}")
}
})?;
// 尝试 CLI15s 超时),失败则直接写配置兜底
let cli_ok = match tokio::time::timeout(
std::time::Duration::from_secs(15),
openclaw_command_async().args(&args).output(),
)
.await
{
Ok(Ok(o)) if o.status.success() => true,
Ok(Ok(o)) => {
let stderr = String::from_utf8_lossy(&o.stderr);
eprintln!(
"[agent] CLI 创建失败: {}",
stderr.chars().take(200).collect::<String>()
);
false
}
Ok(Err(e)) => {
eprintln!("[agent] CLI 执行错误: {e}");
false
}
Err(_) => {
eprintln!("[agent] CLI 超时 (15s)");
false
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("创建 Agent 失败: {stderr}"));
if !cli_ok {
// 兜底:直接写 openclaw.json
add_agent_to_config(&name, &model, &ws)?;
}
// 确保 workspace 目录存在
if !ws.exists() {
let _ = fs::create_dir_all(&ws);
}
list_agents().await
}
/// 直接写 openclaw.json 创建 agentCLI 不可用时的兜底方案)
fn add_agent_to_config(id: &str, model: &str, workspace: &std::path::Path) -> Result<(), String> {
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 mut config: Value =
serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {e}"))?;
// 确保 agents.list 存在
if config.get("agents").is_none() {
config
.as_object_mut()
.ok_or("配置格式错误")?
.insert("agents".to_string(), serde_json::json!({}));
}
if config["agents"].get("list").is_none() {
config["agents"]
.as_object_mut()
.ok_or("agents 格式错误")?
.insert("list".to_string(), serde_json::json!([]));
}
let list = config["agents"]["list"]
.as_array_mut()
.ok_or("agents.list 格式错误")?;
// 检查是否已存在同名 agent
let exists = list
.iter()
.any(|a| a.get("id").and_then(|v| v.as_str()) == Some(id));
if exists {
return Err(format!("Agent「{id}」已存在"));
}
let mut agent = serde_json::json!({
"id": id,
"workspace": workspace.to_string_lossy(),
});
if !model.is_empty() {
agent
.as_object_mut()
.unwrap()
.insert("model".to_string(), serde_json::json!({ "primary": model }));
}
list.push(agent);
// 备份 + 写回
let bak = super::openclaw_dir().join("openclaw.json.bak");
let _ = fs::copy(&config_path, &bak);
let json = serde_json::to_string_pretty(&config).map_err(|e| format!("序列化失败: {e}"))?;
fs::write(&config_path, json).map_err(|e| format!("写入配置失败: {e}"))?;
Ok(())
}
/// 删除 agent直接操作 openclaw.json + 删除 agent 目录,不走 CLI
#[tauri::command]
pub async fn delete_agent(id: String) -> Result<String, String> {

View File

@@ -2587,6 +2587,66 @@ pub async fn restart_gateway() -> Result<String, String> {
reload_gateway().await
}
/// 运行 openclaw doctor --fix 自动修复配置问题
#[tauri::command]
pub async fn doctor_fix() -> Result<Value, String> {
use crate::utils::openclaw_command_async;
let result = tokio::time::timeout(
std::time::Duration::from_secs(30),
openclaw_command_async().args(["doctor", "--fix"]).output(),
)
.await;
match result {
Ok(Ok(o)) => {
let stdout = String::from_utf8_lossy(&o.stdout).to_string();
let stderr = String::from_utf8_lossy(&o.stderr).to_string();
let success = o.status.success();
Ok(json!({
"success": success,
"output": stdout.trim(),
"errors": stderr.trim(),
"exitCode": o.status.code(),
}))
}
Ok(Err(e)) => {
if e.kind() == std::io::ErrorKind::NotFound {
Err("OpenClaw CLI 未找到,请先安装".to_string())
} else {
Err(format!("执行 doctor 失败: {e}"))
}
}
Err(_) => Err("doctor --fix 执行超时 (30s)".to_string()),
}
}
/// 运行 openclaw doctor仅诊断不修复
#[tauri::command]
pub async fn doctor_check() -> Result<Value, String> {
use crate::utils::openclaw_command_async;
let result = tokio::time::timeout(
std::time::Duration::from_secs(20),
openclaw_command_async().args(["doctor"]).output(),
)
.await;
match result {
Ok(Ok(o)) => {
let stdout = String::from_utf8_lossy(&o.stdout).to_string();
let stderr = String::from_utf8_lossy(&o.stderr).to_string();
Ok(json!({
"success": o.status.success(),
"output": stdout.trim(),
"errors": stderr.trim(),
}))
}
Ok(Err(e)) => Err(format!("执行 doctor 失败: {e}")),
Err(_) => Err("doctor 执行超时 (20s)".to_string()),
}
}
/// 清理 base URL去掉尾部斜杠和已知端点路径防止用户粘贴完整端点 URL 导致路径重复
fn normalize_base_url(raw: &str) -> String {
let mut base = raw.trim_end_matches('/').to_string();
@@ -3080,9 +3140,25 @@ pub async fn check_panel_update() -> Result<Value, String> {
// === 面板配置 (clawpanel.json) ===
/// 获取当前生效的 OpenClaw 配置目录路径
#[tauri::command]
pub fn get_openclaw_dir() -> Result<Value, String> {
let resolved = super::openclaw_dir();
let is_custom = super::read_panel_config_value()
.and_then(|v| v.get("openclawDir")?.as_str().map(String::from))
.map(|s| !s.is_empty())
.unwrap_or(false);
let config_exists = resolved.join("openclaw.json").exists();
Ok(json!({
"path": resolved.to_string_lossy(),
"isCustom": is_custom,
"configExists": config_exists,
}))
}
#[tauri::command]
pub fn read_panel_config() -> Result<Value, String> {
let path = super::openclaw_dir().join("clawpanel.json");
let path = super::panel_config_path();
if !path.exists() {
return Ok(serde_json::json!({}));
}
@@ -3092,15 +3168,29 @@ pub fn read_panel_config() -> Result<Value, String> {
#[tauri::command]
pub fn write_panel_config(config: Value) -> Result<(), String> {
let dir = super::openclaw_dir();
if !dir.exists() {
fs::create_dir_all(&dir).map_err(|e| format!("创建目录失败: {e}"))?;
let path = super::panel_config_path();
if let Some(dir) = path.parent() {
if !dir.exists() {
fs::create_dir_all(dir).map_err(|e| format!("创建目录失败: {e}"))?;
}
}
let path = dir.join("clawpanel.json");
let json = serde_json::to_string_pretty(&config).map_err(|e| format!("序列化失败: {e}"))?;
fs::write(&path, json).map_err(|e| format!("写入失败: {e}"))
}
/// 重启应用(用于设置变更后自动重启)
#[tauri::command]
pub async fn relaunch_app(app: tauri::AppHandle) -> Result<(), String> {
let exe = std::env::current_exe().map_err(|e| format!("获取可执行文件路径失败: {e}"))?;
std::process::Command::new(&exe)
.spawn()
.map_err(|e| format!("重启失败: {e}"))?;
// 短暂延迟后退出当前进程
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
app.exit(0);
Ok(())
}
/// 测试代理连通性:通过配置的代理访问指定 URL返回状态码和耗时
#[tauri::command]
pub async fn test_proxy(url: Option<String>) -> Result<Value, String> {

View File

@@ -64,6 +64,8 @@ async fn agent_workspace(agent_id: &str) -> Result<PathBuf, String> {
.and_then(|w| w.as_str())
.map(PathBuf::from)
.unwrap_or_else(|| super::openclaw_dir().join("workspace"));
// 解析符号链接
let default_workspace = fs::canonicalize(&default_workspace).unwrap_or(default_workspace);
let mut new_map = HashMap::new();
// main agent 使用默认 workspace
@@ -93,6 +95,8 @@ async fn agent_workspace(agent_id: &str) -> Result<PathBuf, String> {
.join("workspace")
}
});
// 解析符号链接,确保软连接的 workspace 也能正确访问
let ws = fs::canonicalize(&ws).unwrap_or(ws);
new_map.insert(id.to_string(), ws);
}
}

View File

@@ -16,13 +16,32 @@ pub mod service;
pub mod skills;
pub mod update;
/// 获取 OpenClaw 配置目录 (~/.openclaw/)
pub fn openclaw_dir() -> PathBuf {
/// 默认 OpenClaw 配置目录ClawPanel 自身配置始终在此)
fn default_openclaw_dir() -> PathBuf {
dirs::home_dir().unwrap_or_default().join(".openclaw")
}
/// 获取 OpenClaw 配置目录
/// 优先使用 clawpanel.json 中的 openclawDir 自定义路径,不存在则回退默认 ~/.openclaw
pub fn openclaw_dir() -> PathBuf {
// 直接读 clawpanel.json始终在默认目录下避免循环依赖
let config_path = default_openclaw_dir().join("clawpanel.json");
if let Ok(content) = std::fs::read_to_string(&config_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(custom) = v.get("openclawDir").and_then(|d| d.as_str()) {
let p = PathBuf::from(custom);
if !custom.is_empty() && p.exists() {
return p;
}
}
}
}
default_openclaw_dir()
}
fn panel_config_path() -> PathBuf {
openclaw_dir().join("clawpanel.json")
// ClawPanel 自身配置始终在默认目录,不随 openclawDir 变化
default_openclaw_dir().join("clawpanel.json")
}
fn read_panel_config_value() -> Option<serde_json::Value> {
@@ -184,14 +203,15 @@ fn build_enhanced_path() -> String {
#[cfg(target_os = "macos")]
{
// 版本管理器路径优先于系统路径,确保 nvm/volta/fnm 管理的 Node.js 版本被优先检测到
let mut extra: Vec<String> = vec![
"/usr/local/bin".into(),
"/opt/homebrew/bin".into(),
format!("{}/.nvm/current/bin", home.display()),
format!("{}/.volta/bin", home.display()),
format!("{}/.nodenv/shims", home.display()),
format!("{}/n/bin", home.display()),
format!("{}/.npm-global/bin", home.display()),
"/usr/local/bin".into(),
"/opt/homebrew/bin".into(),
];
// NPM_CONFIG_PREFIX: 用户通过 npm config set prefix 自定义的全局安装路径
if let Ok(prefix) = std::env::var("NPM_CONFIG_PREFIX") {
@@ -238,16 +258,17 @@ fn build_enhanced_path() -> String {
#[cfg(target_os = "linux")]
{
// 版本管理器路径优先于系统路径,确保 nvm/volta/fnm 管理的 Node.js 版本被优先检测到
let mut extra: Vec<String> = vec![
"/usr/local/bin".into(),
"/usr/bin".into(),
"/snap/bin".into(),
format!("{}/.local/bin", home.display()),
format!("{}/.nvm/current/bin", home.display()),
format!("{}/.volta/bin", home.display()),
format!("{}/.nodenv/shims", home.display()),
format!("{}/n/bin", home.display()),
format!("{}/.npm-global/bin", home.display()),
format!("{}/.local/bin", home.display()),
"/usr/local/bin".into(),
"/usr/bin".into(),
"/snap/bin".into(),
];
// NPM_CONFIG_PREFIX: 用户通过 npm config set prefix 自定义的全局安装路径
if let Ok(prefix) = std::env::var("NPM_CONFIG_PREFIX") {
@@ -316,35 +337,17 @@ fn build_enhanced_path() -> String {
let localappdata = std::env::var("LOCALAPPDATA").unwrap_or_default();
let appdata = std::env::var("APPDATA").unwrap_or_default();
let mut extra: Vec<String> = vec![format!(r"{}\nodejs", pf), format!(r"{}\nodejs", pf86)];
if !localappdata.is_empty() {
extra.push(format!(r"{}\Programs\nodejs", localappdata));
extra.push(format!(r"{}\fnm_multishells", localappdata));
}
if !appdata.is_empty() {
extra.push(format!(r"{}\npm", appdata));
extra.push(format!(r"{}\nvm", appdata));
// 扫描 nvm-windows 实际安装的版本目录
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();
if p.is_dir() && p.join("node.exe").exists() {
extra.push(p.to_string_lossy().to_string());
}
}
}
}
}
// NVM_SYMLINK 环境变量nvm-windows 的活跃版本符号链接,如 D:\nodejs
// 版本管理器路径优先,确保 nvm/volta/fnm 管理的 Node.js 被优先检测到
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());
}
}
// NVM_HOME 环境变量(用户可能自定义 nvm 安装目录)
// 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() {
@@ -358,8 +361,27 @@ fn build_enhanced_path() -> String {
}
}
}
// 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();
if p.is_dir() && p.join("node.exe").exists() {
extra.push(p.to_string_lossy().to_string());
}
}
}
}
}
// 4. volta
extra.push(format!(r"{}\.volta\bin", home.display()));
// fnm: 扫描 %FNM_DIR% 或默认 %APPDATA%\fnm 下的版本目录
// 5. fnm
if !localappdata.is_empty() {
extra.push(format!(r"{}\fnm_multishells", localappdata));
}
let fnm_base = std::env::var("FNM_DIR")
.ok()
.map(std::path::PathBuf::from)
@@ -375,8 +397,17 @@ fn build_enhanced_path() -> String {
}
}
}
// 扫描常见盘符下的 Node 安装(用户可能装在 D:\、F:\ 等)
// 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));

View File

@@ -8,20 +8,35 @@ use std::os::windows::process::CommandExt;
/// 列出所有 Skills 及其状态openclaw skills list --json
#[tauri::command]
pub async fn skills_list() -> Result<Value, String> {
let output = openclaw_command_async()
.args(["skills", "list", "--json"])
.output()
.await;
let output = tokio::time::timeout(
std::time::Duration::from_secs(15),
openclaw_command_async()
.args(["skills", "list", "--json"])
.output(),
)
.await;
match output {
Ok(o) if o.status.success() => {
Ok(Ok(o)) => {
let stdout = String::from_utf8_lossy(&o.stdout);
// CLI output may contain non-JSON lines (Node warnings, update prompts).
// Extract the first valid JSON object or array from stdout.
extract_json(&stdout).ok_or_else(|| "解析失败: 输出中未找到有效 JSON".to_string())
// CLI 可能在有 skill 缺依赖时返回非零退出码,但 JSON 输出仍然有效
// 优先尝试解析 JSON无论退出码
match extract_json(&stdout) {
Some(v) => Ok(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()
}
}
}
_ => {
// CLI 不可用时,兜底扫描本地 skills 目录
// CLI 不可用或超时,兜底扫描本地 skills 目录
scan_local_skills()
}
}
@@ -144,14 +159,14 @@ pub async fn skills_skillhub_check() -> Result<Value, String> {
#[cfg(target_os = "windows")]
let mut cmd = {
let mut c = tokio::process::Command::new("cmd");
c.args(["/c", "skillhub", "--cli-version"]);
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("--cli-version");
c.arg("--version");
c
};
cmd.env("PATH", &path_env);

View File

@@ -89,6 +89,7 @@ pub fn run() {
config::uninstall_gateway,
config::patch_model_vision,
config::check_panel_update,
config::get_openclaw_dir,
config::read_panel_config,
config::write_panel_config,
config::test_proxy,
@@ -99,6 +100,9 @@ pub fn run() {
config::configure_git_https,
config::invalidate_path_cache,
config::get_status_summary,
config::doctor_fix,
config::doctor_check,
config::relaunch_app,
// 设备密钥 + Gateway 握手
device::create_connect_frame,
// 设备配对
@@ -178,6 +182,13 @@ pub fn run() {
update::rollback_frontend_update,
update::get_update_status,
])
.on_window_event(|window, event| {
// 关闭窗口时最小化到托盘,不退出应用
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
})
.build(tauri::generate_context!())
.expect("启动 ClawPanel 失败")
.run(|_app, event| {

View File

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