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

View File

@@ -5,6 +5,32 @@
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
## [0.9.7] - 2026-03-21
### 新功能 (Features)
- **Markdown 表格渲染** — 聊天消息中的 Markdown 表格以 HTML 表格形式展示,支持表头高亮、斑马纹、悬停效果 (#112)
- **Doctor 一键诊断修复** — 新增 `openclaw doctor --fix``openclaw doctor` 后端命令,支持从面板内自动检测和修复配置问题 (#103)
- **自定义 OpenClaw 安装路径** — 初始设置和服务管理页支持自定义 OpenClaw 配置目录(如 `E:\数据\AI\.openclaw`),解决非默认安装位置的检测问题
- **关闭窗口最小化到托盘** — 关闭主窗口时最小化到系统托盘,不退出应用
- **应用重启命令** — 设置变更后支持从面板内一键重启应用
### 修复 (Fixes)
- **Agent 创建失败兜底** — CLI 创建 Agent 超时或失败时,自动降级为直接写 openclaw.json不再因 CLI 卡死导致创建失败
- **模型 API 类型自动修复** — 保存时自动将错误的 API 类型(如 `google-gemini``google-generative-ai`)修正为上游支持的格式 (#97)
- **SkillHub 安装状态竞态** — 搜索时先实时检测 SkillHub 安装状态,避免缓存误判导致误报"请先安装"
- **聊天响应看门狗** — 发送消息后 15 秒无 delta 事件自动刷新历史,防止响应丢失时 UI 卡在等待状态
- **Clippy 编译警告** — 修复 dead_code 和 manual_flatten 编译警告,代码更规范
### 改进 (Improvements)
- **模型配置可折叠** — 每个服务商区块支持折叠/展开,模型多时页面更清爽 (#98)
- **PATH 检测优先级优化** — macOS/Linux/Windows 均调整为版本管理器路径nvm/volta/fnm优先于系统路径减少环境检测误判
- **聊天 typing 提示增强** — 等待回复时支持显示工具调用等状态提示文字
- **官网内容更新** — 新增 apple-touch-icon、布局优化、图标资源重命名解决浏览器缓存问题
- **贡献者致谢** — README 和 CONTRIBUTING.md 新增历史贡献者致谢名单及维护指南
## [0.9.6] - 2026-03-18
### 修复 (Fixes)

View File

@@ -34,7 +34,7 @@
"description": "OpenClaw AI Agent 可视化管理面板,基于 Tauri v2 的跨平台桌面应用。支持仪表盘监控、多模型配置、消息渠道管理、内置 QQ 机器人、实时 AI 聊天、记忆管理、Agent 管理、网关配置、内网穿透等功能。",
"url": "https://claw.qt.cool/",
"downloadUrl": "https://github.com/qingchencloud/clawpanel/releases/latest",
"softwareVersion": "0.9.6",
"softwareVersion": "0.9.7",
"author": {
"@type": "Organization",
"name": "晴辰云 QingchenCloud",
@@ -1133,7 +1133,7 @@
<div class="orb orb-2" style="top:auto;bottom:-100px"></div>
<div class="container-sm" style="position:relative;z-index:10">
<div class="section-header">
<div class="reveal download-version"><span class="pulse"></span> v0.9.6 最新版</div>
<div class="reveal download-version"><span class="pulse"></span> v0.9.7 最新版</div>
<h2 class="reveal section-title"><span class="gradient-text">下载安装</span></h2>
<p class="reveal section-desc">选择你的操作系统,一键下载安装</p>
</div>
@@ -1143,11 +1143,11 @@
<h3>macOS</h3>
<p class="dl-desc">支持 Apple Silicon 和 Intel 芯片</p>
<div class="dl-links">
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.6_aarch64.dmg" target="_blank" rel="noopener">
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.7_aarch64.dmg" target="_blank" rel="noopener">
Apple Silicon (M1/M2/M3/M4)
<span class="dl-format">.dmg</span>
</a>
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.6_x64.dmg" target="_blank" rel="noopener">
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.7_x64.dmg" target="_blank" rel="noopener">
Intel 芯片
<span class="dl-format">.dmg</span>
</a>
@@ -1165,11 +1165,11 @@
<h3>Windows</h3>
<p class="dl-desc">支持 Windows 10 及以上版本</p>
<div class="dl-links">
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.6_x64-setup.exe" target="_blank" rel="noopener">
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.7_x64-setup.exe" target="_blank" rel="noopener">
安装程序
<span class="dl-format">.exe</span>
</a>
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.6_x64_en-US.msi" target="_blank" rel="noopener">
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.7_x64_en-US.msi" target="_blank" rel="noopener">
MSI 安装包
<span class="dl-format">.msi</span>
</a>
@@ -1180,11 +1180,11 @@
<h3>Linux</h3>
<p class="dl-desc">支持主流 Linux 发行版</p>
<div class="dl-links">
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.6_amd64.AppImage" target="_blank" rel="noopener">
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.7_amd64.AppImage" target="_blank" rel="noopener">
通用版
<span class="dl-format">.AppImage</span>
</a>
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.6_amd64.deb" target="_blank" rel="noopener">
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.7_amd64.deb" target="_blank" rel="noopener">
Debian / Ubuntu
<span class="dl-format">.deb</span>
</a>

View File

@@ -1,6 +1,6 @@
{
"name": "clawpanel",
"version": "0.9.6",
"version": "0.9.7",
"private": true,
"description": "ClawPanel - OpenClaw 可视化管理面板,基于 Tauri v2 的跨平台桌面应用",
"type": "module",

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",

View File

@@ -181,6 +181,8 @@ export const api = {
writeMcpConfig: (config) => { invalidate('read_mcp_config'); return invoke('write_mcp_config', { config }) },
reloadGateway: () => invoke('reload_gateway'),
restartGateway: () => invoke('restart_gateway'),
doctorCheck: () => invoke('doctor_check'),
doctorFix: () => invoke('doctor_fix'),
listOpenclawVersions: (source = 'chinese') => invoke('list_openclaw_versions', { source }),
upgradeOpenclaw: (source = 'chinese', version = null, method = 'auto') => invoke('upgrade_openclaw', { source, version, method }),
uninstallOpenclaw: (cleanConfig = false) => invoke('uninstall_openclaw', { cleanConfig }),
@@ -222,6 +224,8 @@ export const api = {
installChannelPlugin: (packageName, pluginId) => invoke('install_channel_plugin', { packageName, pluginId }),
// 面板配置 (clawpanel.json)
getOpenclawDir: () => invoke('get_openclaw_dir'),
relaunchApp: () => invoke('relaunch_app'),
readPanelConfig: () => invoke('read_panel_config'),
writePanelConfig: (config) => invoke('write_panel_config', { config }),
testProxy: (url) => invoke('test_proxy', { url: url || null }),

View File

@@ -162,8 +162,14 @@ async function showAddAgentDialog(page, state) {
try {
await api.addAgent(id, model, workspace || null)
// 身份信息更新(非关键,失败不阻塞)
if (name || emoji) {
await api.updateAgentIdentity(id, name || null, emoji || null)
try {
await api.updateAgentIdentity(id, name || null, emoji || null)
} catch (identityErr) {
console.warn('[Agent] 身份信息更新失败Agent 已创建):', identityErr)
toast('Agent 已创建,但名称设置失败,可稍后编辑', 'warning')
}
}
toast('Agent 已创建', 'success')

View File

@@ -371,7 +371,7 @@ async function openConfigDialog(pid, page, state) {
// 飞书插件版本检测:根据已安装的插件自动选择
if (pid === 'feishu' && !existing.pluginVersion) {
try {
const officialStatus = await api.getChannelPluginStatus('feishu-openclaw-plugin')
const officialStatus = await api.getChannelPluginStatus('openclaw-lark') || await api.getChannelPluginStatus('feishu-openclaw-plugin')
if (officialStatus?.installed) existing.pluginVersion = 'official'
else existing.pluginVersion = localStorage.getItem('clawpanel-feishu-plugin-version') || 'builtin'
} catch { existing.pluginVersion = 'builtin' }
@@ -597,8 +597,8 @@ async function openConfigDialog(pid, page, state) {
const pluginVersion = pluginVersionField?.value || 'builtin'
localStorage.setItem('clawpanel-feishu-plugin-version', pluginVersion)
if (pluginVersion === 'official') {
pluginPackage = '@larksuiteoapi/feishu-openclaw-plugin'
pluginId = 'feishu-openclaw-plugin'
pluginPackage = 'openclaw-lark'
pluginId = 'openclaw-lark'
}
}
const pluginStatus = await api.getChannelPluginStatus(pluginId)

View File

@@ -6,23 +6,46 @@ import { api, getRequestLogs, clearRequestLogs } from '../lib/tauri-api.js'
import { wsClient } from '../lib/ws-client.js'
import { isOpenclawReady, isGatewayRunning } from '../lib/app-state.js'
import { icon, statusIcon } from '../lib/icons.js'
import { toast } from '../components/toast.js'
import { navigate } from '../router.js'
export async function render() {
const page = document.createElement('div')
page.className = 'page'
page.innerHTML = `
<div class="page-header">
<div class="page-header" style="margin-bottom:var(--space-lg)">
<h1 class="page-title">系统诊断</h1>
<p class="page-desc">全面检测系统状态,快速定位问题</p>
<div style="display:flex;gap:8px">
<p class="page-desc" style="margin-bottom:1em">全面检测系统状态,快速定位问题</p>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-primary btn-sm" id="btn-refresh">刷新状态</button>
<button class="btn btn-secondary btn-sm" id="btn-doctor-check">诊断配置</button>
<button class="btn btn-warning btn-sm" id="btn-doctor-fix">自动修复</button>
<button class="btn btn-secondary btn-sm" id="btn-test-ws">测试 WebSocket</button>
<button class="btn btn-secondary btn-sm" id="btn-network-log">网络日志</button>
<button class="btn btn-warning btn-sm" id="btn-fix-pairing">一键修复配对</button>
<button class="btn btn-secondary btn-sm" id="btn-fix-pairing">一键修复配对</button>
</div>
</div>
<div id="debug-content">
<div class="config-section" style="border-left:3px solid var(--border)">
<div style="display:flex;gap:var(--space-sm);align-items:center">
<div class="loading-placeholder" style="width:24px;height:24px;border-radius:50%"></div>
<div class="loading-placeholder" style="width:120px;height:20px;border-radius:4px"></div>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:var(--space-md)">
<div class="config-section"><div class="config-section-title" style="margin-bottom:8px">应用状态</div><div class="loading-placeholder" style="height:48px;border-radius:4px"></div></div>
<div class="config-section"><div class="config-section-title" style="margin-bottom:8px">WebSocket 连接</div><div class="loading-placeholder" style="height:48px;border-radius:4px"></div></div>
<div class="config-section"><div class="config-section-title" style="margin-bottom:8px">Node.js 环境</div><div class="loading-placeholder" style="height:48px;border-radius:4px"></div></div>
<div class="config-section"><div class="config-section-title" style="margin-bottom:8px">版本信息</div><div class="loading-placeholder" style="height:48px;border-radius:4px"></div></div>
</div>
</div>
<div id="doctor-output" style="display:none;margin-top:var(--space-md)">
<div class="config-section">
<div class="config-section-title">配置诊断输出</div>
<pre style="background:var(--bg-secondary);border-radius:var(--radius);padding:var(--space-sm);font-size:var(--font-size-xs);max-height:300px;overflow:auto;white-space:pre-wrap;word-break:break-all"></pre>
</div>
</div>
<div id="debug-content"></div>
<div id="ws-test-log" style="display:none;margin-top:16px;background:var(--bg-secondary);border-radius:6px;padding:12px">
<div style="font-weight:600;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center">
<span>WebSocket 连接测试</span>
@@ -46,6 +69,8 @@ export async function render() {
page.querySelector('#btn-test-ws').addEventListener('click', () => testWebSocket(page))
page.querySelector('#btn-network-log').addEventListener('click', () => toggleNetworkLog(page))
page.querySelector('#btn-fix-pairing').addEventListener('click', () => fixPairing(page))
page.querySelector('#btn-doctor-check').addEventListener('click', () => handleDoctor(page, false))
page.querySelector('#btn-doctor-fix').addEventListener('click', () => handleDoctor(page, true))
loadDebugInfo(page)
return page
}
@@ -261,6 +286,69 @@ function renderDebugInfo(el, info) {
el.innerHTML = html
}
// 配置诊断 / 自动修复openclaw doctor
async function handleDoctor(page, fix) {
const btnCheck = page.querySelector('#btn-doctor-check')
const btnFix = page.querySelector('#btn-doctor-fix')
const outputDiv = page.querySelector('#doctor-output')
const section = outputDiv?.querySelector('.config-section')
const pre = outputDiv?.querySelector('pre')
if (!outputDiv || !pre) return
// 清除之前的提示
section?.querySelectorAll('.doctor-tip').forEach(el => el.remove())
if (btnCheck) btnCheck.disabled = true
if (btnFix) btnFix.disabled = true
if (fix && btnFix) btnFix.textContent = '修复中...'
if (!fix && btnCheck) btnCheck.textContent = '诊断中...'
outputDiv.style.display = 'block'
pre.textContent = fix ? '正在运行 openclaw doctor --fix ...' : '正在运行 openclaw doctor ...'
pre.style.color = 'var(--text-secondary)'
try {
const result = fix ? await api.doctorFix() : await api.doctorCheck()
let text = result.output || ''
if (result.errors) text += '\n' + result.errors
const fullText = text.trim()
pre.textContent = fullText || (result.success ? '✓ 未发现问题' : '诊断完成')
pre.style.color = result.success ? 'var(--success)' : 'var(--warning)'
if (fullText.includes('ERR_MODULE_NOT_FOUND') || fullText.includes('Cannot find module')) {
appendDoctorTip(section, 'OpenClaw 安装可能已损坏', '检测到模块文件缺失,建议前往 <a href="#" data-nav="about" style="color:var(--primary);text-decoration:underline;font-weight:500">关于页面</a> 切换版本或重新安装 OpenClaw CLI。')
toast('OpenClaw 安装损坏,建议前往「关于」页重新安装', 'warning')
} else if (fix && result.success) {
toast('配置修复完成', 'success')
} else if (fix) {
toast('修复完成,部分问题可能需手动处理', 'warning')
}
} catch (e) {
const errMsg = e?.message || String(e)
pre.textContent = '执行失败: ' + errMsg
pre.style.color = 'var(--error)'
if (errMsg.includes('ERR_MODULE_NOT_FOUND') || errMsg.includes('Cannot find module') || errMsg.includes('未找到')) {
appendDoctorTip(section, 'OpenClaw CLI 不可用', '请前往 <a href="#" data-nav="about" style="color:var(--primary);text-decoration:underline;font-weight:500">关于页面</a> 安装或重新安装 OpenClaw。')
}
toast('执行失败: ' + e, 'error')
} finally {
if (btnCheck) { btnCheck.disabled = false; btnCheck.textContent = '诊断配置' }
if (btnFix) { btnFix.disabled = false; btnFix.textContent = '自动修复' }
}
}
function appendDoctorTip(parent, title, body) {
if (!parent) return
const tip = document.createElement('div')
tip.className = 'doctor-tip'
tip.style.cssText = 'margin-top:var(--space-sm);padding:var(--space-sm);background:rgba(239,68,68,0.08);border-radius:var(--radius);font-size:var(--font-size-sm);color:var(--error);line-height:1.6'
tip.innerHTML = `<strong>⚠ ${title}</strong><br>${body}`
tip.querySelector('[data-nav="about"]')?.addEventListener('click', (e) => {
e.preventDefault()
navigate('/about')
})
parent.appendChild(tip)
}
function escapeHtml(str) {
if (!str) return ''
return String(str)

View File

@@ -72,6 +72,7 @@ const _toolEventData = new Map()
const _toolRunIndex = new Map()
const _toolEventSeen = new Set()
let _errorTimer = null, _lastErrorMsg = null
let _responseWatchdog = null, _postFinalCheck = null
let _attachments = []
let _hasEverConnected = false
let _availableModels = []
@@ -155,6 +156,7 @@ export async function render() {
<div class="chat-messages" id="chat-messages">
<div class="typing-indicator" id="typing-indicator" style="display:none">
<span></span><span></span><span></span>
<span class="typing-hint"></span>
</div>
</div>
<button class="chat-scroll-btn" id="chat-scroll-btn" style="display:none">↓</button>
@@ -1000,10 +1002,12 @@ async function doSend(text, attachments = []) {
})
showTyping(true)
_isSending = true
_startResponseWatchdog()
try {
await wsClient.chatSend(_sessionKey, text, attachments.length ? attachments : undefined)
} catch (err) {
showTyping(false)
_cancelResponseWatchdog()
appendSystemMessage('发送失败: ' + err.message)
} finally {
_isSending = false
@@ -1046,6 +1050,11 @@ function handleEvent(msg) {
if (!list.includes(toolCallId)) list.push(toolCallId)
_toolRunIndex.set(payload.runId, list)
}
// 工具执行反馈:更新 typing 提示文字
const toolName = payload.data?.name || payload.data?.toolName || ''
if (toolName && !_isStreaming) {
showTyping(true, `正在使用工具: ${toolName}`)
}
}
if (event === 'chat') handleChatEvent(payload)
@@ -1079,6 +1088,7 @@ function handleChatEvent(payload) {
}
if (state === 'delta') {
_cancelResponseWatchdog()
const c = extractChatContent(payload.message)
if (c?.images?.length) _currentAiImages = c.images
if (c?.videos?.length) _currentAiVideos = c.videos
@@ -1114,6 +1124,7 @@ function handleChatEvent(payload) {
}
if (state === 'final') {
_cancelResponseWatchdog()
const c = extractChatContent(payload.message)
const finalText = c?.text || ''
const finalImages = c?.images || []
@@ -1205,6 +1216,7 @@ function handleChatEvent(payload) {
}
}
resetStreamState()
_schedulePostFinalCheck()
processMessageQueue()
return
}
@@ -1464,6 +1476,45 @@ function doRender() {
}
}
// ── 响应看门狗:防止页面卡在等待状态 ──
function _startResponseWatchdog() {
_cancelResponseWatchdog()
_responseWatchdog = setTimeout(async () => {
_responseWatchdog = null
// 如果还在等待(未开始流式),强制刷新历史
if (!_isStreaming && _sessionKey && _messagesEl && _pageActive) {
console.log('[chat] 响应看门狗触发15s 无 delta刷新历史')
const oldHash = _lastHistoryHash
_lastHistoryHash = ''
await loadHistory()
// 如果历史有更新,关闭 typing 指示器
if (_lastHistoryHash && _lastHistoryHash !== oldHash) {
showTyping(false)
} else {
// 历史没更新,继续等待,再设一轮看门狗
_startResponseWatchdog()
}
}
}, 15000)
}
function _cancelResponseWatchdog() {
clearTimeout(_responseWatchdog)
_responseWatchdog = null
}
function _schedulePostFinalCheck() {
clearTimeout(_postFinalCheck)
_postFinalCheck = setTimeout(async () => {
_postFinalCheck = null
if (_sessionKey && _messagesEl && _pageActive && !_isStreaming && !_isSending) {
_lastHistoryHash = ''
await loadHistory()
}
}, 2000)
}
// ensureAiBubble 已被 createStreamBubble 替代
function resetStreamState() {
@@ -1986,8 +2037,13 @@ function clearMessages() {
_lastScrollTop = 0
}
function showTyping(show) {
if (_typingEl) _typingEl.style.display = show ? 'flex' : 'none'
function showTyping(show, hint) {
if (_typingEl) {
_typingEl.style.display = show ? 'flex' : 'none'
// 更新提示文字(如工具调用状态)
const hintEl = _typingEl.querySelector('.typing-hint')
if (hintEl) hintEl.textContent = hint || ''
}
if (show) scrollToBottom()
}
@@ -2430,6 +2486,9 @@ export function cleanup() {
if (_unsubReady) { _unsubReady(); _unsubReady = null }
if (_unsubStatus) { _unsubStatus(); _unsubStatus = null }
clearTimeout(_streamSafetyTimer)
_cancelResponseWatchdog()
clearTimeout(_postFinalCheck)
_postFinalCheck = null
if (_hostedAbort) { _hostedAbort.abort(); _hostedAbort = null }
_sessionKey = null
_page = null

View File

@@ -200,6 +200,8 @@ function renderProviders(page, state) {
return
}
if (!state._collapsed) state._collapsed = {}
listEl.innerHTML = keys.map(key => {
const p = providers[key]
const models = p.models || []
@@ -212,10 +214,12 @@ function renderProviders(page, state) {
: models
const sorted = sortModels(filtered, sortBy)
const hiddenCount = models.length - sorted.length
const collapsed = !!state._collapsed[key]
const chevron = collapsed ? '▸' : '▾'
return `
<div class="config-section" data-provider="${key}">
<div class="config-section-title" style="display:flex;justify-content:space-between;align-items:center">
<span>${key} <span style="font-size:var(--font-size-xs);color:var(--text-tertiary);font-weight:400">${getApiTypeLabel(p.api)} · ${models.length} 个模型</span></span>
<span style="cursor:pointer;user-select:none" data-action="toggle-provider"><span style="display:inline-block;width:16px;font-size:12px;color:var(--text-tertiary)">${chevron}</span>${key} <span style="font-size:var(--font-size-xs);color:var(--text-tertiary);font-weight:400">${getApiTypeLabel(p.api)} · ${models.length} 个模型</span></span>
<div style="display:flex;gap:8px">
<button class="btn btn-sm btn-secondary" data-action="edit-provider">编辑</button>
<button class="btn btn-sm btn-secondary" data-action="add-model">+ 模型</button>
@@ -223,6 +227,7 @@ function renderProviders(page, state) {
<button class="btn btn-sm btn-danger" data-action="delete-provider">删除</button>
</div>
</div>
<div class="provider-body" style="${collapsed ? 'display:none' : ''}">
${models.length >= 2 ? `
<div style="display:flex;gap:6px;margin-bottom:var(--space-sm);align-items:center">
<button class="btn btn-sm btn-secondary" data-action="batch-test">批量测试</button>
@@ -246,6 +251,7 @@ function renderProviders(page, state) {
${renderModelCards(key, sorted, primary, search)}
${hiddenCount > 0 ? `<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);padding:4px 0">已隐藏 ${hiddenCount} 个不匹配的模型</div>` : ''}
</div>
</div>
</div>
`
}).join('')
@@ -353,11 +359,33 @@ function autoSave(state) {
_saveTimer = setTimeout(() => doAutoSave(state), 300)
}
/** 保存前规范化所有服务商的 baseUrl确保 Gateway 能正确调用 */
/** 已知的 API 类型错误→正确映射,自动修复用户手动编辑或旧版本配置 */
const API_TYPE_FIXES = {
'google-gemini': 'google-generative-ai',
'gemini': 'google-generative-ai',
'google': 'google-generative-ai',
'anthropic': 'anthropic-messages',
'openai': 'openai-completions',
'openai-chat': 'openai-completions',
}
const VALID_API_TYPES = new Set(API_TYPES.map(t => t.value))
/** 保存前规范化所有服务商的 baseUrl 和 API 类型,确保 Gateway 能正确调用 */
function normalizeProviderUrls(config) {
const providers = config?.models?.providers
if (!providers) return
for (const [, p] of Object.entries(providers)) {
// 修复 API 类型
if (p.api) {
const lower = p.api.toLowerCase().trim()
if (API_TYPE_FIXES[lower]) {
p.api = API_TYPE_FIXES[lower]
} else if (!VALID_API_TYPES.has(lower)) {
console.warn(`[models] 未知 API 类型「${p.api}」,自动修正为 openai-completions`)
p.api = 'openai-completions'
}
}
if (!p.baseUrl) continue
let url = p.baseUrl.replace(/\/+$/, '')
// 去掉尾部的已知端点路径(用户可能粘贴了完整 URL
@@ -368,7 +396,7 @@ function normalizeProviderUrls(config) {
const apiType = (p.api || 'openai-completions').toLowerCase()
if (apiType === 'anthropic-messages') {
if (!url.endsWith('/v1')) url += '/v1'
} else if (apiType !== 'google-gemini') {
} else if (apiType !== 'google-generative-ai') {
// Ollama 端口检测11434 默认需要加 /v1
if (/:11434$/.test(url) && !url.endsWith('/v1')) url += '/v1'
// 不再强制追加 /v1尊重用户填写的 URL火山引擎等第三方用 /v3 等路径)
@@ -554,6 +582,17 @@ function bindProviderButtons(listEl, page, state) {
})
})
// 折叠/展开服务商
listEl.querySelectorAll('[data-action="toggle-provider"]').forEach(span => {
span.onclick = () => {
const section = span.closest('[data-provider]')
if (!section) return
const key = section.dataset.provider
state._collapsed[key] = !state._collapsed[key]
renderProviders(page, state)
}
})
// 绑定按钮
listEl.querySelectorAll('button[data-action], input[data-action]').forEach(btn => {
const action = btn.dataset.action

View File

@@ -4,6 +4,7 @@
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { showConfirm } from '../components/modal.js'
const isTauri = !!window.__TAURI_INTERNALS__
@@ -42,6 +43,12 @@ export async function render() {
<div class="config-section-title">npm 源设置</div>
<div id="registry-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
</div>
<div class="config-section" id="openclaw-dir-section">
<div class="config-section-title">OpenClaw 安装路径</div>
<div id="openclaw-dir-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
</div>
`
bindEvents(page)
@@ -50,7 +57,7 @@ export async function render() {
}
async function loadAll(page) {
const tasks = [loadProxyConfig(page), loadModelProxyConfig(page)]
const tasks = [loadProxyConfig(page), loadModelProxyConfig(page), loadOpenclawDir(page)]
tasks.push(loadRegistry(page))
await Promise.all(tasks)
}
@@ -139,6 +146,72 @@ async function loadRegistry(page) {
}
}
// ===== OpenClaw 安装路径 =====
async function loadOpenclawDir(page) {
const bar = page.querySelector('#openclaw-dir-bar')
if (!bar) return
try {
const info = isTauri ? await api.getOpenclawDir() : { path: '~/.openclaw', isCustom: false, configExists: true }
const cfg = await api.readPanelConfig()
const customValue = cfg?.openclawDir || ''
const statusText = info.configExists
? '<span style="color:var(--success)">配置文件存在</span>'
: '<span style="color:var(--warning)">配置文件不存在</span>'
bar.innerHTML = `
<div style="margin-bottom:var(--space-xs)">
<span class="form-hint">当前路径:</span>
<strong style="font-size:var(--font-size-sm)">${escapeHtml(info.path)}</strong>
<span style="margin-left:var(--space-xs);font-size:var(--font-size-xs)">${statusText}</span>
${info.isCustom ? '<span class="clawhub-badge" style="margin-left:var(--space-xs);background:rgba(99,102,241,0.14);color:#6366f1;font-size:var(--font-size-xs)">自定义</span>' : ''}
</div>
<div style="display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap">
<input class="form-input" data-name="openclaw-dir" placeholder="留空使用默认路径 ~/.openclaw" value="${escapeHtml(customValue)}" style="max-width:420px">
<button class="btn btn-primary btn-sm" data-action="save-openclaw-dir">保存</button>
${info.isCustom ? '<button class="btn btn-secondary btn-sm" data-action="reset-openclaw-dir">恢复默认</button>' : ''}
</div>
<div class="form-hint" style="margin-top:var(--space-xs)">
自定义 OpenClaw 配置目录路径。修改后需要重启面板生效。目标目录必须存在且包含 <code>openclaw.json</code>。
</div>
`
} catch (e) {
bar.innerHTML = `<div style="color:var(--error)">加载失败: ${escapeHtml(String(e))}</div>`
}
}
async function handleSaveOpenclawDir(page) {
const input = page.querySelector('[data-name="openclaw-dir"]')
const value = (input?.value || '').trim()
const cfg = await api.readPanelConfig()
if (value) {
cfg.openclawDir = value
} else {
delete cfg.openclawDir
}
await api.writePanelConfig(cfg)
await loadOpenclawDir(page)
await promptRestart(value ? '自定义路径已保存' : '已恢复默认路径')
}
async function handleResetOpenclawDir(page) {
const cfg = await api.readPanelConfig()
delete cfg.openclawDir
await api.writePanelConfig(cfg)
await loadOpenclawDir(page)
await promptRestart('已恢复默认路径')
}
async function promptRestart(msg) {
if (!isTauri) { toast(msg, 'success'); return }
const ok = await showConfirm(`${msg}\n\n需要重启面板才能生效,是否立即重启?`)
if (ok) {
toast('正在重启...', 'info')
try { await api.relaunchApp() } catch { toast('自动重启失败,请手动关闭后重新打开', 'warning') }
} else {
toast(`${msg},下次启动时生效`, 'success')
}
}
// ===== 事件绑定 =====
function bindEvents(page) {
@@ -164,6 +237,12 @@ function bindEvents(page) {
case 'save-registry':
await handleSaveRegistry(page)
break
case 'save-openclaw-dir':
await handleSaveOpenclawDir(page)
break
case 'reset-openclaw-dir':
await handleResetOpenclawDir(page)
break
}
} catch (e) {
toast(e.toString(), 'error')
@@ -171,6 +250,7 @@ function bindEvents(page) {
btn.disabled = false
}
})
}
function normalizeProxyUrl(value) {

View File

@@ -175,7 +175,7 @@ function renderSteps(page, { node, git, cliOk, config, version }) {
}
</div>
`
// 第四步:配置文件
// 第四步:配置文件 + 自定义路径
html += `
<div class="config-section" style="text-align:left;${cliOk ? '' : 'opacity:0.4;pointer-events:none'}">
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
@@ -188,6 +188,23 @@ function renderSteps(page, { node, git, cliOk, config, version }) {
</p>
<button class="btn btn-primary btn-sm" id="btn-init-config">一键初始化配置</button>`
}
<details style="margin-top:var(--space-sm);cursor:pointer" id="custom-dir-details">
<summary style="font-size:var(--font-size-xs);color:var(--text-secondary);font-weight:600;user-select:none">
自定义 OpenClaw 安装路径
</summary>
<div style="margin-top:var(--space-sm);padding:10px 12px;background:var(--bg-tertiary);border-radius:var(--radius-sm);font-size:var(--font-size-xs);line-height:1.6">
<p style="color:var(--text-secondary);margin-bottom:8px">
如果 OpenClaw 安装在非默认目录(如 <code>E:\\数据\\AI\\.openclaw</code>),可在此指定。留空则使用默认路径。
</p>
<div style="display:flex;gap:6px">
<input id="input-openclaw-dir" type="text" placeholder="例如 E:\\数据\\AI\\.openclaw"
style="flex:1;padding:4px 8px;border:1px solid var(--border-primary);border-radius:var(--radius-sm);background:var(--bg-secondary);color:var(--text-primary);font-size:11px;font-family:monospace">
<button class="btn btn-primary btn-sm" id="btn-save-openclaw-dir" style="font-size:11px;padding:3px 10px">保存</button>
<button class="btn btn-secondary btn-sm" id="btn-reset-openclaw-dir" style="font-size:11px;padding:3px 10px">恢复默认</button>
</div>
<div id="openclaw-dir-result" style="margin-top:6px;display:none"></div>
</div>
</details>
</div>
`
@@ -432,6 +449,62 @@ function bindEvents(page, nodeOk, detectState) {
}
})
// 自定义 OpenClaw 安装路径
const dirInput = page.querySelector('#input-openclaw-dir')
const dirResultEl = page.querySelector('#openclaw-dir-result')
// 预填当前自定义路径
if (dirInput) {
api.getOpenclawDir().then(info => {
if (info.isCustom) {
dirInput.value = info.path
// 已有自定义路径时自动展开
const details = page.querySelector('#custom-dir-details')
if (details) details.open = true
}
}).catch(() => {})
}
page.querySelector('#btn-save-openclaw-dir')?.addEventListener('click', async () => {
const value = dirInput?.value?.trim()
if (!value) { toast('请输入路径', 'warning'); return }
const btn = page.querySelector('#btn-save-openclaw-dir')
btn.disabled = true
if (dirResultEl) { dirResultEl.style.display = 'block'; dirResultEl.innerHTML = '<span style="color:var(--text-tertiary)">保存中...</span>' }
try {
const cfg = await api.readPanelConfig()
cfg.openclawDir = value
await api.writePanelConfig(cfg)
invalidate()
if (dirResultEl) dirResultEl.innerHTML = `<span style="color:var(--success)">✓ 路径已保存,正在重新检测...</span>`
toast('自定义路径已保存', 'success')
setTimeout(() => runDetect(page), 500)
} catch (e) {
if (dirResultEl) dirResultEl.innerHTML = `<span style="color:var(--error)">保存失败: ${e}</span>`
toast('保存失败: ' + e, 'error')
} finally {
btn.disabled = false
}
})
page.querySelector('#btn-reset-openclaw-dir')?.addEventListener('click', async () => {
const btn = page.querySelector('#btn-reset-openclaw-dir')
btn.disabled = true
try {
const cfg = await api.readPanelConfig()
delete cfg.openclawDir
await api.writePanelConfig(cfg)
invalidate()
if (dirInput) dirInput.value = ''
if (dirResultEl) { dirResultEl.style.display = 'block'; dirResultEl.innerHTML = `<span style="color:var(--success)">✓ 已恢复默认路径,正在重新检测...</span>` }
toast('已恢复默认路径', 'success')
setTimeout(() => runDetect(page), 500)
} catch (e) {
toast('恢复失败: ' + e, 'error')
} finally {
btn.disabled = false
}
})
// 一键初始化配置
page.querySelector('#btn-init-config')?.addEventListener('click', async () => {
const btn = page.querySelector('#btn-init-config')

View File

@@ -280,7 +280,13 @@ async function handleSourceSearch(page) {
const q = input.value.trim()
if (!q) { results.innerHTML = '<div class="clawhub-empty">输入关键词搜索社区 Skills</div>'; return }
const source = getInstallSource()
// SkillHub 未安装时友好提示
// SkillHub 未安装时友好提示(先实时检测一次,避免竞态误判)
if (source === 'skillhub' && !_skillhubInstalled) {
try {
const info = await api.skillsSkillHubCheck()
_skillhubInstalled = !!info.installed
} catch { /* ignore */ }
}
if (source === 'skillhub' && !_skillhubInstalled) {
results.innerHTML = `<div style="padding:var(--space-lg);text-align:center">
<div style="color:var(--warning);margin-bottom:8px">⚠️ 请先安装 SkillHub CLI</div>
@@ -361,6 +367,7 @@ async function handleSkillHubSetup(page) {
if (statusEl) statusEl.textContent = '正在安装 SkillHub CLI...'
try {
await api.skillsSkillHubSetup(true)
_skillhubInstalled = true
toast('SkillHub CLI 安装成功', 'success')
if (statusEl) statusEl.textContent = '✅ 已安装'
// 隐藏安装按钮

View File

@@ -373,6 +373,17 @@
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
.typing-hint {
width: auto !important;
height: auto !important;
background: none !important;
animation: none !important;
font-size: 12px;
color: var(--text-tertiary);
margin-left: 4px;
white-space: nowrap;
}
@keyframes typing-bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-6px); opacity: 1; }