From dab61ccd24f910af841c47b97b17d4e27772f9ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Wed, 4 Mar 2026 12:16:58 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=A4=9A=E9=A1=B9?= =?UTF-8?q?=E5=85=B3=E9=94=AE=20Bug=EF=BC=8C=E4=B8=8E=20openclaw=20?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=E5=8D=8F=E8=AE=AE=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main.js: wsClient.connect 传参格式错误(完整 ws:// URL → host:port) - ws-client.js: request() 等待重连时不处理 onReady 握手失败 - gateway.js: bind 写入非法值 'all',改为 openclaw 合法值 'lan' - device.rs: connect payload 从 v2 升级到 v3,补充 platform/deviceFamily - config.rs: macOS reload_gateway 在 async fn 中用同步 Command 阻塞 tokio - service.rs: Windows check_service_status 端口硬编码 18789,改为读配置 - extensions.rs: parse_cftunnel_status 全角冒号解析失败,添加 split_after_colon - tauri-api.js: cachedInvoke miss 时 logRequest 被记录两次 - tauri-api.js: mock 补充 list_agents / restart_gateway - chat.js: 附件对象冗余 data 字段(双倍内存)+ 缩进修复 - services.js: 服务操作缺少操作中 toast 反馈 --- src-tauri/src/commands/agent.rs | 122 +++++++++++++---- src-tauri/src/commands/config.rs | 160 ++++++++++++++++------ src-tauri/src/commands/device.rs | 9 +- src-tauri/src/commands/extensions.rs | 121 ++++++++++++++++- src-tauri/src/commands/memory.rs | 41 +++--- src-tauri/src/commands/service.rs | 124 ++++++++++++----- src-tauri/src/lib.rs | 3 + src-tauri/src/utils.rs | 18 ++- src/components/toast.js | 16 ++- src/lib/tauri-api.js | 13 +- src/lib/ws-client.js | 13 +- src/main.js | 2 +- src/pages/about.js | 2 +- src/pages/agents.js | 53 ++++++-- src/pages/chat-debug.js | 1 - src/pages/chat.js | 193 +++++++++++++++++++++++---- src/pages/dashboard.js | 2 +- src/pages/extensions.js | 74 +++++++++- src/pages/gateway.js | 11 +- src/pages/logs.js | 4 +- src/pages/memory.js | 14 +- src/pages/models.js | 22 ++- src/pages/services.js | 10 +- src/style/chat.css | 63 +++++++++ 24 files changed, 882 insertions(+), 209 deletions(-) diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs index 242e0ec..9cbf99c 100644 --- a/src-tauri/src/commands/agent.rs +++ b/src-tauri/src/commands/agent.rs @@ -2,14 +2,15 @@ use serde_json::Value; use std::fs; use std::io::Write; -use crate::utils::openclaw_command; +use crate::utils::openclaw_command_async; /// 获取 agent 列表 #[tauri::command] -pub fn list_agents() -> Result { - let output = openclaw_command() +pub async fn list_agents() -> Result { + let output = openclaw_command_async() .args(["agents", "list", "--json"]) .output() + .await .map_err(|e| format!("执行失败: {e}"))?; if !output.status.success() { @@ -24,7 +25,7 @@ pub fn list_agents() -> Result { /// 创建新 agent #[tauri::command] -pub fn add_agent(name: String, model: String, workspace: Option) -> Result { +pub async fn add_agent(name: String, model: String, workspace: Option) -> Result { let ws = match workspace { Some(ref w) if !w.is_empty() => std::path::PathBuf::from(w), _ => super::openclaw_dir() @@ -48,9 +49,10 @@ pub fn add_agent(name: String, model: String, workspace: Option) -> Resu args.push(model); } - let output = openclaw_command() + let output = openclaw_command_async() .args(&args) .output() + .await .map_err(|e| format!("执行失败: {e}"))?; if !output.status.success() { @@ -61,19 +63,20 @@ pub fn add_agent(name: String, model: String, workspace: Option) -> Resu let stdout = String::from_utf8_lossy(&output.stdout); serde_json::from_str(&stdout).unwrap_or(Value::String("ok".into())); // 返回最新列表 - list_agents() + list_agents().await } /// 删除 agent #[tauri::command] -pub fn delete_agent(id: String) -> Result { +pub async fn delete_agent(id: String) -> Result { if id == "main" { return Err("不能删除默认 Agent".into()); } - let output = openclaw_command() + let output = openclaw_command_async() .args(["agents", "delete", &id]) .output() + .await .map_err(|e| format!("执行失败: {e}"))?; if !output.status.success() { @@ -91,35 +94,69 @@ pub fn update_agent_identity( name: Option, emoji: Option, ) -> Result { - let mut args = vec![ - "agents".to_string(), - "set-identity".to_string(), - "--agent".to_string(), - id, - "--json".to_string(), - ]; + let path = super::openclaw_dir().join("openclaw.json"); + let content = fs::read_to_string(&path) + .map_err(|e| format!("读取配置失败: {e}"))?; + let mut config: Value = serde_json::from_str(&content) + .map_err(|e| format!("解析 JSON 失败: {e}"))?; + + let agents_list = config + .get_mut("agents") + .and_then(|a| a.get_mut("list")) + .and_then(|l| l.as_array_mut()) + .ok_or("配置格式错误")?; + + let agent = agents_list + .iter_mut() + .find(|a| a.get("id").and_then(|v| v.as_str()) == Some(&id)) + .ok_or(format!("Agent「{id}」不存在"))?; + + // 确保 identity 字段存在且为对象 + if !agent.get("identity").and_then(|i| i.as_object()).is_some() { + agent.as_object_mut() + .ok_or("Agent 格式错误")? + .insert("identity".to_string(), serde_json::json!({})); + } + + let identity = agent + .get_mut("identity") + .and_then(|i| i.as_object_mut()) + .ok_or("identity 格式错误")?; if let Some(n) = name { if !n.is_empty() { - args.push("--name".to_string()); - args.push(n); + identity.insert("name".to_string(), Value::String(n)); } } if let Some(e) = emoji { if !e.is_empty() { - args.push("--emoji".to_string()); - args.push(e); + identity.insert("emoji".to_string(), Value::String(e)); } } - let output = openclaw_command() - .args(&args) - .output() - .map_err(|e| format!("执行失败: {e}"))?; + // 提前提取 workspace 路径(克隆为 String,避免借用冲突) + let workspace_path = agent.get("workspace") + .and_then(|w| w.as_str()) + .map(|s| s.to_string()) + .or_else(|| { + config.get("agents") + .and_then(|a| a.get("defaults")) + .and_then(|d| d.get("workspace")) + .and_then(|w| w.as_str()) + .map(|s| s.to_string()) + }); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("更新失败: {stderr}")); + let json = serde_json::to_string_pretty(&config) + .map_err(|e| format!("序列化失败: {e}"))?; + fs::write(&path, json) + .map_err(|e| format!("写入配置失败: {e}"))?; + + // 删除 IDENTITY.md 文件,让配置文件生效 + if let Some(ws_str) = workspace_path { + let identity_file = std::path::PathBuf::from(ws_str).join("IDENTITY.md"); + if identity_file.exists() { + let _ = fs::remove_file(&identity_file); + } } Ok("已更新".into()) @@ -178,3 +215,36 @@ fn collect_dir_to_zip( } Ok(()) } + +/// 更新 agent 模型配置 +#[tauri::command] +pub fn update_agent_model(id: String, model: String) -> Result { + let path = super::openclaw_dir().join("openclaw.json"); + let content = fs::read_to_string(&path) + .map_err(|e| format!("读取配置失败: {e}"))?; + let mut config: Value = serde_json::from_str(&content) + .map_err(|e| format!("解析 JSON 失败: {e}"))?; + + let agents_list = config + .get_mut("agents") + .and_then(|a| a.get_mut("list")) + .and_then(|l| l.as_array_mut()) + .ok_or("配置格式错误")?; + + let agent = agents_list + .iter_mut() + .find(|a| a.get("id").and_then(|v| v.as_str()) == Some(&id)) + .ok_or(format!("Agent「{id}」不存在"))?; + + let model_obj = serde_json::json!({ "primary": model }); + agent.as_object_mut() + .ok_or("Agent 格式错误")? + .insert("model".to_string(), model_obj); + + let json = serde_json::to_string_pretty(&config) + .map_err(|e| format!("序列化失败: {e}"))?; + fs::write(&path, json) + .map_err(|e| format!("写入配置失败: {e}"))?; + + Ok("已更新".into()) +} diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index bdbe9bb..cde7dc4 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -51,8 +51,21 @@ pub fn read_openclaw_config() -> Result { 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) - .map_err(|e| format!("解析 JSON 失败: {e}")) + let mut config: Value = serde_json::from_str(&content) + .map_err(|e| format!("解析 JSON 失败: {e}"))?; + + // 自动清理 UI 专属字段,防止污染配置导致 CLI 启动失败 + if has_ui_fields(&config) { + config = strip_ui_fields(config); + // 静默写回清理后的配置 + let bak = super::openclaw_dir().join("openclaw.json.bak"); + let _ = fs::copy(&path, &bak); + let json = serde_json::to_string_pretty(&config) + .map_err(|e| format!("序列化失败: {e}"))?; + let _ = fs::write(&path, json); + } + + Ok(config) } #[tauri::command] @@ -70,27 +83,61 @@ pub fn write_openclaw_config(config: Value) -> Result<(), String> { .map_err(|e| format!("写入失败: {e}")) } -/// 递归清理 models 数组中的 UI 专属字段(lastTestAt, latency, testStatus, testError) -/// 并为缺少 name 字段的模型自动补上 name = id -fn strip_ui_fields(mut val: Value) -> Value { - if let Some(obj) = val.as_object_mut() { - // 递归处理 providers -> xxx -> models 数组 - if let Some(models) = obj.get("models") { - if let Some(providers) = models.as_object() { - let mut new_models = providers.clone(); - for (_key, provider) in new_models.iter_mut() { - if let Some(pobj) = provider.as_object_mut() { - if let Some(Value::Array(arr)) = pobj.get_mut("models") { - for model in arr.iter_mut() { - if let Some(mobj) = model.as_object_mut() { - mobj.remove("lastTestAt"); - mobj.remove("latency"); - mobj.remove("testStatus"); - mobj.remove("testError"); - // 补上 name 字段(CLI 要求) - if !mobj.contains_key("name") { - if let Some(id) = mobj.get("id").and_then(|v| v.as_str()) { - mobj.insert("name".into(), Value::String(id.to_string())); +/// 检测配置中是否包含 UI 专属字段 +fn has_ui_fields(val: &Value) -> bool { + if let Some(obj) = val.as_object() { + if let Some(models_val) = obj.get("models") { + if let Some(models_obj) = models_val.as_object() { + if let Some(providers_val) = models_obj.get("providers") { + if let Some(providers_obj) = providers_val.as_object() { + for (_provider_name, provider_val) in providers_obj.iter() { + if let Some(provider_obj) = provider_val.as_object() { + if let Some(Value::Array(arr)) = provider_obj.get("models") { + for model in arr.iter() { + if let Some(mobj) = model.as_object() { + if mobj.contains_key("lastTestAt") + || mobj.contains_key("latency") + || mobj.contains_key("testStatus") + || mobj.contains_key("testError") + { + return true; + } + } + } + } + } + } + } + } + } + } + } + false +} + +/// 递归清理 models 数组中的 UI 专属字段(lastTestAt, latency, testStatus, testError) +/// 并为缺少 name 字段的模型自动补上 name = id +fn strip_ui_fields(mut val: Value) -> Value { + if let Some(obj) = val.as_object_mut() { + // 处理 models.providers.xxx.models 结构 + if let Some(models_val) = obj.get_mut("models") { + if let Some(models_obj) = models_val.as_object_mut() { + if let Some(providers_val) = models_obj.get_mut("providers") { + if let Some(providers_obj) = providers_val.as_object_mut() { + for (_provider_name, provider_val) in providers_obj.iter_mut() { + if let Some(provider_obj) = provider_val.as_object_mut() { + if let Some(Value::Array(arr)) = provider_obj.get_mut("models") { + for model in arr.iter_mut() { + if let Some(mobj) = model.as_object_mut() { + mobj.remove("lastTestAt"); + mobj.remove("latency"); + mobj.remove("testStatus"); + mobj.remove("testError"); + if !mobj.contains_key("name") { + if let Some(id) = mobj.get("id").and_then(|v| v.as_str()) { + mobj.insert("name".into(), Value::String(id.to_string())); + } + } } } } @@ -98,7 +145,6 @@ fn strip_ui_fields(mut val: Value) -> Value { } } } - obj.insert("models".into(), Value::Object(new_models)); } } } @@ -126,10 +172,10 @@ pub fn write_mcp_config(config: Value) -> Result<(), String> { .map_err(|e| format!("写入失败: {e}")) } -/// 获取本地安装的 openclaw 版本号 +/// 获取本地安装的 openclaw 版本号(异步版本) /// macOS: 优先从 npm 包的 package.json 读取(含完整后缀),fallback 到 CLI -/// Windows/Linux: 直接用 CLI -fn get_local_version() -> Option { +/// Windows/Linux: 优先读文件系统,fallback 到 CLI +async fn get_local_version() -> Option { // macOS: 通过 symlink 找到包目录,读 package.json 的 version #[cfg(target_os = "macos")] { @@ -167,8 +213,9 @@ fn get_local_version() -> Option { } } } - // 所有平台通用 fallback: CLI 输出 - let output = openclaw_command().arg("--version").output().ok()?; + // 所有平台通用 fallback: CLI 输出(异步) + use crate::utils::openclaw_command_async; + let output = openclaw_command_async().arg("--version").output().await.ok()?; let raw = String::from_utf8_lossy(&output.stdout).trim().to_string(); raw.split_whitespace().last().filter(|s| !s.is_empty()).map(String::from) } @@ -236,7 +283,7 @@ fn detect_installed_source() -> String { #[tauri::command] pub async fn get_version_info() -> Result { - let current = get_local_version(); + let current = get_local_version().await; let source = detect_installed_source(); let latest = get_latest_version_for(&source).await; let parse_ver = |v: &str| -> Vec { @@ -272,7 +319,8 @@ pub async fn upgrade_openclaw(app: tauri::AppHandle, source: String) -> Result Result Result { let _ = app.emit("upgrade-log", "Gateway 服务已重装"); @@ -362,7 +426,7 @@ pub async fn upgrade_openclaw(app: tauri::AppHandle, source: String) -> Result Result { /// macOS: launchctl kickstart -k /// Windows/Linux: openclaw gateway restart #[tauri::command] -pub fn reload_gateway() -> Result { +pub async fn reload_gateway() -> Result { #[cfg(target_os = "macos")] { let uid = get_uid()?; let target = format!("gui/{uid}/ai.openclaw.gateway"); - let output = Command::new("launchctl") + let output = tokio::process::Command::new("launchctl") .args(["kickstart", "-k", &target]) .output() + .await .map_err(|e| format!("重载失败: {e}"))?; if output.status.success() { Ok("Gateway 已重载".to_string()) @@ -572,14 +637,16 @@ pub fn reload_gateway() -> Result { } #[cfg(not(target_os = "macos"))] { - let cli_check = openclaw_command().arg("--version").output(); + use crate::utils::openclaw_command_async; + let cli_check = openclaw_command_async().arg("--version").output().await; match cli_check { Ok(o) if o.status.success() => {} _ => return Err("openclaw CLI 未安装,无法重载 Gateway".into()), } - let output = openclaw_command() + let output = openclaw_command_async() .args(["gateway", "restart"]) .output() + .await .map_err(|e| format!("重载失败: {e}"))?; if output.status.success() { Ok("Gateway 已重载".to_string()) @@ -590,6 +657,13 @@ pub fn reload_gateway() -> Result { } } +/// 重启 Gateway 服务(与 reload_gateway 相同实现) +#[tauri::command] +pub async fn restart_gateway() -> Result { + reload_gateway().await +} + + /// 测试模型连通性:向 provider 发送一个简单的 chat completion 请求 #[tauri::command] pub async fn test_model( @@ -730,9 +804,10 @@ pub async fn list_remote_models( /// 安装 Gateway 服务(执行 openclaw gateway install) #[tauri::command] -pub fn install_gateway() -> Result { +pub async fn install_gateway() -> Result { + use crate::utils::openclaw_command_async; // 先检测 openclaw CLI 是否可用 - let cli_check = openclaw_command().arg("--version").output(); + let cli_check = openclaw_command_async().arg("--version").output().await; match cli_check { Ok(o) if o.status.success() => {} _ => { @@ -744,9 +819,10 @@ pub fn install_gateway() -> Result { } } - let output = openclaw_command() + let output = openclaw_command_async() .args(["gateway", "install"]) .output() + .await .map_err(|e| format!("安装失败: {e}"))?; if output.status.success() { diff --git a/src-tauri/src/commands/device.rs b/src-tauri/src/commands/device.rs index 9d1fc17..d3e1d5a 100644 --- a/src-tauri/src/commands/device.rs +++ b/src-tauri/src/commands/device.rs @@ -92,9 +92,13 @@ pub fn create_connect_frame(nonce: String, gateway_token: String) -> Result Result &str { + // 半角冒号是 1 字节,全角冒号是 3 字节(UTF-8: \xef\xbc\x9a) + if let Some(pos) = s.find(':') { + return s[pos + 1..].trim(); + } + if let Some(pos) = s.find(':') { + return s[pos + ':'.len_utf8()..].trim(); + } + "" +} + /// 解析 cftunnel status 输出 fn parse_cftunnel_status(output: &str) -> serde_json::Map { let mut map = serde_json::Map::new(); for line in output.lines() { let line = line.trim(); if line.starts_with("隧道:") || line.starts_with("隧道:") { - let rest = line.splitn(2, ':').nth(1).unwrap_or("").trim(); + let rest = split_after_colon(line); let name = rest.split('(').next().unwrap_or(rest).trim(); map.insert("tunnel_name".into(), Value::String(name.to_string())); } else if line.starts_with("状态:") || line.starts_with("状态:") { - let rest = line.splitn(2, ':').nth(1).unwrap_or("").trim(); + let rest = split_after_colon(line); let running = rest.contains("运行中"); map.insert("running".into(), Value::Bool(running)); - if let Some(pid_str) = rest.split("PID:").nth(1) { + // 匹配英文和全角 'PID:' / 'PID:' + let pid_rest = rest.split("PID:").nth(1) + .or_else(|| rest.split("PID:").nth(1)); + if let Some(pid_str) = pid_rest { let pid = pid_str.trim().trim_end_matches(')').trim(); if let Ok(p) = pid.parse::() { map.insert("pid".into(), Value::Number(p.into())); @@ -228,6 +243,10 @@ pub fn get_cftunnel_logs(lines: Option) -> Result { pub fn get_clawapp_status() -> Result { let mut result = serde_json::Map::new(); + // 检测是否已安装(检查 npm 全局包) + let installed = check_clawapp_installed(); + result.insert("installed".into(), Value::Bool(installed)); + // 跨平台方式:尝试连接端口检测是否在运行 let running = std::net::TcpStream::connect_timeout( &"127.0.0.1:3210".parse().unwrap(), @@ -259,6 +278,30 @@ pub fn get_clawapp_status() -> Result { Ok(Value::Object(result)) } +/// 检测 ClawApp 是否已安装 +fn check_clawapp_installed() -> bool { + #[cfg(target_os = "windows")] + { + let mut cmd = Command::new("cmd"); + cmd.args(["/c", "npm", "list", "-g", "clawapp"]); + cmd.creation_flags(0x08000000); + if let Ok(out) = cmd.output() { + return out.status.success(); + } + false + } + #[cfg(not(target_os = "windows"))] + { + if let Ok(out) = Command::new("npm") + .args(["list", "-g", "clawapp"]) + .output() + { + return out.status.success(); + } + false + } +} + /// 一键安装 cftunnel /// macOS/Linux: bash 脚本安装 /// Windows: PowerShell 下载安装 @@ -358,3 +401,75 @@ Write-Output '安装完成' let _ = app.emit("install-log", "✅ cftunnel 安装成功"); Ok("安装成功".into()) } + +/// 一键安装 ClawApp(通过 npm) +#[tauri::command] +pub async fn install_clawapp(app: tauri::AppHandle) -> Result { + use std::process::Stdio; + use std::io::{BufRead, BufReader}; + use tauri::Emitter; + + let _ = app.emit("install-log", "开始安装 ClawApp..."); + let _ = app.emit("install-progress", 10); + + let _ = app.emit("install-log", "通过 npm 安装 clawapp..."); + let _ = app.emit("install-progress", 30); + + #[cfg(target_os = "windows")] + let mut child = { + let mut cmd = Command::new("cmd"); + cmd.args(["/c", "npm", "install", "-g", "clawapp"]); + cmd.creation_flags(0x08000000); + cmd.stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("启动安装进程失败: {e}"))? + }; + + #[cfg(not(target_os = "windows"))] + let mut child = { + Command::new("npm") + .args(["install", "-g", "clawapp"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("启动安装进程失败: {e}"))? + }; + + let stderr = child.stderr.take(); + let stdout = child.stdout.take(); + + let app2 = app.clone(); + let handle = std::thread::spawn(move || { + if let Some(pipe) = stderr { + for line in BufReader::new(pipe).lines().map_while(Result::ok) { + let _ = app2.emit("install-log", &line); + } + } + }); + + let mut progress = 40; + if let Some(pipe) = stdout { + for line in BufReader::new(pipe).lines().map_while(Result::ok) { + let _ = app.emit("install-log", &line); + if progress < 90 { + progress += 5; + let _ = app.emit("install-progress", progress); + } + } + } + + let _ = handle.join(); + let _ = app.emit("install-progress", 95); + + let status = child.wait().map_err(|e| format!("等待安装进程失败: {e}"))?; + let _ = app.emit("install-progress", 100); + + if !status.success() { + let _ = app.emit("install-log", "❌ 安装失败"); + return Err("安装失败,请查看日志".into()); + } + + let _ = app.emit("install-log", "✅ ClawApp 安装成功"); + Ok("安装成功".into()) +} diff --git a/src-tauri/src/commands/memory.rs b/src-tauri/src/commands/memory.rs index f441b8e..4c56217 100644 --- a/src-tauri/src/commands/memory.rs +++ b/src-tauri/src/commands/memory.rs @@ -2,7 +2,7 @@ use std::fs; use std::io::Write; use std::path::PathBuf; -use crate::utils::openclaw_command; +use crate::utils::openclaw_command_async; /// 检查路径是否包含不安全字符(目录遍历、绝对路径等) fn is_unsafe_path(path: &str) -> bool { @@ -13,12 +13,13 @@ fn is_unsafe_path(path: &str) -> bool { || (path.len() >= 2 && path.as_bytes()[1] == b':') // Windows 绝对路径 C:\ } -/// 根据 agent_id 获取 workspace 路径 +/// 根据 agent_id 获取 workspace 路径(异步版本) /// 调用 openclaw agents list --json 解析 -fn agent_workspace(agent_id: &str) -> Result { - let output = openclaw_command() +async fn agent_workspace(agent_id: &str) -> Result { + let output = openclaw_command_async() .args(["agents", "list", "--json"]) .output() + .await .map_err(|e| format!("执行 openclaw 失败: {e}"))?; if !output.status.success() { @@ -42,8 +43,8 @@ fn agent_workspace(agent_id: &str) -> Result { Err(format!("Agent「{agent_id}」不存在或无 workspace")) } -fn memory_dir_for_agent(agent_id: &str, category: &str) -> Result { - let ws = agent_workspace(agent_id)?; +async fn memory_dir_for_agent(agent_id: &str, category: &str) -> Result { + let ws = agent_workspace(agent_id).await?; Ok(match category { "memory" => ws.join("memory"), "archive" => { @@ -62,9 +63,9 @@ fn memory_dir_for_agent(agent_id: &str, category: &str) -> Result) -> Result, String> { +pub async fn list_memory_files(category: String, agent_id: Option) -> Result, String> { let aid = agent_id.as_deref().unwrap_or("main"); - let dir = memory_dir_for_agent(aid, &category)?; + let dir = memory_dir_for_agent(aid, &category).await?; if !dir.exists() { return Ok(vec![]); } @@ -105,16 +106,16 @@ fn collect_files( } #[tauri::command] -pub fn read_memory_file(path: String, agent_id: Option) -> Result { +pub async fn read_memory_file(path: String, agent_id: Option) -> Result { if is_unsafe_path(&path) { return Err("非法路径".to_string()); } let aid = agent_id.as_deref().unwrap_or("main"); let candidates = [ - memory_dir_for_agent(aid, "memory"), - memory_dir_for_agent(aid, "archive"), - memory_dir_for_agent(aid, "core"), + memory_dir_for_agent(aid, "memory").await, + memory_dir_for_agent(aid, "archive").await, + memory_dir_for_agent(aid, "core").await, ]; for c in &candidates { @@ -131,14 +132,14 @@ pub fn read_memory_file(path: String, agent_id: Option) -> Result, agent_id: Option) -> Result<(), String> { +pub async fn write_memory_file(path: String, content: String, category: Option, agent_id: Option) -> Result<(), String> { if is_unsafe_path(&path) { return Err("非法路径".to_string()); } let aid = agent_id.as_deref().unwrap_or("main"); let cat = category.unwrap_or_else(|| "memory".to_string()); - let base = memory_dir_for_agent(aid, &cat)?; + let base = memory_dir_for_agent(aid, &cat).await?; let full_path = base.join(&path); if let Some(parent) = full_path.parent() { @@ -148,16 +149,16 @@ pub fn write_memory_file(path: String, content: String, category: Option } #[tauri::command] -pub fn delete_memory_file(path: String, agent_id: Option) -> Result<(), String> { +pub async fn delete_memory_file(path: String, agent_id: Option) -> Result<(), String> { if is_unsafe_path(&path) { return Err("非法路径".to_string()); } let aid = agent_id.as_deref().unwrap_or("main"); let candidates = [ - memory_dir_for_agent(aid, "memory"), - memory_dir_for_agent(aid, "archive"), - memory_dir_for_agent(aid, "core"), + memory_dir_for_agent(aid, "memory").await, + memory_dir_for_agent(aid, "archive").await, + memory_dir_for_agent(aid, "core").await, ]; for c in &candidates { @@ -174,9 +175,9 @@ pub fn delete_memory_file(path: String, agent_id: Option) -> Result<(), } #[tauri::command] -pub fn export_memory_zip(category: String, agent_id: Option) -> Result { +pub async fn export_memory_zip(category: String, agent_id: Option) -> Result { let aid = agent_id.as_deref().unwrap_or("main"); - let dir = memory_dir_for_agent(aid, &category)?; + let dir = memory_dir_for_agent(aid, &category).await?; if !dir.exists() { return Err("目录不存在".to_string()); } diff --git a/src-tauri/src/commands/service.rs b/src-tauri/src/commands/service.rs index 25b7dd3..c950da0 100644 --- a/src-tauri/src/commands/service.rs +++ b/src-tauri/src/commands/service.rs @@ -211,6 +211,8 @@ mod platform { #[cfg(target_os = "windows")] mod platform { + use tokio::process::Command as TokioCommand; + /// Windows 不需要 UID pub fn current_uid() -> Result { Ok(0) @@ -230,10 +232,27 @@ mod platform { vec!["ai.openclaw.gateway".to_string()] } + /// 从 openclaw.json 读取 gateway 端口,fallback 到 18789 + fn read_gateway_port() -> u16 { + let config_path = crate::commands::openclaw_dir().join("openclaw.json"); + if let Ok(content) = std::fs::read_to_string(&config_path) { + if let Ok(val) = serde_json::from_str::(&content) { + if let Some(port) = val.get("gateway").and_then(|g| g.get("port")).and_then(|p| p.as_u64()) { + if port > 0 && port < 65536 { + return port as u16; + } + } + } + } + 18789 + } + /// 通过端口探测检测 Gateway 状态 pub fn check_service_status(_uid: u32, _label: &str) -> (bool, Option) { + let port = read_gateway_port(); + let addr = format!("127.0.0.1:{port}"); match std::net::TcpStream::connect_timeout( - &"127.0.0.1:18789".parse().unwrap(), + &addr.parse().unwrap_or_else(|_| "127.0.0.1:18789".parse().unwrap()), std::time::Duration::from_millis(150), ) { Ok(_) => (true, None), @@ -242,23 +261,42 @@ mod platform { } /// 以前台模式 spawn Gateway(不需要管理员权限) - pub fn start_service_impl(_label: &str) -> Result<(), String> { + pub async fn start_service_impl(_label: &str) -> Result<(), String> { if !is_cli_installed() { return Err("openclaw CLI 未安装,请先通过 npm install -g @qingchencloud/openclaw-zh 安装".into()); } if check_service_status(0, "").0 { return Ok(()); } - crate::utils::openclaw_command() + + let log_dir = dirs::home_dir() + .unwrap_or_default() + .join(".openclaw") + .join("logs"); + std::fs::create_dir_all(&log_dir).ok(); + + let stdout_log = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(log_dir.join("gateway.log")) + .map_err(|e| format!("创建日志文件失败: {e}"))?; + + let stderr_log = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(log_dir.join("gateway.err.log")) + .map_err(|e| format!("创建错误日志文件失败: {e}"))?; + + crate::utils::openclaw_command_async() .arg("gateway") .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) + .stdout(stdout_log) + .stderr(stderr_log) .spawn() .map_err(|e| format!("启动 Gateway 失败: {e}"))?; for _ in 0..25 { - std::thread::sleep(std::time::Duration::from_millis(200)); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; if check_service_status(0, "").0 { return Ok(()); } @@ -266,28 +304,29 @@ mod platform { Err("Gateway 启动超时,请检查日志".into()) } - pub fn stop_service_impl(_label: &str) -> Result<(), String> { - let _ = crate::utils::openclaw_command() + pub async fn stop_service_impl(_label: &str) -> Result<(), String> { + let _ = crate::utils::openclaw_command_async() .args(["gateway", "stop"]) - .output(); + .output() + .await; if check_service_status(0, "").0 { - use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x08000000; - let _ = std::process::Command::new("cmd") + let _ = TokioCommand::new("cmd") .args(["/c", "taskkill", "/f", "/im", "node.exe", "/fi", "WINDOWTITLE eq openclaw*"]) .creation_flags(CREATE_NO_WINDOW) - .output(); + .output() + .await; } Ok(()) } - pub fn restart_service_impl(_label: &str) -> Result<(), String> { - let _ = stop_service_impl(_label); + pub async fn restart_service_impl(_label: &str) -> Result<(), String> { + let _ = stop_service_impl(_label).await; for _ in 0..10 { if !check_service_status(0, "").0 { break; } - std::thread::sleep(std::time::Duration::from_millis(300)); + tokio::time::sleep(std::time::Duration::from_millis(300)).await; } - start_service_impl(_label) + start_service_impl(_label).await } } @@ -295,10 +334,10 @@ mod platform { #[cfg(target_os = "linux")] mod platform { - use std::process::Command; + use tokio::process::Command; pub fn current_uid() -> Result { - let output = Command::new("id") + let output = std::process::Command::new("id") .arg("-u") .output() .map_err(|e| format!("获取 UID 失败: {e}"))?; @@ -306,10 +345,11 @@ mod platform { uid_str.parse::().map_err(|e| format!("解析 UID 失败: {e}")) } - pub fn is_cli_installed() -> bool { + pub async fn is_cli_installed() -> bool { Command::new("openclaw") .arg("--version") .output() + .await .map(|o| o.status.success()) .unwrap_or(false) } @@ -318,14 +358,14 @@ mod platform { vec!["ai.openclaw.gateway".to_string()] } - pub fn check_service_status(_uid: u32, _label: &str) -> (bool, Option) { + pub async fn check_service_status(_uid: u32, _label: &str) -> (bool, Option) { match std::net::TcpStream::connect_timeout( &"127.0.0.1:18789".parse().unwrap(), std::time::Duration::from_secs(2), ) { Ok(_) => (true, None), Err(_) => { - if let Ok(output) = Command::new("openclaw").arg("health").output() { + if let Ok(output) = Command::new("openclaw").arg("health").output().await { let text = String::from_utf8_lossy(&output.stdout); if output.status.success() && !text.contains("not running") { return (true, None); @@ -336,13 +376,14 @@ mod platform { } } - fn gateway_command(action: &str) -> Result<(), String> { - if !is_cli_installed() { + async fn gateway_command(action: &str) -> Result<(), String> { + if !is_cli_installed().await { return Err("openclaw CLI 未安装,请先通过 npm install -g @qingchencloud/openclaw-zh 安装".into()); } - let output = crate::utils::openclaw_command() + let output = crate::utils::openclaw_command_async() .args(["gateway", action]) .output() + .await .map_err(|e| format!("执行 openclaw gateway {action} 失败: {e}"))?; if !output.status.success() { @@ -352,31 +393,40 @@ mod platform { Ok(()) } - pub fn start_service_impl(_label: &str) -> Result<(), String> { - gateway_command("start") + pub async fn start_service_impl(_label: &str) -> Result<(), String> { + gateway_command("start").await } - pub fn stop_service_impl(_label: &str) -> Result<(), String> { - gateway_command("stop") + pub async fn stop_service_impl(_label: &str) -> Result<(), String> { + gateway_command("stop").await } - pub fn restart_service_impl(_label: &str) -> Result<(), String> { - gateway_command("restart") + pub async fn restart_service_impl(_label: &str) -> Result<(), String> { + gateway_command("restart").await } } // ===== 跨平台公共接口 ===== #[tauri::command] -pub fn get_services_status() -> Result, String> { +pub async fn get_services_status() -> Result, String> { let uid = platform::current_uid()?; let labels = platform::scan_service_labels(); let desc_map = description_map(); + + #[cfg(target_os = "linux")] + let cli_installed = platform::is_cli_installed().await; + #[cfg(not(target_os = "linux"))] let cli_installed = platform::is_cli_installed(); + let mut results = Vec::new(); for label in &labels { + #[cfg(target_os = "linux")] + let (running, pid) = platform::check_service_status(uid, label).await; + #[cfg(not(target_os = "linux"))] let (running, pid) = platform::check_service_status(uid, label); + results.push(ServiceStatus { label: label.clone(), pid, @@ -393,16 +443,16 @@ pub fn get_services_status() -> Result, String> { } #[tauri::command] -pub fn start_service(label: String) -> Result<(), String> { - platform::start_service_impl(&label) +pub async fn start_service(label: String) -> Result<(), String> { + platform::start_service_impl(&label).await } #[tauri::command] -pub fn stop_service(label: String) -> Result<(), String> { - platform::stop_service_impl(&label) +pub async fn stop_service(label: String) -> Result<(), String> { + platform::stop_service_impl(&label).await } #[tauri::command] -pub fn restart_service(label: String) -> Result<(), String> { - platform::restart_service_impl(&label) +pub async fn restart_service(label: String) -> Result<(), String> { + platform::restart_service_impl(&label).await } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b2557a7..8cbb402 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -27,6 +27,7 @@ pub fn run() { config::restore_backup, config::delete_backup, config::reload_gateway, + config::restart_gateway, config::test_model, config::list_remote_models, config::upgrade_openclaw, @@ -59,11 +60,13 @@ pub fn run() { extensions::get_cftunnel_logs, extensions::get_clawapp_status, extensions::install_cftunnel, + extensions::install_clawapp, // Agent 管理 agent::list_agents, agent::add_agent, agent::delete_agent, agent::update_agent_identity, + agent::update_agent_model, agent::backup_agent, ]) .run(tauri::generate_context!()) diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index 510f457..5dc6127 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -2,7 +2,7 @@ use std::process::Command; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; -/// 跨平台获取 openclaw 命令的方法 +/// 跨平台获取 openclaw 命令的方法(同步版本) /// 在 Windows 上使用 `cmd /c openclaw` 以兼容全局 npm 路径下的 `.cmd` 脚本 pub fn openclaw_command() -> Command { #[cfg(target_os = "windows")] @@ -18,3 +18,19 @@ pub fn openclaw_command() -> Command { Command::new("openclaw") } } + +/// 异步版本的 openclaw 命令(推荐使用,避免阻塞 UI) +pub fn openclaw_command_async() -> tokio::process::Command { + #[cfg(target_os = "windows")] + { + const CREATE_NO_WINDOW: u32 = 0x08000000; + let mut cmd = tokio::process::Command::new("cmd"); + cmd.arg("/c").arg("openclaw"); + cmd.creation_flags(CREATE_NO_WINDOW); + cmd + } + #[cfg(not(target_os = "windows"))] + { + tokio::process::Command::new("openclaw") + } +} diff --git a/src/components/toast.js b/src/components/toast.js index 88dc024..1667a4b 100644 --- a/src/components/toast.js +++ b/src/components/toast.js @@ -12,11 +12,23 @@ function ensureContainer() { return _container } -export function toast(message, type = 'info', duration = 3000) { +export function toast(message, type = 'info', options = {}) { + const duration = options.duration || 3000 + const action = options.action // 可选的操作按钮(DOM 元素) + const container = ensureContainer() const el = document.createElement('div') el.className = `toast ${type}` - el.textContent = message + + const textSpan = document.createElement('span') + textSpan.textContent = message + el.appendChild(textSpan) + + // 如果有操作按钮,添加到 toast 中 + if (action instanceof HTMLElement) { + el.appendChild(action) + } + container.appendChild(el) setTimeout(() => { diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index d41a999..c386144 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -49,10 +49,7 @@ function cachedInvoke(cmd, args = {}, ttl = CACHE_TTL) { logRequest(cmd, args, 0, true) return Promise.resolve(cached.val) } - const start = Date.now() return invoke(cmd, args).then(val => { - const duration = Date.now() - start - logRequest(cmd, args, duration, false) _cache.set(key, { val, ts: Date.now() }) return val }) @@ -65,6 +62,9 @@ function invalidate(...cmds) { } } +// 导出 invalidate 供外部使用 +export { invalidate } + async function invoke(cmd, args = {}) { const start = Date.now() if (_invokeReady) { @@ -164,6 +164,10 @@ function mockInvoke(cmd, args) { stop_service: () => true, restart_service: () => true, reload_gateway: () => 'Gateway 已重载', + restart_gateway: () => 'Gateway 已重启', + list_agents: () => [ + { id: 'main', isDefault: true, identityName: null, model: null, workspace: null }, + ], upgrade_openclaw: () => '升级成功,当前版本: 2026.2.26-zh.3 (mock)', install_gateway: () => 'Gateway 服务已安装 (mock)', uninstall_gateway: () => 'Gateway 服务已卸载 (mock)', @@ -211,6 +215,7 @@ export const api = { readMcpConfig: () => cachedInvoke('read_mcp_config'), writeMcpConfig: (config) => { invalidate('read_mcp_config'); return invoke('write_mcp_config', { config }) }, reloadGateway: () => invoke('reload_gateway'), + restartGateway: () => invoke('restart_gateway'), upgradeOpenclaw: (source = 'chinese') => invoke('upgrade_openclaw', { source }), installGateway: () => invoke('install_gateway'), uninstallGateway: () => invoke('uninstall_gateway'), @@ -224,6 +229,7 @@ export const api = { addAgent: (name, model, workspace) => { invalidate('list_agents'); return invoke('add_agent', { name, model, workspace: workspace || null }) }, deleteAgent: (id) => { invalidate('list_agents'); return invoke('delete_agent', { id }) }, updateAgentIdentity: (id, name, emoji) => { invalidate('list_agents'); return invoke('update_agent_identity', { id, name, emoji }) }, + updateAgentModel: (id, model) => { invalidate('list_agents'); return invoke('update_agent_model', { id, model }) }, backupAgent: (id) => invoke('backup_agent', { id }), // 日志(短缓存) @@ -255,6 +261,7 @@ export const api = { getCftunnelLogs: (lines = 20) => cachedInvoke('get_cftunnel_logs', { lines }, 5000), getClawappStatus: () => cachedInvoke('get_clawapp_status', {}, 5000), installCftunnel: () => invoke('install_cftunnel'), + installClawapp: () => invoke('install_clawapp'), // 设备密钥 + Gateway 握手 createConnectFrame: (nonce, gatewayToken) => invoke('create_connect_frame', { nonce, gatewayToken }), diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js index 7e6aceb..c3f7a61 100644 --- a/src/lib/ws-client.js +++ b/src/lib/ws-client.js @@ -326,8 +326,9 @@ export class WsClient { if (!this._ws || this._ws.readyState !== WebSocket.OPEN || !this._gatewayReady) { if (!this._intentionalClose && (this._reconnectAttempts > 0 || !this._gatewayReady)) { const waitTimeout = setTimeout(() => { unsub(); reject(new Error('等待重连超时')) }, 15000) - const unsub = this.onReady(() => { + const unsub = this.onReady((hello, sessionKey, err) => { clearTimeout(waitTimeout); unsub() + if (err?.error) { reject(new Error(err.message || 'Gateway 握手失败')); return } this.request(method, params).then(resolve, reject) }) return @@ -341,8 +342,14 @@ export class WsClient { }) } - chatSend(sessionKey, message) { - return this.request('chat.send', { sessionKey, message, deliver: false, idempotencyKey: uuid() }) + chatSend(sessionKey, message, attachments) { + const params = { sessionKey, message, deliver: false, idempotencyKey: uuid() } + if (attachments && attachments.length > 0) { + params.attachments = attachments + console.log('[ws] 发送附件:', attachments.length, '个') + console.log('[ws] 附件详情:', attachments.map(a => ({ type: a.type, mime: a.mimeType, name: a.fileName, size: a.content?.length }))) + } + return this.request('chat.send', params) } chatHistory(sessionKey, limit = 200) { diff --git a/src/main.js b/src/main.js index 8de8956..25a4ec1 100644 --- a/src/main.js +++ b/src/main.js @@ -83,7 +83,7 @@ async function autoConnectWebSocket() { return } - wsClient.connect(`ws://127.0.0.1:${port}/ws`, token) + wsClient.connect(`127.0.0.1:${port}`, token) console.log('[main] WebSocket 连接已启动') } catch (e) { console.error('[main] 自动连接 WebSocket 失败:', e) diff --git a/src/pages/about.js b/src/pages/about.js index 399e142..2e17262 100644 --- a/src/pages/about.js +++ b/src/pages/about.js @@ -145,7 +145,7 @@ const PROJECTS = [ { name: 'OpenClaw', desc: 'AI Agent 框架,支持多模型协作、工具调用、记忆管理', - url: 'https://github.com/openclaw-labs/openclaw', + url: 'https://github.com/openclaw/openclaw', }, { name: 'ClawApp', diff --git a/src/pages/agents.js b/src/pages/agents.js index 6850157..d946e15 100644 --- a/src/pages/agents.js +++ b/src/pages/agents.js @@ -2,7 +2,7 @@ * Agent 管理页面 * Agent 增删改查 + 身份编辑 */ -import { api } from '../lib/tauri-api.js' +import { api, invalidate } from '../lib/tauri-api.js' import { toast } from '../components/toast.js' import { showModal, showConfirm } from '../components/modal.js' @@ -21,7 +21,7 @@ export async function render() {
-
加载中...
+
` @@ -39,6 +39,12 @@ async function loadAgents(page, state) { try { state.agents = await api.listAgents() renderAgents(page, state) + + // 只在第一次加载时绑定事件(避免重复绑定) + if (!state.eventsAttached) { + attachAgentEvents(page, state) + state.eventsAttached = true + } } catch (e) { container.innerHTML = '
加载失败: ' + e + '
' toast('加载 Agent 列表失败: ' + e, 'error') @@ -85,8 +91,10 @@ function renderAgents(page, state) { ` }).join('') +} - // 事件委托 +function attachAgentEvents(page, state) { + const container = page.querySelector('#agents-list') container.addEventListener('click', async (e) => { const btn = e.target.closest('[data-action]') if (!btn) return @@ -143,6 +151,9 @@ async function showAddAgentDialog(page, state) { await api.updateAgentIdentity(id, name || null, emoji || null) } toast('Agent 已创建', 'success') + + // 强制清除缓存并重新加载 + invalidate('list_agents') await loadAgents(page, state) } catch (e) { toast('创建失败: ' + e, 'error') @@ -157,7 +168,7 @@ async function showEditAgentDialog(page, state, id) { const name = agent.identityName ? agent.identityName.split(',')[0].trim() : '' - // 获取模型列表用于下拉选择 + // 获取模型列表 let models = [] try { const config = await api.readOpenclawConfig() @@ -168,23 +179,29 @@ async function showEditAgentDialog(page, state, id) { if (mid) models.push({ value: `${pk}/${mid}`, label: `${pk}/${mid}` }) } } - } catch { /* 忽略 */ } + console.log('[Agent编辑] 获取到模型列表:', models.length, '个') + } catch (e) { + console.error('[Agent编辑] 获取模型列表失败:', e) + } const fields = [ { name: 'name', label: '名称', value: name, placeholder: '例如:翻译助手' }, - { name: 'emoji', label: 'Emoji', value: '', placeholder: '例如:🌐' }, + { name: 'emoji', label: 'Emoji', value: agent.identityEmoji || '', placeholder: '例如:🌐' }, ] - // 有模型列表时提供下拉选择 if (models.length) { - fields.push({ + const modelField = { name: 'model', label: '模型', type: 'select', value: agent.model || models[0]?.value || '', options: models, - }) + } + fields.push(modelField) + console.log('[Agent编辑] 当前模型:', agent.model) + console.log('[Agent编辑] 模型选项:', models) + } else { + console.warn('[Agent编辑] 模型列表为空,不显示模型选择器') } - // 工作区只读展示 fields.push({ name: 'workspace', label: '工作区', value: agent.workspace || '未设置', @@ -196,16 +213,30 @@ async function showEditAgentDialog(page, state, id) { title: `编辑 Agent — ${id}`, fields, onConfirm: async (result) => { + console.log('[Agent编辑] 保存数据:', result) const newName = (result.name || '').trim() const emoji = (result.emoji || '').trim() + const model = (result.model || '').trim() try { if (newName || emoji) { + console.log('[Agent编辑] 更新身份信息...') await api.updateAgentIdentity(id, newName || null, emoji || null) } + if (model && model !== agent.model) { + console.log('[Agent编辑] 更新模型:', agent.model, '->', model) + await api.updateAgentModel(id, model) + } + + // 手动更新 state 并重新渲染,确保立即生效 + if (newName) agent.identityName = newName + if (emoji) agent.identityEmoji = emoji + if (model) agent.model = model + renderAgents(page, state) + toast('已更新', 'success') - await loadAgents(page, state) } catch (e) { + console.error('[Agent编辑] 保存失败:', e) toast('更新失败: ' + e, 'error') } } diff --git a/src/pages/chat-debug.js b/src/pages/chat-debug.js index 14687a8..1bde5ed 100644 --- a/src/pages/chat-debug.js +++ b/src/pages/chat-debug.js @@ -51,7 +51,6 @@ export async function render() { async function loadDebugInfo(page) { const el = page.querySelector('#debug-content') - el.innerHTML = '
检测中...
' const info = { timestamp: new Date().toLocaleString('zh-CN'), diff --git a/src/pages/chat.js b/src/pages/chat.js index c01a988..c1f7ba5 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -39,12 +39,14 @@ const COMMANDS = [ let _sessionKey = null, _page = null, _messagesEl = null, _textarea = null let _sendBtn = null, _statusDot = null, _typingEl = null, _scrollBtn = null -let _sessionListEl = null, _cmdPanelEl = null +let _sessionListEl = null, _cmdPanelEl = null, _attachPreviewEl = null, _fileInputEl = null let _currentAiBubble = null, _currentAiText = '', _currentRunId = null let _isStreaming = false, _isSending = false, _messageQueue = [] let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = '' let _streamSafetyTimer = null, _unsubEvent = null, _unsubReady = null, _unsubStatus = null let _pageActive = false +let _errorTimer = null, _lastErrorMsg = null +let _attachments = [] export async function render() { const page = document.createElement('div') @@ -87,7 +89,12 @@ export async function render() { +
+ +
@@ -107,6 +114,8 @@ export async function render() { _scrollBtn = page.querySelector('#chat-scroll-btn') _sessionListEl = page.querySelector('#chat-session-list') _cmdPanelEl = page.querySelector('#chat-cmd-panel') + _attachPreviewEl = page.querySelector('#chat-attachments-preview') + _fileInputEl = page.querySelector('#chat-file-input') bindEvents(page) // 非阻塞:先返回 DOM,后台连接 Gateway @@ -140,9 +149,13 @@ function bindEvents(page) { page.querySelector('#chat-sidebar').classList.toggle('open') }) page.querySelector('#btn-new-session').addEventListener('click', () => showNewSessionDialog()) -page.querySelector('#btn-cmd').addEventListener('click', () => toggleCmdPanel()) + page.querySelector('#btn-cmd').addEventListener('click', () => toggleCmdPanel()) page.querySelector('#btn-reset-session').addEventListener('click', () => resetCurrentSession()) + // 文件上传 + page.querySelector('#chat-attach-btn').addEventListener('click', () => _fileInputEl.click()) + _fileInputEl.addEventListener('change', handleFileSelect) + _messagesEl.addEventListener('scroll', () => { const { scrollTop, scrollHeight, clientHeight } = _messagesEl _scrollBtn.style.display = (scrollHeight - scrollTop - clientHeight < 80) ? 'none' : 'flex' @@ -151,6 +164,73 @@ page.querySelector('#btn-cmd').addEventListener('click', () => toggleCmdPanel()) _messagesEl.addEventListener('click', () => hideCmdPanel()) } +// ── 文件上传 ── + +async function handleFileSelect(e) { + const files = Array.from(e.target.files || []) + if (!files.length) return + + for (const file of files) { + if (!file.type.startsWith('image/')) { + toast('仅支持图片文件', 'warning') + continue + } + if (file.size > 5 * 1024 * 1024) { + toast(`${file.name} 超过 5MB 限制`, 'warning') + continue + } + + try { + const base64 = await fileToBase64(file) + _attachments.push({ + type: 'image', + mimeType: file.type, + fileName: file.name, + content: base64, + }) + renderAttachments() + } catch (e) { + toast(`读取 ${file.name} 失败`, 'error') + } + } + _fileInputEl.value = '' +} + +function fileToBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const dataUrl = reader.result + const base64 = dataUrl.split(',')[1] + resolve(base64) + } + reader.onerror = reject + reader.readAsDataURL(file) + }) +} + +function renderAttachments() { + if (!_attachments.length) { + _attachPreviewEl.style.display = 'none' + return + } + _attachPreviewEl.style.display = 'flex' + _attachPreviewEl.innerHTML = _attachments.map((att, idx) => ` +
+ ${att.fileName} + +
+ `).join('') + + _attachPreviewEl.querySelectorAll('.chat-attachment-del').forEach(btn => { + btn.addEventListener('click', () => { + const idx = parseInt(btn.dataset.idx) + _attachments.splice(idx, 1) + renderAttachments() + }) + }) +} + // ── Gateway 连接 ── async function connectGateway() { @@ -175,14 +255,16 @@ async function connectGateway() { _unsubReady = wsClient.onReady((hello, sessionKey, err) => { if (!_pageActive) return if (err?.error) { toast(err.message || '连接失败', 'error'); return } + showTyping(false) // Gateway 就绪后关闭加载动画 // 重连后恢复:保留当前 sessionKey,不重复加载历史 if (!_sessionKey) { const saved = localStorage.getItem(STORAGE_SESSION_KEY) _sessionKey = saved || sessionKey updateSessionTitle() loadHistory() - refreshSessionList() } + // 始终刷新会话列表(无论是否有 sessionKey) + refreshSessionList() }) _unsubEvent = wsClient.onEvent((msg) => { @@ -195,6 +277,7 @@ async function connectGateway() { const saved = localStorage.getItem(STORAGE_SESSION_KEY) _sessionKey = saved || wsClient.sessionKey updateStatusDot('ready') + showTyping(false) // 确保关闭加载动画 updateSessionTitle() loadHistory() refreshSessionList() @@ -278,23 +361,18 @@ function switchSession(newKey) { async function showNewSessionDialog() { const defaultAgent = wsClient.snapshot?.sessionDefaults?.defaultAgentId || 'main' - // 获取 agent 列表 - let agents = [] - try { - agents = await api.listAgents() - } catch { agents = [{ id: 'main', identityName: '默认', isDefault: true }] } - const agentOptions = agents.map(a => ({ - value: a.id, - label: `${a.id}${a.isDefault ? ' (默认)' : ''}${a.identityName ? ' — ' + a.identityName.split(',')[0] : ''}` - })) - agentOptions.push({ value: '__new__', label: '+ 新建 Agent' }) + // 先用默认选项立即显示弹窗 + const initialOptions = [ + { value: 'main', label: 'main (默认)' }, + { value: '__new__', label: '+ 新建 Agent' } + ] showModal({ title: '新建会话', fields: [ { name: 'name', label: '会话名称', value: '', placeholder: '例如:翻译助手' }, - { name: 'agent', label: '智能体', type: 'select', value: defaultAgent, options: agentOptions }, + { name: 'agent', label: '智能体', type: 'select', value: defaultAgent, options: initialOptions }, ], onConfirm: (result) => { const name = (result.name || '').trim() @@ -309,6 +387,27 @@ async function showNewSessionDialog() { toast('会话已创建', 'success') } }) + + // 异步加载完整 Agent 列表并更新下拉框 + try { + const agents = await api.listAgents() + const agentOptions = agents.map(a => ({ + value: a.id, + label: `${a.id}${a.isDefault ? ' (默认)' : ''}${a.identityName ? ' — ' + a.identityName.split(',')[0] : ''}` + })) + agentOptions.push({ value: '__new__', label: '+ 新建 Agent' }) + + // 更新弹窗中的下拉框选项 + const selectEl = document.querySelector('.modal-overlay [data-name="agent"]') + if (selectEl) { + const currentValue = selectEl.value + selectEl.innerHTML = agentOptions.map(o => + `` + ).join('') + } + } catch (e) { + console.warn('[chat] 加载 Agent 列表失败:', e) + } } async function deleteSession(key) { @@ -389,22 +488,25 @@ function toggleCmdPanel() { function sendMessage() { const text = _textarea.value.trim() - if (!text) return + if (!text && !_attachments.length) return hideCmdPanel() _textarea.value = '' _textarea.style.height = 'auto' updateSendState() - if (_isSending || _isStreaming) { _messageQueue.push(text); return } - doSend(text) + const attachments = [..._attachments] + _attachments = [] + renderAttachments() + if (_isSending || _isStreaming) { _messageQueue.push({ text, attachments }); return } + doSend(text, attachments) } -async function doSend(text) { - appendUserMessage(text) +async function doSend(text, attachments = []) { + appendUserMessage(text, attachments) saveMessage({ id: uuid(), sessionKey: _sessionKey, role: 'user', content: text, timestamp: Date.now() }) showTyping(true) _isSending = true try { - await wsClient.chatSend(_sessionKey, text) + await wsClient.chatSend(_sessionKey, text, attachments.length ? attachments : undefined) } catch (err) { showTyping(false) appendSystemMessage('发送失败: ' + err.message) @@ -416,7 +518,9 @@ async function doSend(text) { function processMessageQueue() { if (_messageQueue.length === 0 || _isSending || _isStreaming) return - doSend(_messageQueue.shift()) + const msg = _messageQueue.shift() + if (typeof msg === 'string') doSend(msg, []) + else doSend(msg.text, msg.attachments || []) } function stopGeneration() { @@ -442,7 +546,12 @@ function handleChatEvent(payload) { const c = extractChatContent(payload.message) if (c?.text && c.text.length > _currentAiText.length) { showTyping(false) - if (!_currentAiBubble) { _currentAiBubble = createStreamBubble(); _currentRunId = payload.runId } + if (!_currentAiBubble) { + _currentAiBubble = createStreamBubble() + _currentRunId = payload.runId + _isStreaming = true + updateSendState() + } _currentAiText = c.text throttledRender() } @@ -484,10 +593,22 @@ function handleChatEvent(payload) { if (state === 'error') { const errMsg = payload.errorMessage || payload.error?.message || '未知错误' - if (_isStreaming) { - console.warn('[chat] 流式中临时错误,等待重试:', errMsg) + + // 防抖:如果是相同错误且在 2 秒内,忽略(避免重复显示) + const now = Date.now() + if (_lastErrorMsg === errMsg && _errorTimer && (now - _errorTimer < 2000)) { + console.warn('[chat] 忽略重复错误:', errMsg) return } + _lastErrorMsg = errMsg + _errorTimer = now + + // 如果正在流式输出,说明消息已经部分成功,不显示错误 + if (_isStreaming || _currentAiBubble) { + console.warn('[chat] 流式中收到错误,但消息已部分成功,忽略错误提示:', errMsg) + return + } + showTyping(false) appendSystemMessage('错误: ' + errMsg) resetStreamState() @@ -561,6 +682,8 @@ function resetStreamState() { _currentAiText = '' _currentRunId = null _isStreaming = false + _lastErrorMsg = null + _errorTimer = null showTyping(false) updateSendState() } @@ -637,12 +760,30 @@ function extractContent(msg) { // ── DOM 操作 ── -function appendUserMessage(text) { +function appendUserMessage(text, attachments = []) { const wrap = document.createElement('div') wrap.className = 'msg msg-user' const bubble = document.createElement('div') bubble.className = 'msg-bubble' - bubble.textContent = text + + if (attachments.length > 0) { + const imgContainer = document.createElement('div') + imgContainer.style.cssText = 'display:flex;gap:4px;margin-bottom:8px;flex-wrap:wrap' + attachments.forEach(att => { + const img = document.createElement('img') + img.src = `data:${att.mimeType};base64,${att.content}` + img.style.cssText = 'max-width:200px;max-height:200px;border-radius:4px' + imgContainer.appendChild(img) + }) + bubble.appendChild(imgContainer) + } + + if (text) { + const textNode = document.createElement('div') + textNode.textContent = text + bubble.appendChild(textNode) + } + wrap.appendChild(bubble) _messagesEl.insertBefore(wrap, _typingEl) scrollToBottom() diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js index 297bede..3c60e5b 100644 --- a/src/pages/dashboard.js +++ b/src/pages/dashboard.js @@ -29,7 +29,7 @@ export async function render() {
最近日志
-
加载中...
+
` diff --git a/src/pages/extensions.js b/src/pages/extensions.js index 60e9177..c82b7c3 100644 --- a/src/pages/extensions.js +++ b/src/pages/extensions.js @@ -27,12 +27,12 @@ export async function render() {
cftunnel 内网穿透
通过 Cloudflare Tunnel 将本地服务暴露到公网,无需公网 IP 和端口映射。
-
加载中...
+
ClawApp 移动客户端
基于 LobeChat 的 AI 对话客户端,通过 Gateway 连接模型服务。支持本地和外网访问。
-
加载中...
+
` @@ -52,7 +52,6 @@ async function loadAll(page) { async function loadCftunnel(page) { const el = page.querySelector('#cftunnel-content') - el.innerHTML = '
加载中...
' try { const status = await api.getCftunnelStatus() renderCftunnel(el, status) @@ -146,7 +145,6 @@ function renderRoutes(routes) { async function loadClawapp(page) { const el = page.querySelector('#clawapp-content') - el.innerHTML = '
加载中...
' try { const status = await api.getClawappStatus() renderClawapp(el, status) @@ -156,6 +154,18 @@ async function loadClawapp(page) { } function renderClawapp(el, s) { + if (!s.installed) { + el.innerHTML = ` +
ClawApp 未安装
+
+ + 查看文档 +
+
+ ` + return + } + const running = s.running el.innerHTML = `
@@ -208,6 +218,9 @@ function bindEvents(page) { case 'install-cftunnel': await handleInstallCftunnel(page) break + case 'install-clawapp': + await handleInstallClawapp(page) + break } }) } @@ -302,3 +315,56 @@ async function handleInstallCftunnel(page) { unlistenProgress?.() } } + +async function handleInstallClawapp(page) { + const area = page.querySelector('#install-clawapp-progress-area') + if (!area) return + + area.innerHTML = ` +
+
+
+
+
+
准备安装...
+
+
+
+ ` + + const progressFill = area.querySelector('#install-clawapp-progress-fill') + const progressText = area.querySelector('#install-clawapp-progress-text') + const logBox = area.querySelector('#install-clawapp-log-box') + + let unlistenLog, unlistenProgress + try { + const { listen } = await import('@tauri-apps/api/event') + + unlistenLog = await listen('install-log', (e) => { + logBox.textContent += e.payload + '\n' + logBox.scrollTop = logBox.scrollHeight + }) + + unlistenProgress = await listen('install-progress', (e) => { + const progress = e.payload + progressFill.style.width = progress + '%' + progressText.textContent = `安装中... ${progress}%` + }) + + await api.installClawapp() + + progressFill.classList.add('done') + progressText.textContent = '✅ 安装完成' + toast('ClawApp 安装成功', 'success') + + setTimeout(() => loadClawapp(page), 3000) + } catch (e) { + progressFill.classList.add('error') + progressText.textContent = '❌ 安装失败' + logBox.textContent += '\n错误: ' + e + toast('安装失败: ' + e, 'error') + } finally { + unlistenLog?.() + unlistenProgress?.() + } +} diff --git a/src/pages/gateway.js b/src/pages/gateway.js index 7c1992d..2032590 100644 --- a/src/pages/gateway.js +++ b/src/pages/gateway.js @@ -13,7 +13,7 @@ export async function render() {

Gateway 配置

Gateway 是 AI 模型的统一入口,所有应用通过它来调用模型服务

-
加载中...
+
-
-