From 84a6ab4d45a60669b644dcdb16282a6402c73ec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Sat, 28 Feb 2026 03:42:19 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=85=A8=E5=B1=80=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E5=8E=9F=E7=94=9F=E5=BC=B9=E7=AA=97=E4=B8=BA=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=20Modal=20=E5=B9=B6=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E9=A1=B9=E7=9B=AE=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 替换所有不可用的 `alert`, `confirm`, `prompt` 调用为异步的自定义 `Modal` 组件以适配 Tauri WebView 的 API 限制。 - 优化与重构核心服务组件接口,增加模型有效性测试 (`test_model`) 以及依赖更新支持。 - 同步补齐 `README.md` 与 `CHANGELOG.md` 新增的系统特性说明(含仪表盘、日记、存储、重构页面调整)。 --- CHANGELOG.md | 6 +- README.md | 14 +- src-tauri/capabilities/default.json | 9 + src-tauri/gen/schemas/capabilities.json | 2 +- src-tauri/src/commands/config.rs | 266 +++++-- src-tauri/src/commands/extensions.rs | 44 +- src-tauri/src/commands/logs.rs | 76 +- src-tauri/src/commands/memory.rs | 59 +- src-tauri/src/commands/mod.rs | 9 + src-tauri/src/commands/service.rs | 212 ++++-- src-tauri/src/lib.rs | 4 + src-tauri/tauri.conf.json | 3 +- src/components/modal.js | 95 ++- src/lib/tauri-api.js | 25 +- src/main.js | 3 - src/pages/about.js | 83 ++- src/pages/dashboard.js | 59 +- src/pages/extensions.js | 17 +- src/pages/gateway.js | 15 +- src/pages/logs.js | 8 +- src/pages/memory.js | 28 +- src/pages/models.js | 899 +++++++++++++++++++----- src/pages/services.js | 113 ++- src/style/pages.css | 30 +- 24 files changed, 1591 insertions(+), 488 deletions(-) create mode 100644 src-tauri/capabilities/default.json diff --git a/CHANGELOG.md b/CHANGELOG.md index b1bba6e..278a70b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,11 @@ ## [0.1.0] - 2026-02-28 -### 新增 +### 优化项 (Refactor & UI Improvements) + +- 移除了由浏览器原生提供的阻塞级弹窗 (`alert`、`confirm`、`prompt`),以自定义风格化的 `Modal` 组件重写全站交互以兼容 Tauri WebView 限制。 +- 采用更集约化的布局逻辑重构并合并部分低频页面,大幅精简前后端不必要的接口耦合。 +- 当在终端更新网关与模型配置时,将自动重启 Gateway 以立即生效设定。 - 仪表盘:系统概览、服务状态一览 - 服务管理:OpenClaw 服务启停、版本检测、一键升级、Gateway 安装/卸载 diff --git a/README.md b/README.md index a7a5781..cb624a4 100644 --- a/README.md +++ b/README.md @@ -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 相关连接与状态检测 +- **关于** — 版本信息、核心组件与开源项目指引、社群与联系入口 ## 技术架构 diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 0000000..8d7bc73 --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,9 @@ +{ + "identifier": "default", + "description": "ClawPanel 默认权限", + "windows": ["main"], + "permissions": [ + "core:default", + "shell:allow-open" + ] +} diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 9e26dfe..78758e0 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{} \ No newline at end of file +{"default":{"identifier":"default","description":"ClawPanel 默认权限","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open"]}} \ No newline at end of file diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 0e45ff2..454c914 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -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 { - 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 { #[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 { - 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 { #[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 { - // 从 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 { + 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 { + 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 { + let current = get_local_version(); + let latest = get_latest_version().await; + let update_available = match (¤t, &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 { + 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 { - 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 { 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 { 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 { + let output = Command::new("id") + .arg("-u") + .output() + .map_err(|e| format!("获取 UID 失败: {e}"))?; + String::from_utf8_lossy(&output.stdout) + .trim() + .parse::() + .map_err(|e| format!("解析 UID 失败: {e}")) +} + +/// 重载 Gateway 服务(使用 kickstart -k 强制重启) #[tauri::command] pub fn reload_gateway() -> Result { - 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, 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::(&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::(&text) + .ok() + .and_then(|v| { + let data = v.get("data")?.as_array()?; + let mut ids: Vec = 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 { + 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 { + 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()) +} diff --git a/src-tauri/src/commands/extensions.rs b/src-tauri/src/commands/extensions.rs index 39c35eb..013c380 100644 --- a/src-tauri/src/commands/extensions.rs +++ b/src-tauri/src/commands/extensions.rs @@ -59,6 +59,27 @@ fn cftunnel_bin() -> String { "cftunnel".to_string() } +/// 通过 launchctl 检测 cftunnel 服务实际运行状态 +fn check_cftunnel_launchctl() -> Option<(Option, 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::().ok(); + // 第一列是 PID(数字表示在运行,- 表示未运行) + let running = pid.is_some(); + return Some((pid, running)); + } + } + } + None +} + #[tauri::command] pub fn get_cftunnel_status() -> Result { let bin = cftunnel_bin(); @@ -87,6 +108,19 @@ pub fn get_cftunnel_status() -> Result { } } + // 补充检测:如果 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 { #[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}"))?; diff --git a/src-tauri/src/commands/logs.rs b/src-tauri/src/commands/logs.rs index cfde66d..69f24ae 100644 --- a/src-tauri/src/commands/logs.rs +++ b/src-tauri/src/commands/logs.rs @@ -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 { +pub fn read_log_tail(log_name: String, lines: Option) -> Result { + 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 { pub fn search_log( log_name: String, query: String, - max_results: usize, + max_results: Option, ) -> Result, 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 = reader + let mut matched: Vec = 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()) } diff --git a/src-tauri/src/commands/memory.rs b/src-tauri/src/commands/memory.rs index 5f19739..83bd204 100644 --- a/src-tauri/src/commands/memory.rs +++ b/src-tauri/src/commands/memory.rs @@ -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 { - // 安全检查:路径不能包含 .. - 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 { } #[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) -> 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()); } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index bed6bbd..72cc6a3 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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") +} diff --git a/src-tauri/src/commands/service.rs b/src-tauri/src/commands/service.rs index 5759ced..9303582 100644 --- a/src-tauri/src/commands/service.rs +++ b/src-tauri/src/commands/service.rs @@ -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 { + 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::().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 { let home = dirs::home_dir().unwrap_or_default(); let agents_dir = home.join("Library/LaunchAgents"); @@ -30,12 +39,12 @@ fn scan_plist_labels() -> Vec { 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) { + 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 = 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::() { + 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, 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::() { - 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, 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(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d0bedc5..c7716a0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a348a5a..8e889c8 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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": { diff --git a/src/components/modal.js b/src/components/modal.js index b380bce..8a4b735 100644 --- a/src/components/modal.js +++ b/src/components/modal.js @@ -1,21 +1,90 @@ /** * Modal 弹窗组件 */ + +// 转义 HTML 属性值,防止双引号等字符破坏 HTML 结构 +function escapeAttr(str) { + if (!str) return '' + return String(str) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>') +} + +/** + * 自定义确认弹窗,替代原生 confirm() + * Tauri WebView 不支持原生 confirm/alert,必须用自定义弹窗 + * @param {string} message 确认消息 + * @returns {Promise} 用户选择确认返回 true,取消返回 false + */ +export function showConfirm(message) { + return new Promise((resolve) => { + const overlay = document.createElement('div') + overlay.className = 'modal-overlay' + overlay.innerHTML = ` + + ` + 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 => ` -
- - ${f.type === 'select' - ? ` + ${f.label} + + ${f.hint ? `
${f.hint}
` : ''} +
` + } + if (f.type === 'select') { + return ` +
+ + ` - : `` - } -
- `).join('') + + ${f.hint ? `
${f.hint}
` : ''} + ` + } + return ` +
+ + + ${f.hint ? `
${f.hint}
` : ''} +
` + }).join('') overlay.innerHTML = `
cftunnel 内网穿透
+
通过 Cloudflare Tunnel 将本地服务暴露到公网,无需公网 IP 和端口映射。
加载中...
ClawApp 移动客户端
+
基于 LobeChat 的 AI 对话客户端,通过 Gateway 连接模型服务。支持本地和外网访问。
加载中...
` @@ -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) { 最近日志 -
${logs || '暂无日志'}
+
${escapeHtml(logs) || '暂无日志'}
` } catch (e) { diff --git a/src/pages/gateway.js b/src/pages/gateway.js index 8d112a2..a747ed3 100644 --- a/src/pages/gateway.js +++ b/src/pages/gateway.js @@ -55,21 +55,24 @@ function renderConfig(page, state) {
+
Gateway 监听的本地端口号,范围 1024-65535。修改后需重载服务生效。
+
仅本机:只允许本机访问(127.0.0.1)。所有接口:允许局域网内其他设备通过 IP 访问。
+
本地模式:Gateway 直接调用本机模型服务。远程模式:Gateway 转发请求到远程 API 端点。
@@ -81,6 +84,7 @@ function renderConfig(page, state) { +
访问 Gateway 时需要携带的认证令牌。留空表示不启用认证,任何人都可以直接调用。建议在开放网络环境下设置。
@@ -89,6 +93,7 @@ function renderConfig(page, state) {
+
通过 Tailscale 组网暴露 Gateway 的地址。填写后,远程设备可通过此地址访问。留空表示不使用 Tailscale。
` @@ -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 { diff --git a/src/pages/logs.js b/src/pages/logs.js index a77af40..6f23d21 100644 --- a/src/pages/logs.js +++ b/src/pages/logs.js @@ -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: '审计日志' }, ] diff --git a/src/pages/memory.js b/src/pages/memory.js index b4fc2d7..9c63ba0 100644 --- a/src/pages/memory.js +++ b/src/pages/memory.js @@ -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() {
${CATEGORIES.map((c, i) => `
${c.label}
`).join('')}
+
${CATEGORIES[0].desc}
@@ -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') } diff --git a/src/pages/models.js b/src/pages/models.js index 551357d..727a4be 100644 --- a/src/pages/models.js +++ b/src/pages/models.js @@ -1,10 +1,47 @@ /** * 模型配置页面 - * 模型增删改查 + 选择默认主模型应用(未选中自动成为 fallback) + * 服务商管理 + 模型增删改查 + 主模型选择 */ import { api } from '../lib/tauri-api.js' import { toast } from '../components/toast.js' -import { showModal } from '../components/modal.js' +import { showModal, showConfirm } from '../components/modal.js' + +// API 接口类型选项 +const API_TYPES = [ + { value: 'openai-completions', label: 'OpenAI 兼容 (最常用)' }, + { value: 'anthropic', label: 'Anthropic 原生' }, + { value: 'openai-responses', label: 'OpenAI Responses' }, + { value: 'google-gemini', label: 'Google Gemini' }, +] + +// 服务商快捷预设 +const PROVIDER_PRESETS = [ + { key: 'openai', label: 'OpenAI 官方', baseUrl: 'https://api.openai.com/v1', api: 'openai-completions' }, + { key: 'anthropic', label: 'Anthropic 官方', baseUrl: 'https://api.anthropic.com', api: 'anthropic' }, + { key: 'deepseek', label: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1', api: 'openai-completions' }, + { key: 'google', label: 'Google Gemini', baseUrl: 'https://generativelanguage.googleapis.com/v1beta', api: 'google-gemini' }, +] + +// 常用模型预设(按服务商分组) +const MODEL_PRESETS = { + openai: [ + { id: 'gpt-4o', name: 'GPT-4o', contextWindow: 128000 }, + { id: 'gpt-4o-mini', name: 'GPT-4o Mini', contextWindow: 128000 }, + { id: 'o3-mini', name: 'o3 Mini', contextWindow: 200000, reasoning: true }, + ], + anthropic: [ + { id: 'claude-sonnet-4-5-20250514', name: 'Claude Sonnet 4.5', contextWindow: 200000 }, + { id: 'claude-haiku-3-5-20241022', name: 'Claude Haiku 3.5', contextWindow: 200000 }, + ], + deepseek: [ + { id: 'deepseek-chat', name: 'DeepSeek V3', contextWindow: 64000 }, + { id: 'deepseek-reasoner', name: 'DeepSeek R1', contextWindow: 64000, reasoning: true }, + ], + google: [ + { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', contextWindow: 1000000, reasoning: true }, + { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', contextWindow: 1000000 }, + ], +} export async function render() { const page = document.createElement('div') @@ -13,22 +50,33 @@ export async function render() { page.innerHTML = `
- - - + + +
+
+ 服务商是模型的来源(如 OpenAI、DeepSeek 等)。每个服务商下可添加多个模型。 + 标记为「主模型」的将优先使用,其余作为备选自动切换。配置修改后自动保存。
+
+ +
加载中...
` - const state = { config: null } + const state = { config: null, search: '', undoStack: [] } await loadConfig(page, state) - - // 事件委托绑定 bindTopActions(page, state) + + // 搜索框实时过滤 + page.querySelector('#model-search').oninput = (e) => { + state.search = e.target.value.trim().toLowerCase() + renderProviders(page, state) + } + return page } @@ -42,12 +90,10 @@ async function loadConfig(page, state) { } } -// 获取当前默认主模型 function getCurrentPrimary(config) { return config?.agents?.defaults?.model?.primary || '' } -// 收集所有 provider/model-id 组合 function collectAllModels(config) { const result = [] const providers = config?.models?.providers || {} @@ -60,7 +106,11 @@ function collectAllModels(config) { return result } -// 渲染默认模型状态栏 +function getApiTypeLabel(apiType) { + return API_TYPES.find(t => t.value === apiType)?.label || apiType || '未知' +} + +// 渲染当前主模型状态栏 function renderDefaultBar(page, state) { const bar = page.querySelector('#default-model-bar') const primary = getCurrentPrimary(state.config) @@ -69,84 +119,121 @@ function renderDefaultBar(page, state) { bar.innerHTML = `
-
当前应用配置
+
当前生效配置
主模型: ${primary || '未配置'}
- Fallback: + 备选模型: ${fallbacks.length ? fallbacks.join(', ') : '无'}
+
主模型不可用时,系统会自动切换到备选模型
` } -// 渲染 Provider 列表 +// 渲染服务商列表(渲染完后直接绑定事件) function renderProviders(page, state) { const listEl = page.querySelector('#providers-list') const providers = state.config?.models?.providers || {} const keys = Object.keys(providers) const primary = getCurrentPrimary(state.config) + const search = state.search || '' if (!keys.length) { - listEl.innerHTML = '
暂无 Provider,点击上方按钮添加
' + listEl.innerHTML = ` +
+ 暂无服务商,点击「+ 添加服务商」开始配置 +
` return } listEl.innerHTML = keys.map(key => { const p = providers[key] const models = p.models || [] + const filtered = search + ? models.filter((m) => { + const id = (typeof m === 'string' ? m : m.id).toLowerCase() + const name = (m.name || '').toLowerCase() + return id.includes(search) || name.includes(search) + }) + : models + const hiddenCount = models.length - filtered.length return `
- ${key} ${p.api || p.apiType || ''} · ${models.length} 个模型 + ${key} ${getApiTypeLabel(p.api)} · ${models.length} 个模型
+
+ ${models.length >= 2 ? ` +
+ + + +
` : ''}
- ${renderModelCards(key, models, primary)} + ${renderModelCards(key, filtered, primary, search)} + ${hiddenCount > 0 ? `
已隐藏 ${hiddenCount} 个不匹配的模型
` : ''}
` }).join('') - bindProviderEvents(page, state) + // innerHTML 完成后,直接给每个按钮绑定 onclick + bindProviderButtons(listEl, page, state) } -// 渲染单个 Provider 下的模型卡片 -function renderModelCards(providerKey, models, primary) { +// 渲染模型卡片(支持搜索高亮和批量选择 checkbox) +function renderModelCards(providerKey, models, primary, search) { if (!models.length) { - return '
暂无模型
' + return '
暂无模型,点击「+ 模型」添加
' } - return models.map((m, i) => { + return models.map((m) => { const id = typeof m === 'string' ? m : m.id const name = m.name || id const full = `${providerKey}/${id}` const isPrimary = full === primary const borderColor = isPrimary ? 'var(--success)' : 'var(--border-primary)' const bgColor = isPrimary ? 'var(--success-muted)' : 'var(--bg-tertiary)' + const meta = [] + if (name !== id) meta.push(name) + if (m.contextWindow) meta.push((m.contextWindow / 1000) + 'K 上下文') + // 测试状态标签:成功显示耗时,失败显示不可用 + let latencyTag = '' + if (m.testStatus === 'fail') { + latencyTag = `不可用` + } else if (m.latency != null) { + const color = m.latency < 3000 ? 'success' : m.latency < 8000 ? 'warning' : 'error' + const bg = color === 'success' ? 'var(--success-muted)' : color === 'warning' ? 'var(--warning-muted, #fef3c7)' : 'var(--error-muted, #fee2e2)' + const fg = color === 'success' ? 'var(--success)' : color === 'warning' ? 'var(--warning, #d97706)' : 'var(--error)' + latencyTag = `${(m.latency / 1000).toFixed(1)}s` + } + const testTime = m.lastTestAt ? formatTestTime(m.lastTestAt) : '' + if (testTime) meta.push(testTime) return ` -
+
+
${id} ${isPrimary ? '主模型' : ''} - ${m.reasoning ? 'Reasoning' : ''} -
-
- ${name !== id ? name + ' · ' : ''}${m.contextWindow ? (m.contextWindow / 1000) + 'K ctx' : ''}${m.cost?.input ? ' · $' + m.cost.input + '/$' + m.cost.output : ''} + ${m.reasoning ? '推理' : ''} + ${latencyTag}
+
${meta.join(' · ') || ''}
- ${!isPrimary ? `` : ''} + ${!isPrimary ? '' : ''}
@@ -155,52 +242,159 @@ function renderModelCards(providerKey, models, primary) { }).join('') } -// 绑定 Provider 列表内的事件 -function bindProviderEvents(page, state) { - const listEl = page.querySelector('#providers-list') - listEl.querySelectorAll('[data-action]').forEach(btn => { - btn.onclick = () => { - const section = btn.closest('[data-provider]') - const providerKey = section.dataset.provider - const action = btn.dataset.action +// 格式化测试时间为相对时间 +function formatTestTime(ts) { + const diff = Date.now() - ts + if (diff < 60000) return '刚刚测试' + if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前测试` + if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前测试` + return `${Math.floor(diff / 86400000)} 天前测试` +} - if (action === 'delete-provider') { - if (!confirm(`确定删除 Provider "${providerKey}" 及其所有模型?`)) return - delete state.config.models.providers[providerKey] - renderProviders(page, state) - renderDefaultBar(page, state) - toast(`已删除 ${providerKey}`, 'info') - } else if (action === 'add-model') { - addModel(page, state, providerKey) - } else if (action === 'edit-provider') { - editProvider(page, state, providerKey) - } else if (action === 'delete-model') { - const card = btn.closest('.model-card') - const idx = parseInt(card.dataset.index) - const models = state.config.models.providers[providerKey].models - models.splice(idx, 1) - renderProviders(page, state) - renderDefaultBar(page, state) - } else if (action === 'edit-model') { - const card = btn.closest('.model-card') - const idx = parseInt(card.dataset.index) - editModel(page, state, providerKey, idx) - } else if (action === 'set-primary') { - const card = btn.closest('.model-card') - const full = card.dataset.full - setPrimary(state, full) - renderProviders(page, state) - renderDefaultBar(page, state) - toast(`已设为主模型: ${full}`, 'success') - } else if (action === 'test-model') { - const card = btn.closest('.model-card') - const idx = parseInt(card.dataset.index) - testModel(btn, state, providerKey, idx) - } +// 根据 model-id 找到原始 index +function findModelIdx(provider, modelId) { + return (provider.models || []).findIndex(m => (typeof m === 'string' ? m : m.id) === modelId) +} + +// ===== 自动保存 + 撤销机制 ===== + +// 保存快照到撤销栈(变更前调用) +function pushUndo(state) { + state.undoStack.push(JSON.parse(JSON.stringify(state.config))) + if (state.undoStack.length > 20) state.undoStack.shift() +} + +// 撤销上一步 +async function undo(page, state) { + if (!state.undoStack.length) return + state.config = state.undoStack.pop() + renderProviders(page, state) + renderDefaultBar(page, state) + updateUndoBtn(page, state) + await doAutoSave(state) + toast('已撤销', 'info') +} + +// 自动保存(防抖 300ms) +let _saveTimer = null +function autoSave(state) { + clearTimeout(_saveTimer) + _saveTimer = setTimeout(() => doAutoSave(state), 300) +} + +async function doAutoSave(state) { + try { + const primary = getCurrentPrimary(state.config) + if (primary) applyDefaultModel(state) + await api.writeOpenclawConfig(state.config) + try { await api.reloadGateway() } catch {} + toast('已自动保存', 'success') + } catch (e) { + toast('自动保存失败: ' + e, 'error') + } +} + +// 更新撤销按钮状态 +function updateUndoBtn(page, state) { + const btn = page.querySelector('#btn-undo') + if (!btn) return + const n = state.undoStack.length + btn.disabled = !n + btn.textContent = n ? `↩ 撤销 (${n})` : '↩ 撤销' +} + +// 渲染完成后,直接给每个 [data-action] 按钮绑定 onclick +function bindProviderButtons(listEl, page, state) { + listEl.querySelectorAll('[data-action]').forEach(btn => { + const action = btn.dataset.action + const section = btn.closest('[data-provider]') + if (!section) return + const providerKey = section.dataset.provider + const provider = state.config.models.providers[providerKey] + if (!provider) return + const card = btn.closest('.model-card') + + btn.onclick = (e) => { + e.stopPropagation() + handleAction(action, btn, card, section, providerKey, provider, page, state) } }) } +// 统一处理按钮动作 +async function handleAction(action, btn, card, section, providerKey, provider, page, state) { + switch (action) { + case 'edit-provider': + editProvider(page, state, providerKey) + break + case 'add-model': + addModel(page, state, providerKey) + break + case 'fetch-models': + fetchRemoteModels(btn, page, state, providerKey) + break + case 'delete-provider': { + const yes = await showConfirm(`确定删除「${providerKey}」及其所有模型?`) + if (!yes) return + pushUndo(state) + delete state.config.models.providers[providerKey] + renderProviders(page, state) + renderDefaultBar(page, state) + updateUndoBtn(page, state) + autoSave(state) + toast(`已删除 ${providerKey}`, 'info') + break + } + case 'select-all': + handleSelectAll(section) + break + case 'batch-delete': + handleBatchDelete(section, page, state, providerKey) + break + case 'batch-test': + handleBatchTest(section, state, providerKey) + break + case 'delete-model': { + if (!card) return + const modelId = card.dataset.modelId + const yes = await showConfirm(`确定删除模型「${modelId}」?`) + if (!yes) return + pushUndo(state) + const idx = findModelIdx(provider, modelId) + if (idx >= 0) provider.models.splice(idx, 1) + renderProviders(page, state) + renderDefaultBar(page, state) + updateUndoBtn(page, state) + autoSave(state) + toast(`已删除 ${modelId}`, 'info') + break + } + case 'edit-model': { + if (!card) return + const idx = findModelIdx(provider, card.dataset.modelId) + if (idx >= 0) editModel(page, state, providerKey, idx) + break + } + case 'set-primary': { + if (!card) return + pushUndo(state) + setPrimary(state, card.dataset.full) + renderProviders(page, state) + renderDefaultBar(page, state) + updateUndoBtn(page, state) + autoSave(state) + toast('已设为主模型', 'success') + break + } + case 'test-model': { + if (!card) return + const idx = findModelIdx(provider, card.dataset.modelId) + if (idx >= 0) testModel(btn, state, providerKey, idx) + break + } + } +} + // 设置主模型(仅修改 state,不写入文件) function setPrimary(state, full) { if (!state.config.agents) state.config.agents = {} @@ -209,61 +403,7 @@ function setPrimary(state, full) { state.config.agents.defaults.model.primary = full } -// 顶部按钮事件绑定 -function bindTopActions(page, state) { - page.querySelector('#btn-add-provider').onclick = () => addProvider(page, state) - - page.querySelector('#btn-save-models').onclick = async () => { - const btn = page.querySelector('#btn-save-models') - btn.disabled = true - btn.textContent = '保存中...' - try { - await api.writeOpenclawConfig(state.config) - toast('模型配置已保存,正在重载 Gateway...', 'info') - try { - await api.reloadGateway() - toast('Gateway 已重载,模型配置已生效', 'success') - } catch (e) { - toast('配置已保存,但重载 Gateway 失败: ' + e, 'warning') - } - } catch (e) { - toast('保存失败: ' + e, 'error') - } finally { - btn.disabled = false - btn.textContent = '保存模型配置' - } - } - - page.querySelector('#btn-apply-default').onclick = async () => { - const btn = page.querySelector('#btn-apply-default') - const primary = getCurrentPrimary(state.config) - if (!primary) { - toast('请先选择一个主模型', 'warning') - return - } - btn.disabled = true - btn.textContent = '应用中...' - try { - applyDefaultModel(state) - await api.writeOpenclawConfig(state.config) - renderDefaultBar(page, state) - toast('默认模型已应用,正在重载 Gateway...', 'info') - try { - await api.reloadGateway() - toast('Gateway 已重载,默认模型已生效', 'success') - } catch (e) { - toast('配置已保存,但重载 Gateway 失败: ' + e, 'warning') - } - } catch (e) { - toast('应用失败: ' + e, 'error') - } finally { - btn.disabled = false - btn.textContent = '应用默认模型' - } - } -} - -// 应用默认模型:primary + 其余自动成为 fallback +// 应用默认模型:primary + 其余自动成为备选 function applyDefaultModel(state) { const primary = getCurrentPrimary(state.config) const allModels = collectAllModels(state.config) @@ -273,102 +413,477 @@ function applyDefaultModel(state) { defaults.model.primary = primary defaults.model.fallbacks = fallbacks - // 生成 models 映射(所有模型的空配置对象) const modelsMap = {} modelsMap[primary] = {} for (const fb of fallbacks) modelsMap[fb] = {} defaults.models = modelsMap } -// 添加 Provider -function addProvider(page, state) { - showModal({ - title: '添加 Provider', - fields: [ - { name: 'key', label: 'Provider 名称', placeholder: '如 openai, newapi' }, - { name: 'baseUrl', label: 'Base URL', placeholder: 'https://api.openai.com/v1' }, - { name: 'apiKey', label: 'API Key', placeholder: 'sk-...' }, - ], - onConfirm: ({ key, baseUrl, apiKey }) => { - if (!key) return - if (!state.config.models) state.config.models = { mode: 'replace', providers: {} } - if (!state.config.models.providers) state.config.models.providers = {} - state.config.models.providers[key] = { - baseUrl: baseUrl || '', - apiKey: apiKey || '', - api: 'openai-completions', - models: [], - } - renderProviders(page, state) - toast(`已添加 Provider: ${key}`, 'success') - }, - }) +// 顶部按钮事件 +function bindTopActions(page, state) { + page.querySelector('#btn-add-provider').onclick = () => addProvider(page, state) + page.querySelector('#btn-undo').onclick = () => undo(page, state) } -// 编辑 Provider 属性 +// 添加服务商(带预设快捷选择) +function addProvider(page, state) { + // 构建预设按钮 HTML + const presetsHtml = PROVIDER_PRESETS.map(p => + `` + ).join('') + + const overlay = document.createElement('div') + overlay.className = 'modal-overlay' + overlay.innerHTML = ` + + ` + + document.body.appendChild(overlay) + + // 预设按钮点击自动填充 + overlay.querySelectorAll('.preset-btn').forEach(btn => { + btn.onclick = () => { + const preset = PROVIDER_PRESETS.find(p => p.key === btn.dataset.preset) + if (!preset) return + overlay.querySelector('[data-name="key"]').value = preset.key + overlay.querySelector('[data-name="baseUrl"]').value = preset.baseUrl + overlay.querySelector('[data-name="api"]').value = preset.api + // 高亮选中的预设 + overlay.querySelectorAll('.preset-btn').forEach(b => b.style.opacity = '0.5') + btn.style.opacity = '1' + } + }) + + overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove() }) + overlay.querySelector('[data-action="cancel"]').onclick = () => overlay.remove() + + overlay.querySelector('[data-action="confirm"]').onclick = () => { + const key = overlay.querySelector('[data-name="key"]').value.trim() + const baseUrl = overlay.querySelector('[data-name="baseUrl"]').value.trim() + const apiKey = overlay.querySelector('[data-name="apiKey"]').value.trim() + const apiType = overlay.querySelector('[data-name="api"]').value + if (!key) { toast('请填写服务商名称', 'warning'); return } + pushUndo(state) + if (!state.config.models) state.config.models = { mode: 'replace', providers: {} } + if (!state.config.models.providers) state.config.models.providers = {} + state.config.models.providers[key] = { + baseUrl: baseUrl || '', + apiKey: apiKey || '', + api: apiType, + models: [], + } + overlay.remove() + renderProviders(page, state) + updateUndoBtn(page, state) + autoSave(state) + toast(`已添加服务商: ${key}`, 'success') + } + + overlay.querySelector('[data-name="key"]')?.focus() +} + +// 编辑服务商 function editProvider(page, state, providerKey) { const p = state.config.models.providers[providerKey] showModal({ - title: `编辑 Provider: ${providerKey}`, + title: `编辑服务商: ${providerKey}`, fields: [ - { name: 'baseUrl', label: 'Base URL', value: p.baseUrl || '' }, - { name: 'apiKey', label: 'API Key', value: p.apiKey || '' }, - { name: 'api', label: 'API 类型', value: p.api || 'openai-completions' }, + { name: 'baseUrl', label: '接口地址', value: p.baseUrl || '', hint: '模型服务的 API 地址,通常以 /v1 结尾' }, + { name: 'apiKey', label: '密钥 (API Key)', value: p.apiKey || '', hint: '修改后自动保存生效' }, + { + name: 'api', label: '接口类型', type: 'select', value: p.api || 'openai-completions', + options: API_TYPES, + hint: '大多数中转站选「OpenAI 兼容」即可', + }, ], onConfirm: ({ baseUrl, apiKey, api: apiType }) => { + pushUndo(state) p.baseUrl = baseUrl p.apiKey = apiKey p.api = apiType renderProviders(page, state) - toast('Provider 已更新', 'success') + updateUndoBtn(page, state) + autoSave(state) + toast('服务商已更新', 'success') }, }) } -// 添加模型 +// 添加模型(带预设快捷选择) function addModel(page, state, providerKey) { - showModal({ - title: `添加模型到 ${providerKey}`, - fields: [ - { name: 'id', label: '模型 ID', placeholder: '如 claude-opus-4-6' }, - { name: 'name', label: '显示名称', placeholder: '如 Claude Opus 4.6' }, - { name: 'contextWindow', label: 'Context Window', placeholder: '如 200000' }, - ], - onConfirm: ({ id, name, contextWindow }) => { - if (!id) return - const model = { id, name: name || id, reasoning: false, input: ['text', 'image'] } - if (contextWindow) model.contextWindow = parseInt(contextWindow) || 0 - state.config.models.providers[providerKey].models.push(model) + const presets = MODEL_PRESETS[providerKey] || [] + const existingIds = (state.config.models.providers[providerKey].models || []) + .map(m => typeof m === 'string' ? m : m.id) + + // 过滤掉已添加的模型 + const available = presets.filter(p => !existingIds.includes(p.id)) + + const fields = [ + { name: 'id', label: '模型 ID', placeholder: '如 gpt-4o', hint: '必须与服务商支持的模型名一致' }, + { name: 'name', label: '显示名称(选填)', placeholder: '如 GPT-4o', hint: '方便识别的友好名称' }, + { name: 'contextWindow', label: '上下文长度(选填)', placeholder: '如 128000', hint: '模型支持的最大 Token 数' }, + { name: 'reasoning', label: '这是推理模型(如 o3、R1、QwQ 等)', type: 'checkbox', value: false, hint: '推理模型会使用特殊的调用方式' }, + ] + + if (available.length) { + // 有预设可用,构建自定义弹窗 + const overlay = document.createElement('div') + overlay.className = 'modal-overlay' + + const presetBtns = available.map(p => + `` + ).join('') + + overlay.innerHTML = ` + + ` + + document.body.appendChild(overlay) + bindModalEvents(overlay, fields, (vals) => { + pushUndo(state) + doAddModel(state, providerKey, vals) renderProviders(page, state) renderDefaultBar(page, state) - toast(`已添加模型: ${id}`, 'success') - }, - }) + updateUndoBtn(page, state) + autoSave(state) + }) + + // 预设按钮:点击直接添加 + overlay.querySelectorAll('.preset-btn').forEach(btn => { + btn.onclick = () => { + const preset = available.find(p => p.id === btn.dataset.mid) + if (!preset) return + pushUndo(state) + const model = { ...preset, input: ['text', 'image'] } + state.config.models.providers[providerKey].models.push(model) + overlay.remove() + renderProviders(page, state) + renderDefaultBar(page, state) + updateUndoBtn(page, state) + autoSave(state) + toast(`已添加模型: ${preset.name}`, 'success') + } + }) + } else { + // 无预设,直接弹普通 modal + showModal({ + title: `添加模型到 ${providerKey}`, + fields, + onConfirm: (vals) => { + pushUndo(state) + doAddModel(state, providerKey, vals) + renderProviders(page, state) + renderDefaultBar(page, state) + updateUndoBtn(page, state) + autoSave(state) + }, + }) + } } -// 编辑模型属性 +// 构建表单字段 HTML(用于自定义弹窗) +function buildFieldsHtml(fields) { + return fields.map(f => { + if (f.type === 'checkbox') { + return ` +
+ + ${f.hint ? `
${f.hint}
` : ''} +
` + } + return ` +
+ + + ${f.hint ? `
${f.hint}
` : ''} +
` + }).join('') +} + +// 绑定自定义弹窗的通用事件 +function bindModalEvents(overlay, fields, onConfirm) { + overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove() }) + overlay.querySelector('[data-action="cancel"]').onclick = () => overlay.remove() + overlay.querySelector('[data-action="confirm"]').onclick = () => { + const result = {} + overlay.querySelectorAll('[data-name]').forEach(el => { + result[el.dataset.name] = el.type === 'checkbox' ? el.checked : el.value + }) + overlay.remove() + onConfirm(result) + } +} + +// 实际添加模型到 state +function doAddModel(state, providerKey, vals) { + if (!vals.id) { toast('请填写模型 ID', 'warning'); return } + const model = { + id: vals.id.trim(), + name: vals.name?.trim() || vals.id.trim(), + reasoning: !!vals.reasoning, + input: ['text', 'image'], + } + if (vals.contextWindow) model.contextWindow = parseInt(vals.contextWindow) || 0 + state.config.models.providers[providerKey].models.push(model) + toast(`已添加模型: ${model.name}`, 'success') +} + +// 编辑模型 function editModel(page, state, providerKey, idx) { const m = state.config.models.providers[providerKey].models[idx] showModal({ title: `编辑模型: ${m.id}`, fields: [ - { name: 'id', label: '模型 ID', value: m.id || '' }, - { name: 'name', label: '显示名称', value: m.name || '' }, - { name: 'contextWindow', label: 'Context Window', value: String(m.contextWindow || '') }, + { name: 'id', label: '模型 ID', value: m.id || '', hint: '必须与服务商支持的模型名一致' }, + { name: 'name', label: '显示名称', value: m.name || '', hint: '方便识别的友好名称' }, + { name: 'contextWindow', label: '上下文长度', value: String(m.contextWindow || ''), hint: '模型支持的最大 Token 数' }, + { name: 'reasoning', label: '这是推理模型', type: 'checkbox', value: !!m.reasoning, hint: '推理模型会使用特殊的调用方式' }, ], onConfirm: (vals) => { if (!vals.id) return - m.id = vals.id - m.name = vals.name || vals.id + pushUndo(state) + m.id = vals.id.trim() + m.name = vals.name?.trim() || vals.id.trim() + m.reasoning = !!vals.reasoning if (vals.contextWindow) m.contextWindow = parseInt(vals.contextWindow) || 0 renderProviders(page, state) renderDefaultBar(page, state) + updateUndoBtn(page, state) + autoSave(state) toast('模型已更新', 'success') }, }) } -// 测试模型连通性 +// 全选/取消全选 +function handleSelectAll(section) { + const boxes = section.querySelectorAll('.model-checkbox') + const allChecked = [...boxes].every(cb => cb.checked) + boxes.forEach(cb => { cb.checked = !allChecked }) + // 更新批量删除按钮状态 + const batchDelBtn = section.querySelector('[data-action="batch-delete"]') + if (batchDelBtn) batchDelBtn.disabled = allChecked +} + +// 批量删除选中的模型 +async function handleBatchDelete(section, page, state, providerKey) { + const checked = [...section.querySelectorAll('.model-checkbox:checked')] + if (!checked.length) { toast('请先勾选要删除的模型', 'warning'); return } + const ids = checked.map(cb => cb.dataset.modelId) + const yes = await showConfirm(`确定删除选中的 ${ids.length} 个模型?\n${ids.join(', ')}`) + if (!yes) return + pushUndo(state) + const provider = state.config.models.providers[providerKey] + provider.models = (provider.models || []).filter(m => { + const mid = typeof m === 'string' ? m : m.id + return !ids.includes(mid) + }) + renderProviders(page, state) + renderDefaultBar(page, state) + updateUndoBtn(page, state) + autoSave(state) + toast(`已删除 ${ids.length} 个模型`, 'info') +} + +// 批量测试:勾选的模型,没勾选则测试全部(记录耗时和状态) +async function handleBatchTest(section, state, providerKey) { + const provider = state.config.models.providers[providerKey] + const checked = [...section.querySelectorAll('.model-checkbox:checked')] + const ids = checked.length + ? checked.map(cb => cb.dataset.modelId) + : (provider.models || []).map(m => typeof m === 'string' ? m : m.id) + + if (!ids.length) { toast('没有可测试的模型', 'warning'); return } + + const batchBtn = section.querySelector('[data-action="batch-test"]') + if (batchBtn) { batchBtn.disabled = true; batchBtn.textContent = '测试中...' } + + let ok = 0, fail = 0 + for (const modelId of ids) { + const model = (provider.models || []).find(m => (typeof m === 'string' ? m : m.id) === modelId) + const start = Date.now() + try { + await api.testModel(provider.baseUrl, provider.apiKey || '', modelId) + const elapsed = Date.now() - start + if (model && typeof model === 'object') { + model.latency = elapsed + model.lastTestAt = Date.now() + model.testStatus = 'ok' + delete model.testError + } + ok++ + toast(`${modelId} 连通正常 ${(elapsed / 1000).toFixed(1)}s (${ok + fail}/${ids.length})`, 'success') + } catch (e) { + const elapsed = Date.now() - start + if (model && typeof model === 'object') { + model.latency = null + model.lastTestAt = Date.now() + model.testStatus = 'fail' + model.testError = String(e).slice(0, 100) + } + fail++ + toast(`${modelId} 不可用 ${(elapsed / 1000).toFixed(1)}s: ${e}`, 'error') + } + } + + if (batchBtn) { batchBtn.disabled = false; batchBtn.textContent = '批量测试' } + // 刷新卡片显示最新状态 + const page = section.closest('.page') + if (page) { + renderProviders(page, state) + renderDefaultBar(page, state) + } + toast(`批量测试完成:${ok} 成功,${fail} 失败`, ok === ids.length ? 'success' : 'warning') +} + +// 从服务商远程获取模型列表 +async function fetchRemoteModels(btn, page, state, providerKey) { + const provider = state.config.models.providers[providerKey] + btn.disabled = true + btn.textContent = '获取中...' + + try { + const remoteIds = await api.listRemoteModels(provider.baseUrl, provider.apiKey || '') + btn.disabled = false + btn.textContent = '获取列表' + + // 标记已添加的模型 + const existingIds = (provider.models || []).map(m => typeof m === 'string' ? m : m.id) + + // 弹窗展示可选模型列表 + const overlay = document.createElement('div') + overlay.className = 'modal-overlay' + overlay.innerHTML = ` + + ` + document.body.appendChild(overlay) + + const listEl = overlay.querySelector('#remote-model-list') + const filterInput = overlay.querySelector('#remote-filter') + const countEl = overlay.querySelector('#remote-selected-count') + + function renderRemoteList(filter) { + const filtered = filter + ? remoteIds.filter(id => id.toLowerCase().includes(filter.toLowerCase())) + : remoteIds + listEl.innerHTML = filtered.map(id => { + const exists = existingIds.includes(id) + return ` + ` + }).join('') + updateCount() + } + + function updateCount() { + const n = listEl.querySelectorAll('.remote-cb:checked').length + countEl.textContent = `已选 ${n} 个` + } + + renderRemoteList('') + filterInput.oninput = () => renderRemoteList(filterInput.value.trim()) + listEl.addEventListener('change', updateCount) + + overlay.querySelector('#remote-toggle-all').onclick = () => { + const cbs = listEl.querySelectorAll('.remote-cb:not(:disabled)') + const allChecked = [...cbs].every(cb => cb.checked) + cbs.forEach(cb => { cb.checked = !allChecked }) + updateCount() + } + + overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove() }) + overlay.querySelector('[data-action="cancel"]').onclick = () => overlay.remove() + overlay.querySelector('[data-action="confirm"]').onclick = () => { + const selected = [...listEl.querySelectorAll('.remote-cb:checked')].map(cb => cb.dataset.id) + if (!selected.length) { toast('请至少选择一个模型', 'warning'); return } + pushUndo(state) + for (const id of selected) { + provider.models.push({ id, input: ['text'] }) + } + overlay.remove() + renderProviders(page, state) + renderDefaultBar(page, state) + updateUndoBtn(page, state) + autoSave(state) + toast(`已添加 ${selected.length} 个模型`, 'success') + } + + filterInput.focus() + } catch (e) { + btn.disabled = false + btn.textContent = '获取列表' + toast(`获取模型列表失败: ${e}`, 'error') + } +} + +// 测试模型连通性(记录耗时和状态) async function testModel(btn, state, providerKey, idx) { const provider = state.config.models.providers[providerKey] const model = provider.models[idx] @@ -378,13 +893,35 @@ async function testModel(btn, state, providerKey, idx) { const origText = btn.textContent btn.textContent = '测试中...' + const start = Date.now() try { const reply = await api.testModel(provider.baseUrl, provider.apiKey || '', modelId) - toast(`${modelId} 连通正常: "${reply.slice(0, 60)}"`, 'success') + const elapsed = Date.now() - start + // 记录到模型对象 + if (typeof model === 'object') { + model.latency = elapsed + model.lastTestAt = Date.now() + model.testStatus = 'ok' + delete model.testError + } + toast(`${modelId} 连通正常 (${(elapsed / 1000).toFixed(1)}s): "${reply.slice(0, 50)}"`, 'success') } catch (e) { - toast(`${modelId} 测试失败: ${e}`, 'error') + const elapsed = Date.now() - start + if (typeof model === 'object') { + model.latency = null + model.lastTestAt = Date.now() + model.testStatus = 'fail' + model.testError = String(e).slice(0, 100) + } + toast(`${modelId} 不可用 (${(elapsed / 1000).toFixed(1)}s): ${e}`, 'error') } finally { btn.disabled = false btn.textContent = origText + // 刷新卡片显示最新状态 + const page = btn.closest('.page') + if (page) { + renderProviders(page, state) + renderDefaultBar(page, state) + } } } diff --git a/src/pages/services.js b/src/pages/services.js index 1cb7c7a..79d4251 100644 --- a/src/pages/services.js +++ b/src/pages/services.js @@ -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, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} 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 = `
@@ -54,7 +64,8 @@ async function loadVersion(page) { 当前版本
${ver}
-
${info.update_available ? '有新版本可用' : '已是最新版本'}
+
${hasUpdate ? '新版本: ' + info.latest : '已是最新版本'}
+ ${hasUpdate ? '' : ''}
` @@ -71,33 +82,52 @@ async function loadServices(page) { const services = await api.getServicesStatus() renderServices(container, services) } catch (e) { - container.innerHTML = `
加载服务列表失败: ${e}
` + container.innerHTML = `
加载服务列表失败: ${escapeHtml(String(e))}
` } } function renderServices(container, services) { - if (!services || !services.length) { - container.innerHTML = '
暂无服务
' - return - } - container.innerHTML = services.map(s => ` -
+ const gw = services.find(s => s.label === 'ai.openclaw.gateway') + + // Gateway 专属卡片(带安装/卸载) + let html = '' + if (gw) { + html += ` +
- +
-
${s.label}
-
${s.description || ''}${s.pid ? ' (PID: ' + s.pid + ')' : ''}
+
${gw.label}
+
${gw.description || ''}${gw.pid ? ' (PID: ' + gw.pid + ')' : ''}
- ${s.running - ? ` - ` - : `` + ${gw.running + ? ` + + ` + : ` + ` }
-
- `).join('') +
` + } else { + html += ` +
+
+ +
+
ai.openclaw.gateway
+
Gateway 服务未安装
+
+
+
+ +
+
` + } + + 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) +} diff --git a/src/style/pages.css b/src/style/pages.css index 7274bf9..885fad0 100644 --- a/src/style/pages.css +++ b/src/style/pages.css @@ -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; -}