refactor: 全局重构原生弹窗为自定义 Modal 并同步更新项目文档

- 替换所有不可用的 `alert`, `confirm`, `prompt` 调用为异步的自定义 `Modal` 组件以适配 Tauri WebView 的 API 限制。
- 优化与重构核心服务组件接口,增加模型有效性测试 (`test_model`) 以及依赖更新支持。
- 同步补齐 `README.md` 与 `CHANGELOG.md` 新增的系统特性说明(含仪表盘、日记、存储、重构页面调整)。
This commit is contained in:
晴天
2026-02-28 03:42:19 +08:00
parent 75e94a7560
commit 84a6ab4d45
24 changed files with 1591 additions and 488 deletions

View File

@@ -7,7 +7,11 @@
## [0.1.0] - 2026-02-28
### 新增
### 优化项 (Refactor & UI Improvements)
- 移除了由浏览器原生提供的阻塞级弹窗 (`alert``confirm``prompt`),以自定义风格化的 `Modal` 组件重写全站交互以兼容 Tauri WebView 限制。
- 采用更集约化的布局逻辑重构并合并部分低频页面,大幅精简前后端不必要的接口耦合。
- 当在终端更新网关与模型配置时,将自动重启 Gateway 以立即生效设定。
- 仪表盘:系统概览、服务状态一览
- 服务管理OpenClaw 服务启停、版本检测、一键升级、Gateway 安装/卸载

View File

@@ -28,13 +28,13 @@ ClawPanel 是 [OpenClaw](https://github.com/openclaw-labs/openclaw) AI Agent 框
## 功能特性
- **仪表盘** — 系统概览,服务状态实时监控
- **服务管理** — OpenClaw 服务启停、版本检测、配置备份与恢复
- **模型配置** — 多服务商管理、模型增删改查、主模型选择、批量测试、延迟检测、自动保存与撤销
- **网关配置** — Gateway 端口、运行模式、认证方式配置
- **日志查看** — 多日志源实时查看与关键字搜索
- **记忆管理** — OpenClaw 记忆文件的查看、编辑、导出
- **扩展工具** — cftunnel 内网穿透管理、ClawApp 连接状态
- **关于** — 版本信息、社群入口、相关项目
- **服务管理** — OpenClaw 服务状态监控与启停控制、版本检测、配置自动备份、备份状态查看与快速还原
- **模型配置** — 多服务商管理、模型增删改查、支持快速验证网络连通性 (Model Test)、一键应用默认配置
- **网关配置** — Gateway 端口配置、运行模式管理与认证相关设置项,支持自动重载
- **日志查看** — 多日志源OpenClaw 核心与 Gateway实时查看与关键字搜索
- **记忆管理** — OpenClaw 记忆文件系统的动态管理及内容预览,支持打包下载 (.zip) 或部分导出
- **扩展工具** — Cloudflare Tunnel 内网穿透隧道管理 (cftunnel)、ClawApp 相关连接状态检测
- **关于** — 版本信息、核心组件与开源项目指引、社群与联系入口
## 技术架构

View File

@@ -0,0 +1,9 @@
{
"identifier": "default",
"description": "ClawPanel 默认权限",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-open"
]
}

View File

@@ -1 +1 @@
{}
{"default":{"identifier":"default","description":"ClawPanel 默认权限","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open"]}}

View File

@@ -2,22 +2,17 @@
use serde_json::Value;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use crate::models::types::VersionInfo;
fn openclaw_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_default()
.join(".openclaw")
}
fn backups_dir() -> PathBuf {
openclaw_dir().join("backups")
super::openclaw_dir().join("backups")
}
#[tauri::command]
pub fn read_openclaw_config() -> Result<Value, String> {
let path = openclaw_dir().join("openclaw.json");
let path = super::openclaw_dir().join("openclaw.json");
let content = fs::read_to_string(&path)
.map_err(|e| format!("读取配置失败: {e}"))?;
serde_json::from_str(&content)
@@ -26,9 +21,9 @@ pub fn read_openclaw_config() -> Result<Value, String> {
#[tauri::command]
pub fn write_openclaw_config(config: Value) -> Result<(), String> {
let path = openclaw_dir().join("openclaw.json");
let path = super::openclaw_dir().join("openclaw.json");
// 备份
let bak = openclaw_dir().join("openclaw.json.bak");
let bak = super::openclaw_dir().join("openclaw.json.bak");
let _ = fs::copy(&path, &bak);
// 写入
let json = serde_json::to_string_pretty(&config)
@@ -39,7 +34,7 @@ pub fn write_openclaw_config(config: Value) -> Result<(), String> {
#[tauri::command]
pub fn read_mcp_config() -> Result<Value, String> {
let path = openclaw_dir().join("mcp.json");
let path = super::openclaw_dir().join("mcp.json");
if !path.exists() {
return Ok(Value::Object(Default::default()));
}
@@ -51,37 +46,87 @@ pub fn read_mcp_config() -> Result<Value, String> {
#[tauri::command]
pub fn write_mcp_config(config: Value) -> Result<(), String> {
let path = openclaw_dir().join("mcp.json");
let path = super::openclaw_dir().join("mcp.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 fn get_version_info() -> Result<VersionInfo, String> {
// 从 openclaw.json 的 meta.lastTouchedVersion 读取
let config = read_openclaw_config()?;
let current = config
.get("meta")
.and_then(|m| m.get("lastTouchedVersion"))
.and_then(|v| v.as_str())
.map(String::from);
/// 获取本地安装的 openclaw 版本号
fn get_local_version() -> Option<String> {
let output = Command::new("openclaw")
.arg("--version")
.output()
.ok()?;
let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
// 格式可能是 "openclaw 2026.2.23" 或纯版本号
let version = raw
.split_whitespace()
.last()
.filter(|s| !s.is_empty())
.map(String::from)?;
Some(version)
}
/// 从 npm registry 获取最新版本号,超时 5 秒
async fn get_latest_version() -> Option<String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.ok()?;
let resp = client
.get("https://registry.npmjs.org/@qingchencloud%2Fopenclaw-zh/latest")
.send()
.await
.ok()?;
let json: Value = resp.json().await.ok()?;
json.get("version")
.and_then(|v| v.as_str())
.map(String::from)
}
#[tauri::command]
pub async fn get_version_info() -> Result<VersionInfo, String> {
let current = get_local_version();
let latest = get_latest_version().await;
let update_available = match (&current, &latest) {
(Some(c), Some(l)) => l != c,
_ => false,
};
Ok(VersionInfo {
current,
latest: None,
update_available: false,
latest,
update_available,
})
}
/// 执行 npm 全局升级 openclaw
#[tauri::command]
pub async fn upgrade_openclaw() -> Result<String, String> {
let output = Command::new("npm")
.args(["install", "-g", "@qingchencloud/openclaw-zh@latest"])
.output()
.map_err(|e| format!("执行升级命令失败: {e}"))?;
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if !output.status.success() {
return Err(format!("升级失败: {stderr}"));
}
// 获取升级后的版本号
let new_ver = get_local_version().unwrap_or_else(|| "未知".into());
Ok(format!("升级成功,当前版本: {new_ver}"))
}
#[tauri::command]
pub fn check_installation() -> Result<Value, String> {
let openclaw_dir = openclaw_dir();
let installed = openclaw_dir.join("openclaw.json").exists();
let dir = super::openclaw_dir();
let installed = dir.join("openclaw.json").exists();
let mut result = serde_json::Map::new();
result.insert("installed".into(), Value::Bool(installed));
result.insert("path".into(), Value::String(openclaw_dir.to_string_lossy().to_string()));
result.insert("path".into(), Value::String(dir.to_string_lossy().to_string()));
Ok(Value::Object(result))
}
@@ -94,6 +139,13 @@ pub fn write_env_file(path: String, config: String) -> Result<(), String> {
} else {
PathBuf::from(&path)
};
// 安全限制:只允许写入 ~/.openclaw/ 目录下的文件
let openclaw_base = super::openclaw_dir();
if !expanded.starts_with(&openclaw_base) {
return Err("只允许写入 ~/.openclaw/ 目录下的文件".to_string());
}
if let Some(parent) = expanded.parent() {
let _ = fs::create_dir_all(parent);
}
@@ -121,8 +173,11 @@ pub fn list_backups() -> Result<Value, String> {
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let meta = fs::metadata(&path).ok();
let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
// macOS 支持 created()fallback 到 modified()
let created = meta
.and_then(|m| m.modified().ok())
.and_then(|m| {
m.created().ok().or_else(|| m.modified().ok())
})
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
@@ -148,7 +203,7 @@ pub fn create_backup() -> Result<Value, String> {
fs::create_dir_all(&dir)
.map_err(|e| format!("创建备份目录失败: {e}"))?;
let src = openclaw_dir().join("openclaw.json");
let src = super::openclaw_dir().join("openclaw.json");
if !src.exists() {
return Err("openclaw.json 不存在".into());
}
@@ -176,7 +231,7 @@ pub fn restore_backup(name: String) -> Result<(), String> {
if !backup_path.exists() {
return Err(format!("备份文件不存在: {name}"));
}
let target = openclaw_dir().join("openclaw.json");
let target = super::openclaw_dir().join("openclaw.json");
// 恢复前先自动备份当前配置
if target.exists() {
@@ -201,39 +256,33 @@ pub fn delete_backup(name: String) -> Result<(), String> {
.map_err(|e| format!("删除失败: {e}"))
}
/// 重载 Gateway 服务unload + load plist
/// 获取当前用户 UID
fn get_uid() -> Result<u32, String> {
let output = Command::new("id")
.arg("-u")
.output()
.map_err(|e| format!("获取 UID 失败: {e}"))?;
String::from_utf8_lossy(&output.stdout)
.trim()
.parse::<u32>()
.map_err(|e| format!("解析 UID 失败: {e}"))
}
/// 重载 Gateway 服务(使用 kickstart -k 强制重启)
#[tauri::command]
pub fn reload_gateway() -> Result<String, String> {
let home = dirs::home_dir().unwrap_or_default();
let plist = format!(
"{}/Library/LaunchAgents/ai.openclaw.gateway.plist",
home.display()
);
if !std::path::Path::new(&plist).exists() {
return Err("Gateway plist 不存在".into());
}
// 先 unload忽略错误
let _ = std::process::Command::new("launchctl")
.args(["unload", &plist])
.output();
std::thread::sleep(std::time::Duration::from_millis(500));
let output = std::process::Command::new("launchctl")
.args(["load", &plist])
let uid = get_uid()?;
let target = format!("gui/{uid}/ai.openclaw.gateway");
let output = Command::new("launchctl")
.args(["kickstart", "-k", &target])
.output()
.map_err(|e| format!("重载 Gateway 失败: {e}"))?;
if !output.status.success() {
.map_err(|e| format!("重载失败: {e}"))?;
if output.status.success() {
Ok("Gateway 已重载".to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.trim().is_empty() {
return Err(format!("重载 Gateway 失败: {stderr}"));
}
Err(format!("重载失败: {stderr}"))
}
Ok("Gateway 已重载".into())
}
/// 测试模型连通性:向 provider 发送一个简单的 chat completion 请求
@@ -308,3 +357,106 @@ pub async fn test_model(
Ok(reply)
}
/// 获取服务商的远程模型列表(调用 /models 接口)
#[tauri::command]
pub async fn list_remote_models(
base_url: String,
api_key: String,
) -> Result<Vec<String>, String> {
let url = format!("{}/models", base_url.trim_end_matches('/'));
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?;
let mut req = client.get(&url);
if !api_key.is_empty() {
req = req.header("Authorization", format!("Bearer {api_key}"));
}
let resp = req.send().await.map_err(|e| {
if e.is_timeout() {
"请求超时 (15s),该服务商可能不支持模型列表接口".to_string()
} else if e.is_connect() {
format!("连接失败,请检查接口地址是否正确: {e}")
} else {
format!("请求失败: {e}")
}
})?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
let msg = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|v| {
v.get("error")
.and_then(|e| e.get("message"))
.and_then(|m| m.as_str())
.map(String::from)
})
.unwrap_or_else(|| format!("HTTP {status}"));
return Err(format!("获取模型列表失败: {msg}"));
}
// 解析 OpenAI 格式的 /models 响应
let ids = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|v| {
let data = v.get("data")?.as_array()?;
let mut ids: Vec<String> = data
.iter()
.filter_map(|m| m.get("id").and_then(|id| id.as_str()).map(String::from))
.collect();
ids.sort();
Some(ids)
})
.unwrap_or_default();
if ids.is_empty() {
return Err("该服务商返回了空的模型列表,可能不支持 /models 接口".to_string());
}
Ok(ids)
}
/// 安装 Gateway 服务(执行 openclaw gateway install
#[tauri::command]
pub fn install_gateway() -> Result<String, String> {
let output = Command::new("openclaw")
.args(["gateway", "install"])
.output()
.map_err(|e| format!("安装失败: {e}"))?;
if output.status.success() {
Ok("Gateway 服务已安装".to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!("安装失败: {stderr}"))
}
}
/// 卸载 Gateway 服务(先 bootout 再删除 plist
#[tauri::command]
pub fn uninstall_gateway() -> Result<String, String> {
let uid = get_uid()?;
let target = format!("gui/{uid}/ai.openclaw.gateway");
// 先停止服务
let _ = Command::new("launchctl")
.args(["bootout", &target])
.output();
// 删除 plist 文件
let home = dirs::home_dir().unwrap_or_default();
let plist = home.join("Library/LaunchAgents/ai.openclaw.gateway.plist");
if plist.exists() {
fs::remove_file(&plist)
.map_err(|e| format!("删除 plist 失败: {e}"))?;
}
Ok("Gateway 服务已卸载".to_string())
}

View File

@@ -59,6 +59,27 @@ fn cftunnel_bin() -> String {
"cftunnel".to_string()
}
/// 通过 launchctl 检测 cftunnel 服务实际运行状态
fn check_cftunnel_launchctl() -> Option<(Option<u64>, bool)> {
let output = Command::new("launchctl")
.args(["list"])
.output()
.ok()?;
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines() {
if line.contains("com.cftunnel") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 3 {
let pid = parts[0].parse::<u64>().ok();
// 第一列是 PID数字表示在运行- 表示未运行)
let running = pid.is_some();
return Some((pid, running));
}
}
}
None
}
#[tauri::command]
pub fn get_cftunnel_status() -> Result<Value, String> {
let bin = cftunnel_bin();
@@ -87,6 +108,19 @@ pub fn get_cftunnel_status() -> Result<Value, String> {
}
}
// 补充检测:如果 cftunnel status 报已停止,但 launchctl 显示进程在跑,以实际为准
let reported_running = result.get("running").and_then(|v| v.as_bool()).unwrap_or(false);
if !reported_running {
if let Some((pid, running)) = check_cftunnel_launchctl() {
if running {
result.insert("running".into(), Value::Bool(true));
if let Some(p) = pid {
result.insert("pid".into(), Value::Number(p.into()));
}
}
}
}
// 获取路由列表
if let Ok(out) = Command::new(&bin).arg("list").output() {
let text = String::from_utf8_lossy(&out.stdout);
@@ -100,12 +134,14 @@ pub fn get_cftunnel_status() -> Result<Value, String> {
#[tauri::command]
pub fn cftunnel_action(action: String) -> Result<(), String> {
let bin = cftunnel_bin();
match action.as_str() {
"up" | "down" => {}
let args = match action.as_str() {
"up" => vec!["up"],
"down" => vec!["down"],
"restart" => vec!["restart"],
_ => return Err(format!("不支持的操作: {action}")),
}
};
let output = Command::new(&bin)
.arg(&action)
.args(&args)
.output()
.map_err(|e| format!("执行 cftunnel {action} 失败: {e}"))?;

View File

@@ -1,6 +1,7 @@
/// 日志读取命令
/// 使用 BufReader + Seek 避免 OOM限制最大读取量
use std::fs;
use std::io::{BufRead, BufReader};
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
use std::path::PathBuf;
fn log_dir() -> PathBuf {
@@ -23,16 +24,44 @@ fn log_path(log_name: &str) -> PathBuf {
}
#[tauri::command]
pub fn read_log_tail(log_name: String, lines: usize) -> Result<String, String> {
pub fn read_log_tail(log_name: String, lines: Option<u32>) -> Result<String, String> {
let lines = lines.unwrap_or(200) as usize;
let path = log_path(&log_name);
if !path.exists() {
return Ok(String::new());
}
let content = fs::read_to_string(&path)
let mut file = fs::File::open(&path)
.map_err(|e| format!("打开日志失败: {e}"))?;
let file_len = file
.metadata()
.map_err(|e| format!("获取文件元数据失败: {e}"))?
.len();
// 最多从尾部读取 1MB避免 OOM
let max_read: u64 = 1024 * 1024;
let start_pos = if file_len > max_read {
file_len - max_read
} else {
0
};
file.seek(SeekFrom::Start(start_pos))
.map_err(|e| format!("Seek 失败: {e}"))?;
let mut buf = String::new();
file.read_to_string(&mut buf)
.map_err(|e| format!("读取日志失败: {e}"))?;
let all_lines: Vec<&str> = content.lines().collect();
let mut all_lines: Vec<&str> = buf.lines().collect();
// 如果从中间开始读,第一行可能不完整,跳过
if start_pos > 0 && all_lines.len() > 1 {
all_lines.remove(0);
}
// 取最后 N 行
let start = if all_lines.len() > lines {
all_lines.len() - lines
} else {
@@ -46,24 +75,53 @@ pub fn read_log_tail(log_name: String, lines: usize) -> Result<String, String> {
pub fn search_log(
log_name: String,
query: String,
max_results: usize,
max_results: Option<u32>,
) -> Result<Vec<String>, String> {
let max_results = max_results.unwrap_or(50) as usize;
let path = log_path(&log_name);
if !path.exists() {
return Ok(vec![]);
}
let file = fs::File::open(&path)
let mut file = fs::File::open(&path)
.map_err(|e| format!("打开日志失败: {e}"))?;
let file_len = file
.metadata()
.map_err(|e| format!("获取文件元数据失败: {e}"))?
.len();
// 搜索最多读取尾部 2MB避免 OOM同时保证搜索最新内容
let max_read: u64 = 2 * 1024 * 1024;
let start_pos = if file_len > max_read {
file_len - max_read
} else {
0
};
file.seek(SeekFrom::Start(start_pos))
.map_err(|e| format!("Seek 失败: {e}"))?;
let reader = BufReader::new(file);
let query_lower = query.to_lowercase();
let results: Vec<String> = reader
let mut matched: Vec<String> = reader
.lines()
.filter_map(|l| l.ok())
.filter(|l| l.to_lowercase().contains(&query_lower))
.take(max_results)
.collect();
Ok(results)
// 如果从中间开始读,第一条匹配可能是不完整行,跳过
if start_pos > 0 && !matched.is_empty() {
matched.remove(0);
}
// 取最后 N 条(最新的匹配结果)
let start = if matched.len() > max_results {
matched.len() - max_results
} else {
0
};
Ok(matched[start..].to_vec())
}

View File

@@ -3,18 +3,13 @@ use std::fs;
use std::io::Write;
use std::path::PathBuf;
fn openclaw_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_default()
.join(".openclaw")
}
fn memory_dir(category: &str) -> PathBuf {
fn memory_dir(category: &str) -> std::path::PathBuf {
let base = super::openclaw_dir();
match category {
"memory" => openclaw_dir().join("workspace").join("memory"),
"archive" => openclaw_dir().join("workspace-memory"),
"core" => openclaw_dir().join("workspace"),
_ => openclaw_dir().join("workspace").join("memory"),
"memory" => base.join("workspace").join("memory"),
"archive" => base.join("workspace-memory"),
"core" => base.join("workspace"),
_ => base.join("workspace").join("memory"),
}
}
@@ -62,8 +57,8 @@ fn collect_files(
#[tauri::command]
pub fn read_memory_file(path: String) -> Result<String, String> {
// 安全检查:路径不能包含 ..
if path.contains("..") {
// 安全检查:路径不能包含 ..、绝对路径、空字节
if path.contains("..") || path.starts_with('/') || path.contains('\0') {
return Err("非法路径".to_string());
}
@@ -85,36 +80,32 @@ pub fn read_memory_file(path: String) -> Result<String, String> {
}
#[tauri::command]
pub fn write_memory_file(path: String, content: String) -> Result<(), String> {
if path.contains("..") {
pub fn write_memory_file(path: String, content: String, category: Option<String>) -> Result<(), String> {
// 安全检查:路径不能包含 ..、绝对路径、空字节
if path.contains("..") || path.starts_with('/') || path.contains('\0') {
return Err("非法路径".to_string());
}
let candidates = [
memory_dir("memory").join(&path),
memory_dir("archive").join(&path),
memory_dir("core").join(&path),
];
let cat = category.unwrap_or_else(|| "memory".to_string());
let base = match cat.as_str() {
"memory" => super::openclaw_dir().join("workspace").join("memory"),
"archive" => super::openclaw_dir().join("workspace-memory"),
"core" => super::openclaw_dir().join("workspace"),
_ => return Err(format!("未知分类: {cat}")),
};
for candidate in &candidates {
if candidate.exists() {
return fs::write(candidate, &content)
.map_err(|e| format!("写入失败: {e}"));
}
let full_path = base.join(&path);
// 确保父目录存在
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {e}"))?;
}
// 默认写入 memory 目录
let target = memory_dir("memory").join(&path);
if let Some(parent) = target.parent() {
let _ = fs::create_dir_all(parent);
}
fs::write(&target, &content)
.map_err(|e| format!("写入失败: {e}"))
fs::write(&full_path, &content).map_err(|e| format!("写入失败: {e}"))
}
#[tauri::command]
pub fn delete_memory_file(path: String) -> Result<(), String> {
if path.contains("..") {
// 安全检查:路径不能包含 ..、绝对路径、空字节
if path.contains("..") || path.starts_with('/') || path.contains('\0') {
return Err("非法路径".to_string());
}

View File

@@ -1,5 +1,14 @@
use std::path::PathBuf;
pub mod config;
pub mod extensions;
pub mod logs;
pub mod memory;
pub mod service;
/// 获取 OpenClaw 配置目录 (~/.openclaw/)
pub fn openclaw_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_default()
.join(".openclaw")
}

View File

@@ -1,27 +1,36 @@
/// 服务管理命令 (macOS launchd)
/// 动态扫描 ~/Library/LaunchAgents/ 下的 openclaw/cftunnel 相关 plist
/// 扫描 OpenClaw 核心服务 (ai.openclaw.* / com.openclaw.guardian.* / com.openclaw.watchdog)
/// 使用新版 launchctl bootstrap/bootout/kickstart API
use std::collections::HashMap;
use std::fs;
use std::process::Command;
use crate::models::types::ServiceStatus;
/// 友好名称映射
/// 获取当前用户 UID
fn current_uid() -> Result<u32, String> {
let output = Command::new("id")
.arg("-u")
.output()
.map_err(|e| format!("获取 UID 失败: {e}"))?;
let uid_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
uid_str.parse::<u32>().map_err(|e| format!("解析 UID 失败: {e}"))
}
/// OpenClaw 官方服务的友好名称映射
fn description_map() -> HashMap<&'static str, &'static str> {
HashMap::from([
("ai.openclaw.gateway", "OpenClaw Gateway"),
("com.openclaw.guardian.watch", "健康监控 (60s)"),
("com.openclaw.guardian.backup", "配置备份 (3600s)"),
("com.openclaw.watchdog", "看门狗 (120s)"),
("com.openclaw.webhook-router", "Webhook 路由"),
("com.openclaw.webhook-tunnel", "Webhook SSH 隧道"),
("com.openclaw.cf-tunnel", "Cloudflare Tunnel (旧)"),
("com.cftunnel.cloudflared", "cftunnel 隧道服务"),
("actions.runner.2221186349-qingchen.openclaw-mac", "GitHub Actions Runner"),
("ai.openclaw.node", "OpenClaw Node Host"),
])
}
/// 动态扫描 LaunchAgents 目录,找出所有 openclaw/cftunnel 相关 plist
/// OpenClaw 官方服务前缀ai.openclaw.gateway / ai.openclaw.node 等)
const OPENCLAW_PREFIXES: &[&str] = &[
"ai.openclaw.",
];
/// 动态扫描 LaunchAgents 目录,只返回 OpenClaw 核心服务
fn scan_plist_labels() -> Vec<String> {
let home = dirs::home_dir().unwrap_or_default();
let agents_dir = home.join("Library/LaunchAgents");
@@ -30,12 +39,12 @@ fn scan_plist_labels() -> Vec<String> {
if let Ok(entries) = fs::read_dir(&agents_dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if (name.contains("openclaw") || name.contains("cftunnel"))
&& name.ends_with(".plist")
{
// 文件名去掉 .plist 就是 label
let label = name.trim_end_matches(".plist").to_string();
labels.push(label);
if !name.ends_with(".plist") {
continue;
}
let label = name.trim_end_matches(".plist");
if OPENCLAW_PREFIXES.iter().any(|p| label.starts_with(p)) {
labels.push(label.to_string());
}
}
}
@@ -52,42 +61,65 @@ fn plist_path(label: &str) -> String {
)
}
/// 用 `launchctl print gui/{uid}/{label}` 检测单个服务状态
/// 返回 (running, pid)
fn check_service_status(uid: u32, label: &str) -> (bool, Option<u32>) {
let target = format!("gui/{}/{}", uid, label);
let output = Command::new("launchctl")
.args(["print", &target])
.output();
let Ok(out) = output else {
return (false, None);
};
// launchctl print 返回非零 → 服务未注册
if !out.status.success() {
return (false, None);
}
let stdout = String::from_utf8_lossy(&out.stdout);
let mut pid: Option<u32> = None;
let mut running = false;
for line in stdout.lines() {
// 只解析顶层字段(单个 tab 缩进),忽略嵌套的 state = active 等
if !line.starts_with('\t') || line.starts_with("\t\t") {
continue;
}
let trimmed = line.trim();
if trimmed.starts_with("pid = ") {
if let Ok(p) = trimmed["pid = ".len()..].trim().parse::<u32>() {
pid = Some(p);
}
}
if trimmed.starts_with("state = ") {
let state = trimmed["state = ".len()..].trim();
running = state == "running";
}
}
(running, pid)
}
#[tauri::command]
pub fn get_services_status() -> Result<Vec<ServiceStatus>, String> {
let output = Command::new("launchctl")
.arg("list")
.output()
.map_err(|e| format!("执行 launchctl 失败: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let uid = current_uid()?;
let labels = scan_plist_labels();
let desc_map = description_map();
let mut results = Vec::new();
for label in &labels {
let mut status = ServiceStatus {
let (running, pid) = check_service_status(uid, label);
results.push(ServiceStatus {
label: label.clone(),
pid: None,
running: false,
pid,
running,
description: desc_map
.get(label.as_str())
.unwrap_or(&"")
.to_string(),
};
// 解析 launchctl list 输出: PID\tStatus\tLabel
for line in stdout.lines() {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 3 && parts[2] == label {
if let Ok(pid) = parts[0].trim().parse::<u32>() {
status.pid = Some(pid);
status.running = true;
}
// PID 为 "-" 但 label 存在于 launchctl list 中 → 已加载但未运行
break;
}
}
results.push(status);
});
}
Ok(results)
@@ -95,57 +127,115 @@ pub fn get_services_status() -> Result<Vec<ServiceStatus>, String> {
#[tauri::command]
pub fn start_service(label: String) -> Result<(), String> {
let uid = current_uid()?;
let path = plist_path(&label);
let output = Command::new("launchctl")
.args(["load", &path])
.output()
.map_err(|e| format!("启动失败: {e}"))?;
let domain_target = format!("gui/{}", uid);
let service_target = format!("gui/{}/{}", uid, label);
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.trim().is_empty() {
// bootstrap 加载 plist
let bootstrap_out = Command::new("launchctl")
.args(["bootstrap", &domain_target, &path])
.output()
.map_err(|e| format!("bootstrap 失败: {e}"))?;
if !bootstrap_out.status.success() {
let stderr = String::from_utf8_lossy(&bootstrap_out.stderr);
// 如果已经加载过,忽略该错误,继续 kickstart
if !stderr.contains("already bootstrapped") && !stderr.trim().is_empty() {
return Err(format!("启动 {label} 失败: {stderr}"));
}
}
// kickstart 触发服务运行
let kickstart_out = Command::new("launchctl")
.args(["kickstart", &service_target])
.output()
.map_err(|e| format!("kickstart 失败: {e}"))?;
if !kickstart_out.status.success() {
let stderr = String::from_utf8_lossy(&kickstart_out.stderr);
if !stderr.trim().is_empty() {
return Err(format!("kickstart {label} 失败: {stderr}"));
}
}
Ok(())
}
#[tauri::command]
pub fn stop_service(label: String) -> Result<(), String> {
let path = plist_path(&label);
let uid = current_uid()?;
let service_target = format!("gui/{}/{}", uid, label);
let output = Command::new("launchctl")
.args(["unload", &path])
.args(["bootout", &service_target])
.output()
.map_err(|e| format!("停止失败: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.trim().is_empty() {
// 忽略"未加载"类错误
if !stderr.contains("No such process")
&& !stderr.contains("Could not find specified service")
&& !stderr.trim().is_empty()
{
return Err(format!("停止 {label} 失败: {stderr}"));
}
}
Ok(())
}
#[tauri::command]
pub fn restart_service(label: String) -> Result<(), String> {
let uid = current_uid()?;
let path = plist_path(&label);
// 先 unload忽略错误可能本来就没加载
let domain_target = format!("gui/{}", uid);
let service_target = format!("gui/{}/{}", uid, label);
// 第一步bootout 停止服务(忽略未加载错误)
let _ = Command::new("launchctl")
.args(["unload", &path])
.args(["bootout", &service_target])
.output();
std::thread::sleep(std::time::Duration::from_millis(500));
let output = Command::new("launchctl")
.args(["load", &path])
// 第二步:轮询等待旧进程退出,最多等 3 秒
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
loop {
let (running, _) = check_service_status(uid, &label);
if !running {
break;
}
if std::time::Instant::now() >= deadline {
break; // 超时后继续尝试
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
// 第三步bootstrap 重新加载 plist
let bootstrap_out = Command::new("launchctl")
.args(["bootstrap", &domain_target, &path])
.output()
.map_err(|e| format!("重启失败: {e}"))?;
.map_err(|e| format!("重启 bootstrap 失败: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.trim().is_empty() {
return Err(format!("重启 {label} 失败: {stderr}"));
if !bootstrap_out.status.success() {
let stderr = String::from_utf8_lossy(&bootstrap_out.stderr);
if !stderr.contains("already bootstrapped") && !stderr.trim().is_empty() {
return Err(format!("重启 {label} 失败 (bootstrap): {stderr}"));
}
}
// 第四步kickstart -k 强制重启
let kickstart_out = Command::new("launchctl")
.args(["kickstart", "-k", &service_target])
.output()
.map_err(|e| format!("重启 kickstart 失败: {e}"))?;
if !kickstart_out.status.success() {
let stderr = String::from_utf8_lossy(&kickstart_out.stderr);
if !stderr.trim().is_empty() {
return Err(format!("重启 {label} 失败 (kickstart): {stderr}"));
}
}
Ok(())
}

View File

@@ -21,6 +21,10 @@ pub fn run() {
config::delete_backup,
config::reload_gateway,
config::test_model,
config::list_remote_models,
config::upgrade_openclaw,
config::install_gateway,
config::uninstall_gateway,
// 服务
service::get_services_status,
service::start_service,

View File

@@ -21,8 +21,9 @@
"resizable": true
}
],
"withGlobalTauri": true,
"security": {
"csp": null
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://localhost:* http://127.0.0.1:* http://192.168.*.* https://* http://*; img-src 'self' data:"
}
},
"bundle": {

View File

@@ -1,21 +1,90 @@
/**
* Modal 弹窗组件
*/
// 转义 HTML 属性值,防止双引号等字符破坏 HTML 结构
function escapeAttr(str) {
if (!str) return ''
return String(str)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
/**
* 自定义确认弹窗,替代原生 confirm()
* Tauri WebView 不支持原生 confirm/alert必须用自定义弹窗
* @param {string} message 确认消息
* @returns {Promise<boolean>} 用户选择确认返回 true取消返回 false
*/
export function showConfirm(message) {
return new Promise((resolve) => {
const overlay = document.createElement('div')
overlay.className = 'modal-overlay'
overlay.innerHTML = `
<div class="modal" style="max-width:400px">
<div class="modal-title">确认操作</div>
<div style="font-size:var(--font-size-sm);color:var(--text-secondary);white-space:pre-wrap;line-height:1.6">${escapeAttr(message)}</div>
<div class="modal-actions">
<button class="btn btn-secondary btn-sm" data-action="cancel">取消</button>
<button class="btn btn-danger btn-sm" data-action="confirm">确定</button>
</div>
</div>
`
document.body.appendChild(overlay)
const close = (result) => {
overlay.remove()
resolve(result)
}
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close(false)
})
overlay.querySelector('[data-action="cancel"]').onclick = () => close(false)
overlay.querySelector('[data-action="confirm"]').onclick = () => close(true)
overlay.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); close(true) }
else if (e.key === 'Escape') close(false)
})
// 聚焦确认按钮以接收键盘事件
overlay.querySelector('[data-action="confirm"]').focus()
})
}
export function showModal({ title, fields, onConfirm }) {
const overlay = document.createElement('div')
overlay.className = 'modal-overlay'
const fieldHtml = fields.map(f => `
<div class="form-group">
<label class="form-label">${f.label}</label>
${f.type === 'select'
? `<select class="form-input" data-name="${f.name}">
const fieldHtml = fields.map(f => {
if (f.type === 'checkbox') {
return `
<div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" data-name="${f.name}" ${f.value ? 'checked' : ''}>
<span class="form-label" style="margin:0">${f.label}</span>
</label>
${f.hint ? `<div class="form-hint">${f.hint}</div>` : ''}
</div>`
}
if (f.type === 'select') {
return `
<div class="form-group">
<label class="form-label">${f.label}</label>
<select class="form-input" data-name="${f.name}">
${f.options.map(o => `<option value="${o.value}" ${o.value === f.value ? 'selected' : ''}>${o.label}</option>`).join('')}
</select>`
: `<input class="form-input" data-name="${f.name}" value="${f.value || ''}" placeholder="${f.placeholder || ''}">`
}
</div>
`).join('')
</select>
${f.hint ? `<div class="form-hint">${f.hint}</div>` : ''}
</div>`
}
return `
<div class="form-group">
<label class="form-label">${f.label}</label>
<input class="form-input" data-name="${f.name}" value="${escapeAttr(f.value)}" placeholder="${escapeAttr(f.placeholder)}">
${f.hint ? `<div class="form-hint">${f.hint}</div>` : ''}
</div>`
}).join('')
overlay.innerHTML = `
<div class="modal">
@@ -40,7 +109,11 @@ export function showModal({ title, fields, onConfirm }) {
overlay.querySelector('[data-action="confirm"]').onclick = () => {
const result = {}
overlay.querySelectorAll('[data-name]').forEach(el => {
result[el.dataset.name] = el.value
if (el.type === 'checkbox') {
result[el.dataset.name] = el.checked
} else {
result[el.dataset.name] = el.value
}
})
overlay.remove()
onConfirm(result)

View File

@@ -3,14 +3,13 @@
* 开发阶段用 mock 数据Tauri 环境用 invoke
*/
const isTauri = !!window.__TAURI__
const isTauri = !!window.__TAURI_INTERNALS__
async function invoke(cmd, args = {}) {
if (isTauri) {
const { invoke: tauriInvoke } = await import('@tauri-apps/api/core')
return tauriInvoke(cmd, args)
}
// 开发模式 mock
return mockInvoke(cmd, args)
}
@@ -19,12 +18,6 @@ function mockInvoke(cmd, args) {
const mocks = {
get_services_status: () => [
{ label: 'ai.openclaw.gateway', pid: null, running: false, description: 'OpenClaw Gateway' },
{ label: 'com.cftunnel.cloudflared', pid: 35218, running: true, description: 'cftunnel 隧道服务' },
{ label: 'com.openclaw.guardian.watch', pid: 55290, running: true, description: '健康监控 (60s)' },
{ label: 'com.openclaw.guardian.backup', pid: null, running: false, description: '配置备份 (3600s)' },
{ label: 'com.openclaw.watchdog', pid: null, running: false, description: '看门狗 (120s)' },
{ label: 'com.openclaw.webhook-router', pid: 38983, running: true, description: 'Webhook 路由' },
{ label: 'com.openclaw.webhook-tunnel', pid: null, running: false, description: 'Webhook SSH 隧道' },
],
get_version_info: () => ({
current: '2026.2.23',
@@ -38,7 +31,7 @@ function mockInvoke(cmd, args) {
providers: {
'newapi-claude': {
baseUrl: 'http://192.168.1.14:30080/v1',
apiType: 'openai',
api: 'openai-completions',
models: [
{ id: 'claude-opus-4-6' },
{ id: 'claude-sonnet-4-5' },
@@ -103,7 +96,11 @@ function mockInvoke(cmd, args) {
stop_service: () => true,
restart_service: () => true,
reload_gateway: () => 'Gateway 已重载',
test_model: ({ base_url, model_id }) => `模型 ${model_id} 连通正常 (mock)`,
upgrade_openclaw: () => '升级成功,当前版本: 2026.2.26-zh.3 (mock)',
install_gateway: () => 'Gateway 服务已安装 (mock)',
uninstall_gateway: () => 'Gateway 服务已卸载 (mock)',
test_model: ({ modelId }) => `模型 ${modelId} 连通正常 (mock)`,
list_remote_models: () => ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-3.5-turbo', 'o3-mini', 'dall-e-3', 'text-embedding-3-small'],
write_env_file: () => true,
list_backups: () => [
{ name: 'openclaw-20260226-143000.json', size: 8542, created_at: 1740577800 },
@@ -144,7 +141,11 @@ export const api = {
readMcpConfig: () => invoke('read_mcp_config'),
writeMcpConfig: (config) => invoke('write_mcp_config', { config }),
reloadGateway: () => invoke('reload_gateway'),
testModel: (baseUrl, apiKey, modelId) => invoke('test_model', { base_url: baseUrl, api_key: apiKey, model_id: modelId }),
upgradeOpenclaw: () => invoke('upgrade_openclaw'),
installGateway: () => invoke('install_gateway'),
uninstallGateway: () => invoke('uninstall_gateway'),
testModel: (baseUrl, apiKey, modelId) => invoke('test_model', { baseUrl, apiKey, modelId }),
listRemoteModels: (baseUrl, apiKey) => invoke('list_remote_models', { baseUrl, apiKey }),
// 日志
readLogTail: (logName, lines = 100) => invoke('read_log_tail', { logName, lines }),
@@ -153,7 +154,7 @@ export const api = {
// 记忆文件
listMemoryFiles: (category) => invoke('list_memory_files', { category }),
readMemoryFile: (path) => invoke('read_memory_file', { path }),
writeMemoryFile: (path, content) => invoke('write_memory_file', { path, content }),
writeMemoryFile: (path, content, category) => invoke('write_memory_file', { path, content, category: category || 'memory' }),
deleteMemoryFile: (path) => invoke('delete_memory_file', { path }),
exportMemoryZip: (category) => invoke('export_memory_zip', { category }),

View File

@@ -31,6 +31,3 @@ const content = document.getElementById('content')
renderSidebar(sidebar)
initRouter(content)
// 路由变化时刷新侧边栏高亮
window.addEventListener('hashchange', () => renderSidebar(sidebar))

View File

@@ -3,21 +3,29 @@
* 版本信息、项目链接、相关项目、系统环境
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
export async function render() {
const page = document.createElement('div')
page.className = 'page'
page.innerHTML = `
<div class="page-header">
<h1 class="page-title">关于</h1>
<p class="page-desc">ClawPanel — OpenClaw 可视化管理面板</p>
<div class="page-header" style="display:flex;align-items:center;gap:16px">
<img src="/images/logo.svg" alt="ClawPanel" style="width:48px;height:48px;border-radius:var(--radius-md)">
<div>
<h1 class="page-title" style="margin:0">ClawPanel</h1>
<p class="page-desc" style="margin:0">OpenClaw 可视化管理面板</p>
</div>
</div>
<div class="stat-cards" id="version-cards">
<div class="stat-card loading-placeholder"></div>
<div class="stat-card loading-placeholder"></div>
<div class="stat-card loading-placeholder"></div>
</div>
<div class="config-section">
<div class="config-section-title">社群交流</div>
<div id="community-section"></div>
</div>
<div class="config-section">
<div class="config-section-title">相关项目</div>
<div id="projects-list"></div>
@@ -33,6 +41,7 @@ export async function render() {
`
loadData(page)
renderCommunity(page)
renderProjects(page)
renderLinks(page)
return page
@@ -45,28 +54,88 @@ async function loadData(page) {
api.getVersionInfo(),
api.checkInstallation(),
])
// 尝试从 Tauri API 获取 ClawPanel 自身版本号,失败则 fallback
let panelVersion = '0.1.0'
try {
const { getVersion } = await import('@tauri-apps/api/app')
panelVersion = await getVersion()
} catch {
// 非 Tauri 环境或 API 不可用,使用 fallback
}
cards.innerHTML = `
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">ClawPanel</span></div>
<div class="stat-card-value">0.1.0</div>
<div class="stat-card-value">${panelVersion}</div>
<div class="stat-card-meta">Tauri v2 桌面应用</div>
</div>
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">OpenClaw</span></div>
<div class="stat-card-value">${version.current || '未'}</div>
<div class="stat-card-meta">${version.update_available ? '有新版本可用' : '已是最新'}</div>
<div class="stat-card-value">${version.current || '未安装'}</div>
<div class="stat-card-meta" style="display:flex;align-items:center;gap:8px">
${version.update_available
? `<span style="color:var(--accent)">新版本: ${version.latest}</span><button class="btn btn-primary btn-sm" id="btn-upgrade" style="padding:2px 8px;font-size:var(--font-size-xs)">升级</button>`
: version.current ? '<span style="color:var(--success)">已是最新</span>' : '<span style="color:var(--error)">未检测到</span>'}
</div>
</div>
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">安装路径</span></div>
<div class="stat-card-value" style="font-size:var(--font-size-sm);word-break:break-all">${install.path || '未知'}</div>
<div class="stat-card-meta">${install.installed ? '已安装' : '未安装'}</div>
<div class="stat-card-meta">${install.installed ? '配置文件存在' : '未找到配置文件'}</div>
</div>
`
// 绑定升级按钮
const upgradeBtn = cards.querySelector('#btn-upgrade')
if (upgradeBtn) {
upgradeBtn.onclick = async () => {
upgradeBtn.disabled = true
upgradeBtn.textContent = '升级中...'
try {
const msg = await api.upgradeOpenclaw()
upgradeBtn.textContent = '完成'
// 刷新版本信息
loadData(page)
} catch (e) {
upgradeBtn.disabled = false
upgradeBtn.textContent = '升级失败'
toast('升级失败: ' + e, 'error')
}
}
}
} catch {
cards.innerHTML = '<div class="stat-card"><div class="stat-card-label">加载失败</div></div>'
}
}
function renderCommunity(page) {
const el = page.querySelector('#community-section')
el.innerHTML = `
<div style="display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start">
<div style="text-align:center">
<img src="/images/OpenClaw-QQ.png" alt="QQ 交流群" style="width:140px;height:140px;border-radius:var(--radius-md);border:1px solid var(--border-primary)">
<div style="font-size:var(--font-size-sm);margin-top:8px;color:var(--text-secondary)">QQ 交流群</div>
</div>
<div style="text-align:center">
<img src="/images/OpenClawWx.png" alt="微信交流群" style="width:140px;height:140px;border-radius:var(--radius-md);border:1px solid var(--border-primary)">
<div style="font-size:var(--font-size-sm);margin-top:8px;color:var(--text-secondary)">微信交流群</div>
</div>
<div style="flex:1;min-width:200px;display:flex;flex-direction:column;gap:8px;padding-top:4px">
<div style="font-size:var(--font-size-sm);color:var(--text-secondary)">扫码或点击链接加入交流群,反馈问题、获取帮助</div>
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:8px">
<a class="btn btn-primary btn-sm" href="https://qt.cool/c/OpenClaw" target="_blank" rel="noopener">加入 QQ 群</a>
<a class="btn btn-primary btn-sm" href="https://qt.cool/c/OpenClawWx" target="_blank" rel="noopener">加入微信群</a>
<a class="btn btn-secondary btn-sm" href="https://yb.tencent.com/gp/i/LsvIw7mdR7Lb" target="_blank" rel="noopener">元宝派社群</a>
</div>
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:8px">
2000 人大群,满员自动切换 · 碰到问题可直接在群内反馈
</div>
</div>
</div>
`
}
const PROJECTS = [
{
name: 'OpenClaw',

View File

@@ -35,26 +35,28 @@ export async function render() {
}
async function loadDashboardData(page) {
try {
const [services, version, logs] = await Promise.all([
api.getServicesStatus(),
api.getVersionInfo(),
api.readLogTail('gateway', 20),
])
const [servicesRes, versionRes, logsRes] = await Promise.allSettled([
api.getServicesStatus(),
api.getVersionInfo(),
api.readLogTail('gateway', 20),
])
renderStatCards(page, services, version)
renderLogs(page, logs)
bindActions(page)
} catch (e) {
toast('加载仪表盘数据失败: ' + e, 'error')
}
const services = servicesRes.status === 'fulfilled' ? servicesRes.value : []
const version = versionRes.status === 'fulfilled' ? versionRes.value : {}
const logs = logsRes.status === 'fulfilled' ? logsRes.value : ''
if (servicesRes.status === 'rejected') toast('服务状态加载失败', 'error')
if (versionRes.status === 'rejected') toast('版本信息加载失败', 'error')
if (logsRes.status === 'rejected') toast('日志加载失败', 'error')
renderStatCards(page, services, version)
renderLogs(page, logs)
bindActions(page)
}
function renderStatCards(page, services, version) {
const cardsEl = page.querySelector('#stat-cards')
const gw = services.find(s => s.label.includes('gateway'))
const guardian = services.find(s => s.label.includes('guardian.watch'))
const watchdog = services.find(s => s.label.includes('watchdog'))
const gw = services.find(s => s.label === 'ai.openclaw.gateway')
const runningCount = services.filter(s => s.running).length
cardsEl.innerHTML = `
@@ -64,30 +66,21 @@ function renderStatCards(page, services, version) {
<span class="status-dot ${gw?.running ? 'running' : 'stopped'}"></span>
</div>
<div class="stat-card-value">${gw?.running ? '运行中' : '已停止'}</div>
<div class="stat-card-meta">${gw?.pid ? 'PID: ' + gw.pid : ''}</div>
</div>
<div class="stat-card">
<div class="stat-card-header">
<span class="stat-card-label">Guardian</span>
<span class="status-dot ${guardian?.running ? 'running' : 'stopped'}"></span>
</div>
<div class="stat-card-value">${guardian?.running ? '运行中' : '已停止'}</div>
<div class="stat-card-meta">健康监控</div>
</div>
<div class="stat-card">
<div class="stat-card-header">
<span class="stat-card-label">Watchdog</span>
<span class="status-dot ${watchdog?.running ? 'running' : 'stopped'}"></span>
</div>
<div class="stat-card-value">${watchdog?.running ? '运行中' : '已停止'}</div>
<div class="stat-card-meta">看门狗</div>
<div class="stat-card-meta">${gw?.pid ? 'PID: ' + gw.pid : '未启动'}</div>
</div>
<div class="stat-card">
<div class="stat-card-header">
<span class="stat-card-label">版本</span>
</div>
<div class="stat-card-value">${version.current || '未知'}</div>
<div class="stat-card-meta">服务 ${runningCount}/${services.length} 运行中</div>
<div class="stat-card-meta">${version.update_available ? '有新版本: ' + version.latest : '已是最新'}</div>
</div>
<div class="stat-card">
<div class="stat-card-header">
<span class="stat-card-label">服务</span>
</div>
<div class="stat-card-value">${runningCount}/${services.length}</div>
<div class="stat-card-meta">运行中</div>
</div>
`
}

View File

@@ -5,7 +5,15 @@
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
let _delegated = false
// HTML 转义,防止 XSS
function escapeHtml(str) {
if (!str) return ''
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
export async function render() {
const page = document.createElement('div')
@@ -18,10 +26,12 @@ export async function render() {
</div>
<div id="cftunnel-card" class="config-section">
<div class="config-section-title">cftunnel 内网穿透</div>
<div class="form-hint" style="margin-bottom:var(--space-md)">通过 Cloudflare Tunnel 将本地服务暴露到公网,无需公网 IP 和端口映射。</div>
<div id="cftunnel-content">加载中...</div>
</div>
<div id="clawapp-card" class="config-section">
<div class="config-section-title">ClawApp 移动客户端</div>
<div class="form-hint" style="margin-bottom:var(--space-md)">基于 LobeChat 的 AI 对话客户端,通过 Gateway 连接模型服务。支持本地和外网访问。</div>
<div id="clawapp-content">加载中...</div>
</div>
`
@@ -150,9 +160,6 @@ function renderClawapp(el, s) {
// ===== 事件绑定 =====
function bindEvents(page) {
if (_delegated) return
_delegated = true
page.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]')
if (!btn) return
@@ -206,7 +213,7 @@ async function handleCftunnelLogs(page) {
<span style="font-weight:600;font-size:var(--font-size-sm)">最近日志</span>
<button class="btn btn-secondary btn-sm" data-action="cftunnel-logs">收起</button>
</div>
<pre class="log-viewer">${logs || '暂无日志'}</pre>
<pre class="log-viewer">${escapeHtml(logs) || '暂无日志'}</pre>
</div>
`
} catch (e) {

View File

@@ -55,21 +55,24 @@ function renderConfig(page, state) {
<div class="form-group">
<label class="form-label">端口</label>
<input class="form-input" id="gw-port" type="number" value="${gw.port || 18789}" min="1024" max="65535">
<div class="form-hint">Gateway 监听的本地端口号,范围 1024-65535。修改后需重载服务生效。</div>
</div>
<div class="form-group">
<label class="form-label">绑定模式</label>
<select class="form-input" id="gw-bind">
<option value="loopback" ${gw.bind === 'loopback' ? 'selected' : ''}>Loopback (仅本机)</option>
<option value="all" ${gw.bind === 'all' ? 'selected' : ''}>All (所有接口)</option>
<option value="loopback" ${gw.bind === 'loopback' ? 'selected' : ''}>仅本机 (Loopback)</option>
<option value="all" ${gw.bind === 'all' ? 'selected' : ''}>所有接口 (All)</option>
</select>
<div class="form-hint">仅本机只允许本机访问127.0.0.1)。所有接口:允许局域网内其他设备通过 IP 访问。</div>
</div>
</div>
<div class="form-group">
<label class="form-label">运行模式</label>
<select class="form-input" id="gw-mode">
<option value="local" ${gw.mode === 'local' ? 'selected' : ''}>Local</option>
<option value="remote" ${gw.mode === 'remote' ? 'selected' : ''}>Remote</option>
<option value="local" ${gw.mode === 'local' ? 'selected' : ''}>本地模式</option>
<option value="remote" ${gw.mode === 'remote' ? 'selected' : ''}>远程模式</option>
</select>
<div class="form-hint">本地模式Gateway 直接调用本机模型服务。远程模式Gateway 转发请求到远程 API 端点。</div>
</div>
</div>
@@ -81,6 +84,7 @@ function renderConfig(page, state) {
<input class="form-input" id="gw-token" type="password" value="${gw.authToken || ''}" placeholder="留空则无认证" style="flex:1">
<button class="btn btn-sm btn-secondary" id="btn-toggle-token">显示</button>
</div>
<div class="form-hint">访问 Gateway 时需要携带的认证令牌。留空表示不启用认证,任何人都可以直接调用。建议在开放网络环境下设置。</div>
</div>
</div>
@@ -89,6 +93,7 @@ function renderConfig(page, state) {
<div class="form-group">
<label class="form-label">Tailscale 地址</label>
<input class="form-input" id="gw-tailscale" value="${gw.tailscale?.address || ''}" placeholder="如 100.x.x.x:18789">
<div class="form-hint">通过 Tailscale 组网暴露 Gateway 的地址。填写后,远程设备可通过此地址访问。留空表示不使用 Tailscale。</div>
</div>
</div>
`
@@ -117,7 +122,7 @@ async function saveConfig(page, state) {
state.config.gateway = {
...state.config.gateway,
port, bind, mode, authToken,
tailscale: tailscaleAddr ? { address: tailscaleAddr } : (state.config.gateway?.tailscale || undefined),
tailscale: tailscaleAddr.trim() ? { address: tailscaleAddr.trim() } : undefined,
}
try {

View File

@@ -5,10 +5,10 @@ import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
const LOG_TABS = [
{ key: 'gateway', label: 'Gateway' },
{ key: 'gateway-err', label: 'Gateway Err' },
{ key: 'guardian', label: 'Guardian' },
{ key: 'guardian-backup', label: 'Backup' },
{ key: 'gateway', label: 'Gateway 日志' },
{ key: 'gateway-err', label: 'Gateway 错误' },
{ key: 'guardian', label: '守护进程' },
{ key: 'guardian-backup', label: '备份日志' },
{ key: 'config-audit', label: '审计日志' },
]

View File

@@ -6,9 +6,9 @@ import { toast } from '../components/toast.js'
import { showModal } from '../components/modal.js'
const CATEGORIES = [
{ key: 'memory', label: '工作记忆' },
{ key: 'archive', label: '记忆归档' },
{ key: 'core', label: '核心文件' },
{ key: 'memory', label: '工作记忆', desc: '当前活跃的工作上下文、决策记录和进度追踪' },
{ key: 'archive', label: '记忆归档', desc: '已归档的历史记忆文件,按时间周期整理' },
{ key: 'core', label: '核心文件', desc: 'Agent 核心配置文件,如 AGENTS.md、CLAUDE.md 等' },
]
export async function render() {
@@ -23,6 +23,7 @@ export async function render() {
<div class="tab-bar">
${CATEGORIES.map((c, i) => `<div class="tab${i === 0 ? ' active' : ''}" data-tab="${c.key}">${c.label}</div>`).join('')}
</div>
<div class="form-hint" id="category-desc" style="margin-bottom:var(--space-md)">${CATEGORIES[0].desc}</div>
<div class="memory-layout">
<div class="memory-sidebar">
<div style="padding:0 var(--space-sm) var(--space-sm);display:flex;gap:4px">
@@ -57,6 +58,8 @@ export async function render() {
tab.classList.add('active')
state.category = tab.dataset.tab
state.currentPath = null
const cat = CATEGORIES.find(c => c.key === state.category)
page.querySelector('#category-desc').textContent = cat?.desc || ''
resetEditor(page)
loadFiles(page, state)
}
@@ -72,11 +75,11 @@ export async function render() {
page.querySelector('#btn-new-file').onclick = () => {
showModal({
title: '新建记忆文件',
fields: [{ name: 'filename', label: '文件名', placeholder: '如 notes.md' }],
fields: [{ name: 'filename', label: '文件名', placeholder: '如 notes.md', hint: '建议使用 .md 格式,文件将保存到当前分类目录下' }],
onConfirm: async ({ filename }) => {
if (!filename) return
try {
await api.writeMemoryFile(filename, `# ${filename}\n\n`)
await api.writeMemoryFile(filename, `# ${filename}\n\n`, state.category)
toast(`已创建 ${filename}`, 'success')
loadFiles(page, state)
} catch (e) {
@@ -90,7 +93,9 @@ export async function render() {
page.querySelector('#btn-del-file').onclick = async () => {
if (!state.currentPath) return
const name = state.currentPath.split('/').pop()
if (!confirm(`确定删除 ${name}`)) return
const { showConfirm } = await import('../components/modal.js')
const yes = await showConfirm(`确定删除 ${name}`)
if (!yes) return
try {
await api.deleteMemoryFile(state.currentPath)
toast(`已删除 ${name}`, 'success')
@@ -270,7 +275,16 @@ async function exportZip(state) {
try {
const zipPath = await api.exportMemoryZip(state.category)
const label = CATEGORIES.find(c => c.key === state.category)?.label || state.category
toast(`已导出: ${label}${zipPath}`, 'success')
// 尝试用 Tauri shell open 打开文件所在目录
try {
const { open } = await import('@tauri-apps/plugin-shell')
const dir = zipPath.substring(0, zipPath.lastIndexOf('/')) || zipPath
await open(dir)
toast(`已导出: ${label}${zipPath}`, 'success')
} catch {
// fallback仅显示路径
toast(`已导出: ${label}${zipPath}`, 'success')
}
} catch (e) {
toast('打包下载失败: ' + e, 'error')
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,8 +4,17 @@
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { showConfirm } from '../components/modal.js'
let _delegated = false
// HTML 转义,防止 XSS
function escapeHtml(str) {
if (!str) return ''
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
export async function render() {
const page = document.createElement('div')
@@ -47,6 +56,7 @@ async function loadVersion(page) {
try {
const info = await api.getVersionInfo()
const ver = info.current || '未知'
const hasUpdate = info.update_available
bar.innerHTML = `
<div class="stat-cards" style="margin-bottom:var(--space-lg)">
<div class="stat-card">
@@ -54,7 +64,8 @@ async function loadVersion(page) {
<span class="stat-card-label">当前版本</span>
</div>
<div class="stat-card-value">${ver}</div>
<div class="stat-card-meta">${info.update_available ? '新版本可用' : '已是最新版本'}</div>
<div class="stat-card-meta">${hasUpdate ? '新版本: ' + info.latest : '已是最新版本'}</div>
${hasUpdate ? '<button class="btn btn-primary btn-sm" data-action="upgrade" style="margin-top:var(--space-sm)">升级到最新版</button>' : ''}
</div>
</div>
`
@@ -71,33 +82,52 @@ async function loadServices(page) {
const services = await api.getServicesStatus()
renderServices(container, services)
} catch (e) {
container.innerHTML = `<div style="color:var(--error)">加载服务列表失败: ${e}</div>`
container.innerHTML = `<div style="color:var(--error)">加载服务列表失败: ${escapeHtml(String(e))}</div>`
}
}
function renderServices(container, services) {
if (!services || !services.length) {
container.innerHTML = '<div style="color:var(--text-tertiary)">暂无服务</div>'
return
}
container.innerHTML = services.map(s => `
<div class="service-card" data-label="${s.label}">
const gw = services.find(s => s.label === 'ai.openclaw.gateway')
// Gateway 专属卡片(带安装/卸载)
let html = ''
if (gw) {
html += `
<div class="service-card" data-label="${gw.label}">
<div class="service-info">
<span class="status-dot ${s.running ? 'running' : 'stopped'}"></span>
<span class="status-dot ${gw.running ? 'running' : 'stopped'}"></span>
<div>
<div class="service-name">${s.label}</div>
<div class="service-desc">${s.description || ''}${s.pid ? ' (PID: ' + s.pid + ')' : ''}</div>
<div class="service-name">${gw.label}</div>
<div class="service-desc">${gw.description || ''}${gw.pid ? ' (PID: ' + gw.pid + ')' : ''}</div>
</div>
</div>
<div class="service-actions">
${s.running
? `<button class="btn btn-secondary btn-sm" data-action="restart" data-label="${s.label}">重启</button>
<button class="btn btn-danger btn-sm" data-action="stop" data-label="${s.label}">停止</button>`
: `<button class="btn btn-primary btn-sm" data-action="start" data-label="${s.label}">启动</button>`
${gw.running
? `<button class="btn btn-secondary btn-sm" data-action="restart" data-label="${gw.label}">重启</button>
<button class="btn btn-danger btn-sm" data-action="stop" data-label="${gw.label}">停止</button>
<button class="btn btn-danger btn-sm" data-action="uninstall-gateway">卸载</button>`
: `<button class="btn btn-primary btn-sm" data-action="start" data-label="${gw.label}">启动</button>
<button class="btn btn-danger btn-sm" data-action="uninstall-gateway">卸载</button>`
}
</div>
</div>
`).join('')
</div>`
} else {
html += `
<div class="service-card">
<div class="service-info">
<span class="status-dot stopped"></span>
<div>
<div class="service-name">ai.openclaw.gateway</div>
<div class="service-desc">Gateway 服务未安装</div>
</div>
</div>
<div class="service-actions">
<button class="btn btn-primary btn-sm" data-action="install-gateway">安装</button>
</div>
</div>`
}
container.innerHTML = html
}
// ===== 备份管理 =====
@@ -139,9 +169,6 @@ function renderBackups(container, backups) {
// ===== 事件绑定(事件委托) =====
function bindEvents(page) {
if (_delegated) return
_delegated = true
page.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]')
if (!btn) return
@@ -164,6 +191,15 @@ function bindEvents(page) {
case 'delete-backup':
await handleDeleteBackup(btn.dataset.name, page)
break
case 'upgrade':
await handleUpgrade(btn, page)
break
case 'install-gateway':
await handleInstallGateway(btn, page)
break
case 'uninstall-gateway':
await handleUninstallGateway(btn, page)
break
}
} catch (e) {
toast(e.toString(), 'error')
@@ -193,15 +229,46 @@ async function handleCreateBackup(page) {
}
async function handleRestoreBackup(name, page) {
if (!confirm(`确定要恢复备份 "${name}" 吗?\n当前配置将自动备份后再恢复。`)) return
const yes = await showConfirm(`确定要恢复备份 "${name}" 吗?\n当前配置将自动备份后再恢复。`)
if (!yes) return
await api.restoreBackup(name)
toast('配置已恢复', 'success')
await loadBackups(page)
}
async function handleDeleteBackup(name, page) {
if (!confirm(`确定要删除备份 "${name}" 吗?此操作不可撤销。`)) return
const yes = await showConfirm(`确定要删除备份 "${name}" 吗?此操作不可撤销。`)
if (!yes) return
await api.deleteBackup(name)
toast('备份已删除', 'success')
await loadBackups(page)
}
// ===== 升级操作 =====
async function handleUpgrade(btn, page) {
const yes = await showConfirm('确定要升级 OpenClaw 到最新版本吗?\n升级过程中 Gateway 会短暂中断。')
if (!yes) return
btn.textContent = '升级中...'
const msg = await api.upgradeOpenclaw()
toast(msg, 'success')
await loadVersion(page)
}
// ===== Gateway 安装/卸载 =====
async function handleInstallGateway(btn, page) {
btn.textContent = '安装中...'
await api.installGateway()
toast('Gateway 服务已安装', 'success')
await loadServices(page)
}
async function handleUninstallGateway(btn, page) {
const yes = await showConfirm('确定要卸载 Gateway 服务吗?\n这会停止服务并移除 LaunchAgent。')
if (!yes) return
btn.textContent = '卸载中...'
await api.uninstallGateway()
toast('Gateway 服务已卸载', 'success')
await loadServices(page)
}

View File

@@ -76,6 +76,14 @@
background: var(--bg-glass);
}
/* 配置项说明 */
.form-hint {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
margin-top: 4px;
line-height: 1.5;
}
/* 配置编辑区 */
.config-section {
background: var(--bg-card);
@@ -165,25 +173,3 @@
padding: var(--space-sm) var(--space-lg);
border-bottom: 1px solid var(--border-secondary);
}
.editor-content {
flex: 1;
padding: var(--space-lg);
overflow-y: auto;
font-family: var(--font-mono);
font-size: var(--font-size-sm);
line-height: 1.7;
}
.editor-content textarea {
width: 100%;
height: 100%;
background: transparent;
border: none;
resize: none;
outline: none;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: var(--font-size-sm);
line-height: 1.7;
}