diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 438da83..427e911 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -103,6 +103,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "1.3.2" @@ -318,11 +324,15 @@ dependencies = [ name = "clawpanel" version = "0.1.0" dependencies = [ + "base64 0.22.1", "chrono", "dirs", + "ed25519-dalek", + "rand 0.8.5", "reqwest 0.12.28", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-shell", @@ -339,6 +349,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "convert_case" version = "0.4.0" @@ -475,6 +491,33 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling" version = "0.21.3" @@ -510,6 +553,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -661,6 +714,31 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "embed-resource" version = "3.0.6" @@ -726,6 +804,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -2390,6 +2474,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -3265,6 +3359,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -3353,6 +3456,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 372e833..54f3863 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -19,3 +19,7 @@ dirs = "6" chrono = "0.4" zip = { version = "2", default-features = false, features = ["deflate"] } reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +ed25519-dalek = { version = "2", features = ["rand_core"] } +sha2 = "0.10" +rand = "0.8" +base64 = "0.22" diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs new file mode 100644 index 0000000..0083cd3 --- /dev/null +++ b/src-tauri/src/commands/agent.rs @@ -0,0 +1,180 @@ +/// Agent 管理命令 — 调用 openclaw CLI 实现增删改查 +use serde_json::Value; +use std::process::Command; +use std::fs; +use std::io::Write; + +/// 获取 agent 列表 +#[tauri::command] +pub fn list_agents() -> Result { + let output = Command::new("openclaw") + .args(["agents", "list", "--json"]) + .output() + .map_err(|e| format!("执行失败: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("获取 Agent 列表失败: {stderr}")); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + serde_json::from_str(&stdout) + .map_err(|e| format!("解析 JSON 失败: {e}")) +} + +/// 创建新 agent +#[tauri::command] +pub 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() + .join("agents") + .join(&name) + .join("workspace"), + }; + + let mut args = vec![ + "agents".to_string(), + "add".to_string(), + name.clone(), + "--non-interactive".to_string(), + "--workspace".to_string(), + ws.to_string_lossy().to_string(), + "--json".to_string(), + ]; + + if !model.is_empty() { + args.push("--model".to_string()); + args.push(model); + } + + let output = Command::new("openclaw") + .args(&args) + .output() + .map_err(|e| format!("执行失败: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("创建 Agent 失败: {stderr}")); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + serde_json::from_str(&stdout).unwrap_or(Value::String("ok".into())); + // 返回最新列表 + list_agents() +} + +/// 删除 agent +#[tauri::command] +pub fn delete_agent(id: String) -> Result { + if id == "main" { + return Err("不能删除默认 Agent".into()); + } + + let output = Command::new("openclaw") + .args(["agents", "delete", &id]) + .output() + .map_err(|e| format!("执行失败: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("删除 Agent 失败: {stderr}")); + } + + Ok("已删除".into()) +} + +/// 更新 agent 身份信息 +#[tauri::command] +pub fn update_agent_identity( + id: String, + name: Option, + emoji: Option, +) -> Result { + let mut args = vec![ + "agents".to_string(), + "set-identity".to_string(), + "--agent".to_string(), + id, + "--json".to_string(), + ]; + + if let Some(n) = name { + if !n.is_empty() { + args.push("--name".to_string()); + args.push(n); + } + } + if let Some(e) = emoji { + if !e.is_empty() { + args.push("--emoji".to_string()); + args.push(e); + } + } + + let output = Command::new("openclaw") + .args(&args) + .output() + .map_err(|e| format!("执行失败: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("更新失败: {stderr}")); + } + + Ok("已更新".into()) +} + +/// 备份 agent 数据(agent 配置 + 会话记录)打包为 zip +#[tauri::command] +pub fn backup_agent(id: String) -> Result { + let agent_dir = super::openclaw_dir().join("agents").join(&id); + if !agent_dir.exists() { + return Err(format!("Agent「{id}」数据目录不存在")); + } + + let tmp_dir = std::env::temp_dir(); + let now = chrono::Local::now(); + let zip_name = format!("agent-{}-{}.zip", id, now.format("%Y%m%d-%H%M%S")); + let zip_path = tmp_dir.join(&zip_name); + + let file = fs::File::create(&zip_path) + .map_err(|e| format!("创建 zip 失败: {e}"))?; + let mut zip = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + + collect_dir_to_zip(&agent_dir, &agent_dir, &mut zip, options)?; + + zip.finish().map_err(|e| format!("完成 zip 失败: {e}"))?; + Ok(zip_path.to_string_lossy().to_string()) +} + +fn collect_dir_to_zip( + base: &std::path::Path, + dir: &std::path::Path, + zip: &mut zip::ZipWriter, + options: zip::write::SimpleFileOptions, +) -> Result<(), String> { + let entries = fs::read_dir(dir) + .map_err(|e| format!("读取目录失败: {e}"))?; + + for entry in entries.flatten() { + let path = entry.path(); + let rel = path.strip_prefix(base) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + + if path.is_dir() { + collect_dir_to_zip(base, &path, zip, options)?; + } else { + let content = fs::read(&path) + .map_err(|e| format!("读取 {rel} 失败: {e}"))?; + zip.start_file(&rel, options) + .map_err(|e| format!("写入 zip 失败: {e}"))?; + zip.write_all(&content) + .map_err(|e| format!("写入内容失败: {e}"))?; + } + } + Ok(()) +} diff --git a/src-tauri/src/commands/device.rs b/src-tauri/src/commands/device.rs new file mode 100644 index 0000000..9d1fc17 --- /dev/null +++ b/src-tauri/src/commands/device.rs @@ -0,0 +1,133 @@ +/// 设备密钥管理 + Gateway connect 握手签名 +use ed25519_dalek::{SigningKey, Signer, VerifyingKey}; +use sha2::{Sha256, Digest}; +use serde_json::Value; +use std::fs; + +const DEVICE_KEY_FILE: &str = "clawpanel-device-key.json"; +const SCOPES: &[&str] = &[ + "operator.admin", "operator.approvals", "operator.pairing", + "operator.read", "operator.write", +]; + +/// 获取或生成设备密钥 +fn get_or_create_key() -> Result<(String, String, SigningKey), String> { + let dir = super::openclaw_dir(); + let path = dir.join(DEVICE_KEY_FILE); + + if path.exists() { + let content = fs::read_to_string(&path) + .map_err(|e| format!("读取设备密钥失败: {e}"))?; + let json: Value = serde_json::from_str(&content) + .map_err(|e| format!("解析设备密钥失败: {e}"))?; + + let device_id = json["deviceId"].as_str().unwrap_or("").to_string(); + let pub_b64 = json["publicKey"].as_str().unwrap_or("").to_string(); + let secret_hex = json["secretKey"].as_str().unwrap_or(""); + + let secret_bytes = hex::decode(secret_hex) + .map_err(|e| format!("解码密钥失败: {e}"))?; + if secret_bytes.len() != 32 { + return Err("密钥长度错误".into()); + } + let mut key_bytes = [0u8; 32]; + key_bytes.copy_from_slice(&secret_bytes); + let signing_key = SigningKey::from_bytes(&key_bytes); + + return Ok((device_id, pub_b64, signing_key)); + } + + // 生成新密钥 + let mut rng = rand::thread_rng(); + let signing_key = SigningKey::generate(&mut rng); + let verifying_key: VerifyingKey = (&signing_key).into(); + let pub_bytes = verifying_key.to_bytes(); + + let device_id = { + let mut hasher = Sha256::new(); + hasher.update(&pub_bytes); + hex::encode(hasher.finalize()) + }; + let pub_b64 = base64_url_encode(&pub_bytes); + let secret_hex = hex::encode(signing_key.to_bytes()); + + let json = serde_json::json!({ + "deviceId": device_id, + "publicKey": pub_b64, + "secretKey": secret_hex, + }); + + let _ = fs::create_dir_all(&dir); + fs::write(&path, serde_json::to_string_pretty(&json).unwrap()) + .map_err(|e| format!("保存设备密钥失败: {e}"))?; + + Ok((device_id, pub_b64, signing_key)) +} + +/// base64url 编码(无 padding) +fn base64_url_encode(data: &[u8]) -> String { + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data) +} + +/// hex 编码(ed25519_dalek 不自带 hex) +mod hex { + pub fn encode(data: impl AsRef<[u8]>) -> String { + data.as_ref().iter().map(|b| format!("{b:02x}")).collect() + } + pub fn decode(s: &str) -> Result, String> { + if s.len() % 2 != 0 { return Err("奇数长度".into()) } + (0..s.len()).step_by(2) + .map(|i| u8::from_str_radix(&s[i..i+2], 16).map_err(|e| e.to_string())) + .collect() + } +} + +/// 生成 Gateway connect 帧(含 Ed25519 签名) +#[tauri::command] +pub fn create_connect_frame(nonce: String, gateway_token: String) -> Result { + let (device_id, pub_b64, signing_key) = get_or_create_key()?; + let signed_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis(); + + let scopes_str = SCOPES.join(","); + let payload_str = format!( + "v2|{device_id}|gateway-client|backend|operator|{scopes_str}|{signed_at}|{gateway_token}|{nonce}" + ); + + let signature = signing_key.sign(payload_str.as_bytes()); + let sig_b64 = base64_url_encode(&signature.to_bytes()); + + let frame = serde_json::json!({ + "type": "req", + "id": format!("connect-{:08x}-{:04x}", signed_at as u32, rand::random::()), + "method": "connect", + "params": { + "minProtocol": 3, + "maxProtocol": 3, + "client": { + "id": "gateway-client", + "version": "1.0.0", + "platform": "desktop", + "mode": "backend" + }, + "role": "operator", + "scopes": SCOPES, + "caps": [], + "auth": { "token": gateway_token }, + "device": { + "id": device_id, + "publicKey": pub_b64, + "signedAt": signed_at as u64, + "nonce": nonce, + "signature": sig_b64, + }, + "locale": "zh-CN", + "userAgent": "ClawPanel/1.0.0", + } + }); + + Ok(frame) +} diff --git a/src-tauri/src/commands/memory.rs b/src-tauri/src/commands/memory.rs index 83bd204..517d7ee 100644 --- a/src-tauri/src/commands/memory.rs +++ b/src-tauri/src/commands/memory.rs @@ -3,19 +3,58 @@ use std::fs; use std::io::Write; use std::path::PathBuf; -fn memory_dir(category: &str) -> std::path::PathBuf { - let base = super::openclaw_dir(); - match category { - "memory" => base.join("workspace").join("memory"), - "archive" => base.join("workspace-memory"), - "core" => base.join("workspace"), - _ => base.join("workspace").join("memory"), +/// 根据 agent_id 获取 workspace 路径 +/// 调用 openclaw agents list --json 解析 +fn agent_workspace(agent_id: &str) -> Result { + let output = std::process::Command::new("openclaw") + .args(["agents", "list", "--json"]) + .output() + .map_err(|e| format!("执行 openclaw 失败: {e}"))?; + + if !output.status.success() { + return Err("获取 Agent 列表失败".into()); } + + let stdout = String::from_utf8_lossy(&output.stdout); + let agents: serde_json::Value = serde_json::from_str(&stdout) + .map_err(|e| format!("解析 JSON 失败: {e}"))?; + + if let Some(arr) = agents.as_array() { + for a in arr { + if a.get("id").and_then(|v| v.as_str()) == Some(agent_id) { + if let Some(ws) = a.get("workspace").and_then(|v| v.as_str()) { + return Ok(PathBuf::from(ws)); + } + } + } + } + + Err(format!("Agent「{agent_id}」不存在或无 workspace")) +} + +fn memory_dir_for_agent(agent_id: &str, category: &str) -> Result { + let ws = agent_workspace(agent_id)?; + Ok(match category { + "memory" => ws.join("memory"), + "archive" => { + // 归档目录在 agent workspace 同级的 workspace-memory + // 对 main: ~/.openclaw/workspace-memory + // 对其他: ~/.openclaw/agents/{id}/workspace-memory + if let Some(parent) = ws.parent() { + parent.join("workspace-memory") + } else { + ws.join("memory-archive") + } + } + "core" => ws.clone(), + _ => ws.join("memory"), + }) } #[tauri::command] -pub fn list_memory_files(category: String) -> Result, String> { - let dir = memory_dir(&category); +pub 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)?; if !dir.exists() { return Ok(vec![]); } @@ -56,23 +95,25 @@ fn collect_files( } #[tauri::command] -pub fn read_memory_file(path: String) -> Result { - // 安全检查:路径不能包含 ..、绝对路径、空字节 +pub fn read_memory_file(path: String, agent_id: Option) -> Result { if path.contains("..") || path.starts_with('/') || path.contains('\0') { return Err("非法路径".to_string()); } - // 尝试在各个记忆目录下查找 + let aid = agent_id.as_deref().unwrap_or("main"); let candidates = [ - memory_dir("memory").join(&path), - memory_dir("archive").join(&path), - memory_dir("core").join(&path), + memory_dir_for_agent(aid, "memory"), + memory_dir_for_agent(aid, "archive"), + memory_dir_for_agent(aid, "core"), ]; - for candidate in &candidates { - if candidate.exists() { - return fs::read_to_string(candidate) - .map_err(|e| format!("读取失败: {e}")); + for c in &candidates { + if let Ok(dir) = c { + let full = dir.join(&path); + if full.exists() { + return fs::read_to_string(&full) + .map_err(|e| format!("读取失败: {e}")); + } } } @@ -80,22 +121,16 @@ pub fn read_memory_file(path: String) -> Result { } #[tauri::command] -pub fn write_memory_file(path: String, content: String, category: Option) -> Result<(), String> { - // 安全检查:路径不能包含 ..、绝对路径、空字节 +pub fn write_memory_file(path: String, content: String, category: Option, agent_id: Option) -> Result<(), String> { if path.contains("..") || path.starts_with('/') || path.contains('\0') { return Err("非法路径".to_string()); } + let aid = agent_id.as_deref().unwrap_or("main"); 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}")), - }; + let base = memory_dir_for_agent(aid, &cat)?; let full_path = base.join(&path); - // 确保父目录存在 if let Some(parent) = full_path.parent() { fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {e}"))?; } @@ -103,22 +138,25 @@ pub fn write_memory_file(path: String, content: String, category: Option } #[tauri::command] -pub fn delete_memory_file(path: String) -> Result<(), String> { - // 安全检查:路径不能包含 ..、绝对路径、空字节 +pub fn delete_memory_file(path: String, agent_id: Option) -> Result<(), String> { if path.contains("..") || path.starts_with('/') || path.contains('\0') { return Err("非法路径".to_string()); } + let aid = agent_id.as_deref().unwrap_or("main"); let candidates = [ - memory_dir("memory").join(&path), - memory_dir("archive").join(&path), - memory_dir("core").join(&path), + memory_dir_for_agent(aid, "memory"), + memory_dir_for_agent(aid, "archive"), + memory_dir_for_agent(aid, "core"), ]; - for candidate in &candidates { - if candidate.exists() { - return fs::remove_file(candidate) - .map_err(|e| format!("删除失败: {e}")); + for c in &candidates { + if let Ok(dir) = c { + let full = dir.join(&path); + if full.exists() { + return fs::remove_file(&full) + .map_err(|e| format!("删除失败: {e}")); + } } } @@ -126,8 +164,9 @@ pub fn delete_memory_file(path: String) -> Result<(), String> { } #[tauri::command] -pub fn export_memory_zip(category: String) -> Result { - let dir = memory_dir(&category); +pub 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)?; if !dir.exists() { return Err("目录不存在".to_string()); } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 72cc6a3..6c97da5 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,8 @@ use std::path::PathBuf; +pub mod agent; pub mod config; +pub mod device; pub mod extensions; pub mod logs; pub mod memory; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 11652d7..3cc4178 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,7 @@ mod commands; mod models; -use commands::{config, extensions, logs, memory, service}; +use commands::{agent, config, device, extensions, logs, memory, service}; pub fn run() { tauri::Builder::default() @@ -27,6 +27,8 @@ pub fn run() { config::uninstall_gateway, config::get_npm_registry, config::set_npm_registry, + // 设备密钥 + Gateway 握手 + device::create_connect_frame, // 服务 service::get_services_status, service::start_service, @@ -47,6 +49,12 @@ pub fn run() { extensions::get_cftunnel_logs, extensions::get_clawapp_status, extensions::install_cftunnel, + // Agent 管理 + agent::list_agents, + agent::add_agent, + agent::delete_agent, + agent::update_agent_identity, + agent::backup_agent, ]) .run(tauri::generate_context!()) .expect("启动 ClawPanel 失败"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8e889c8..30cc04d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -23,7 +23,7 @@ ], "withGlobalTauri": true, "security": { - "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:" + "csp": null } }, "bundle": { diff --git a/src/components/modal.js b/src/components/modal.js index d74a200..9539771 100644 --- a/src/components/modal.js +++ b/src/components/modal.js @@ -81,7 +81,7 @@ export function showModal({ title, fields, onConfirm }) { return `
- + ${f.hint ? `
${f.hint}
` : ''}
` }).join('') @@ -115,8 +115,10 @@ export function showModal({ title, fields, onConfirm }) { result[el.dataset.name] = el.value } }) - overlay.remove() - onConfirm(result) + // 先调用回调,再移除 overlay,避免嵌套对话框时序问题 + const callback = onConfirm + setTimeout(() => overlay.remove(), 0) + callback(result) } // 键盘事件:Enter 确认,Escape 关闭 diff --git a/src/components/sidebar.js b/src/components/sidebar.js index d6405e5..55cbb27 100644 --- a/src/components/sidebar.js +++ b/src/components/sidebar.js @@ -9,6 +9,7 @@ const NAV_ITEMS = [ section: '概览', items: [ { route: '/dashboard', label: '仪表盘', icon: 'dashboard' }, + { route: '/chat', label: '实时聊天', icon: 'chat' }, { route: '/services', label: '服务管理', icon: 'services' }, { route: '/logs', label: '日志查看', icon: 'logs' }, ] @@ -17,6 +18,7 @@ const NAV_ITEMS = [ section: '配置', items: [ { route: '/models', label: '模型配置', icon: 'models' }, + { route: '/agents', label: 'Agent 管理', icon: 'agents' }, { route: '/gateway', label: 'Gateway', icon: 'gateway' }, ] }, @@ -42,9 +44,11 @@ const NAV_ITEMS = [ const ICONS = { dashboard: '', + chat: '', services: '', logs: '', models: '', + agents: '', gateway: '', memory: '', extensions: '', diff --git a/src/lib/markdown.js b/src/lib/markdown.js new file mode 100644 index 0000000..00504c1 --- /dev/null +++ b/src/lib/markdown.js @@ -0,0 +1,136 @@ +/** + * Markdown 渲染器 - 轻量级,支持代码高亮 + * 从 clawapp 移植,去掉 MEDIA 路径处理 + */ + +const KEYWORDS = new Set([ + 'const','let','var','function','return','if','else','for','while','do', + 'switch','case','break','continue','new','this','class','extends','import', + 'export','from','default','try','catch','finally','throw','async','await', + 'yield','of','in','typeof','instanceof','void','delete','true','false', + 'null','undefined','static','get','set','super','with','debugger', + 'def','print','self','elif','lambda','pass','raise','except','None','True','False', + 'fn','pub','mut','impl','struct','enum','match','use','mod','crate','trait', + 'int','string','bool','float','double','char','byte','long','short','unsigned', + 'package','main','fmt','go','chan','defer','select','type','interface','map','range', +]) + +function highlightCode(code, lang) { + const escaped = escapeHtml(code) + return escaped + .replace(/\b(\d+\.?\d*)\b/g, '$1') + .replace(/(\/\/.*$|#.*$)/gm, '$1') + .replace(/(\/\*[\s\S]*?\*\/)/g, '$1') + .replace(/("(?:[^&]|&(?!quot;))*?"|'(?:[^&]|&(?!#x27;))*?'|`[^`]*`)/g, + '$1') + .replace(/\b([A-Z][a-zA-Z0-9_]*)\b/g, (m, w) => + KEYWORDS.has(w) ? m : `${w}`) + .replace(/\b(\w+)(?=\s*\()/g, (m, w) => + KEYWORDS.has(w) ? m : `${w}`) + .replace(/\b(\w+)\b/g, (m, w) => + KEYWORDS.has(w) ? `${w}` : m) +} + +function escapeHtml(str) { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +export function renderMarkdown(text) { + if (!text) return '' + let html = text + + // 代码块 + html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => { + const highlighted = highlightCode(code.trimEnd(), lang) + const langLabel = lang ? `${escapeHtml(lang)}` : '' + return `
${langLabel}${highlighted}
` + }) + + // 行内代码 + html = html.replace(/`([^`\n]+)`/g, (_, code) => `${escapeHtml(code)}`) + + const lines = html.split('\n') + const result = [] + let inList = false + let listType = '' + + for (let i = 0; i < lines.length; i++) { + let line = lines[i] + + // 跳过 pre 块内容 + if (line.startsWith('')) { i++; result.push(lines[i]) } + continue + } + + // 标题 + const headingMatch = line.match(/^(#{1,3})\s+(.+)$/) + if (headingMatch) { + if (inList) { result.push(``); inList = false } + const level = headingMatch[1].length + result.push(`${inlineFormat(headingMatch[2])}`) + continue + } + + // 无序列表 + const ulMatch = line.match(/^[\s]*[-*]\s+(.+)$/) + if (ulMatch) { + if (!inList || listType !== 'ul') { + if (inList) result.push(``) + result.push('
    '); inList = true; listType = 'ul' + } + result.push(`
  • ${inlineFormat(ulMatch[1])}
  • `) + continue + } + + // 有序列表 + const olMatch = line.match(/^[\s]*\d+\.\s+(.+)$/) + if (olMatch) { + if (!inList || listType !== 'ol') { + if (inList) result.push(``) + result.push('
      '); inList = true; listType = 'ol' + } + result.push(`
    1. ${inlineFormat(olMatch[1])}
    2. `) + continue + } + + if (inList) { result.push(``); inList = false } + if (line.trim() === '') { result.push(''); continue } + if (!line.startsWith('<')) { result.push(`

      ${inlineFormat(line)}

      `) } + else { result.push(line) } + } + + if (inList) result.push(``) + return result.join('\n') +} + +function inlineFormat(text) { + return text + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/__(.+?)__/g, '$1') + .replace(/_(.+?)_/g, '$1') + .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1') + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => { + const safe = /^https?:|^mailto:/i.test(url.trim()) ? url : '#' + return `${label}` + }) +} + +window.__copyCode = function(btn) { + const pre = btn.closest('pre') + const code = pre.querySelector('code') + navigator.clipboard.writeText(code.innerText).then(() => { + btn.textContent = '✓' + setTimeout(() => { btn.textContent = 'Copy' }, 1500) + }).catch(() => { + btn.textContent = '✗' + setTimeout(() => { btn.textContent = 'Copy' }, 1500) + }) +} diff --git a/src/lib/message-db.js b/src/lib/message-db.js new file mode 100644 index 0000000..6a2ad90 --- /dev/null +++ b/src/lib/message-db.js @@ -0,0 +1,112 @@ +/** + * 本地消息存储 - IndexedDB + * 从 clawapp 移植,适配 ClawPanel + */ + +const DB_NAME = 'clawpanel-messages' +const DB_VERSION = 1 +const STORE_NAME = 'messages' +const STORE_SESSIONS = 'sessions' + +let _db = null + +function openDB() { + return new Promise((resolve, reject) => { + if (_db) return resolve(_db) + const request = indexedDB.open(DB_NAME, DB_VERSION) + request.onerror = () => reject(request.error) + request.onsuccess = () => { _db = request.result; resolve(_db) } + request.onupgradeneeded = (event) => { + const db = event.target.result + if (!db.objectStoreNames.contains(STORE_NAME)) { + const msgStore = db.createObjectStore(STORE_NAME, { keyPath: 'id' }) + msgStore.createIndex('sessionKey', 'sessionKey', { unique: false }) + msgStore.createIndex('timestamp', 'timestamp', { unique: false }) + msgStore.createIndex('sessionKey_timestamp', ['sessionKey', 'timestamp'], { unique: false }) + } + if (!db.objectStoreNames.contains(STORE_SESSIONS)) { + db.createObjectStore(STORE_SESSIONS, { keyPath: 'sessionKey' }) + } + } + }) +} + +export async function saveMessage(message) { + if (!message || !message.id) return + try { + const db = await openDB() + const tx = db.transaction(STORE_NAME, 'readwrite') + tx.objectStore(STORE_NAME).put({ + id: message.id, + sessionKey: message.sessionKey || '', + role: message.role || 'assistant', + content: message.content || message.text || '', + timestamp: message.timestamp || Date.now(), + sync: true + }) + } catch (e) { + console.error('[db] saveMessage error:', e) + } +} + +export async function saveMessages(messages) { + if (!messages?.length) return + try { + const db = await openDB() + const tx = db.transaction(STORE_NAME, 'readwrite') + const store = tx.objectStore(STORE_NAME) + messages.forEach(msg => { + if (!msg.id) return + store.put({ + id: msg.id, + sessionKey: msg.sessionKey || '', + role: msg.role || 'assistant', + content: msg.content || msg.text || '', + timestamp: msg.timestamp || Date.now(), + sync: true + }) + }) + } catch (e) { + console.error('[db] saveMessages error:', e) + } +} + +export async function getLocalMessages(sessionKey, limit = 200) { + try { + const db = await openDB() + return new Promise((resolve) => { + const tx = db.transaction(STORE_NAME, 'readonly') + const index = tx.objectStore(STORE_NAME).index('sessionKey_timestamp') + const range = IDBKeyRange.bound([sessionKey, 0], [sessionKey, Date.now() + 1]) + const messages = [] + const request = index.openCursor(range, 'prev') + request.onsuccess = (event) => { + const cursor = event.target.result + if (cursor && messages.length < limit) { messages.push(cursor.value); cursor.continue() } + } + tx.oncomplete = () => resolve(messages.reverse()) + tx.onerror = () => resolve([]) + }) + } catch (e) { + console.error('[db] getLocalMessages error:', e) + return [] + } +} + +export async function clearSessionMessages(sessionKey) { + try { + const db = await openDB() + const tx = db.transaction(STORE_NAME, 'readwrite') + const request = tx.objectStore(STORE_NAME).index('sessionKey').openCursor(sessionKey) + request.onsuccess = (event) => { + const cursor = event.target.result + if (cursor) { cursor.delete(); cursor.continue() } + } + } catch (e) { + console.error('[db] clearSessionMessages error:', e) + } +} + +export function isStorageAvailable() { + try { return 'indexedDB' in window && !!indexedDB } catch { return false } +} diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 4879048..71c4168 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -30,7 +30,7 @@ function mockInvoke(cmd, args) { mode: 'replace', providers: { 'newapi-claude': { - baseUrl: 'http://192.168.1.14:30080/v1', + baseUrl: 'http://localhost:30080/v1', api: 'openai-completions', models: [ { id: 'claude-opus-4-6' }, @@ -115,9 +115,9 @@ function mockInvoke(cmd, args) { installed: true, version: 'cftunnel 0.7.0', running: true, tunnel_name: 'mac-home', pid: 73325, routes: [ - { name: 'clawapp', domain: 'chat.qrj.ai', service: 'http://localhost:3210' }, - { name: 'newapi', domain: 'newapi.qrj.ai', service: 'http://localhost:30080' }, - { name: 'webhook', domain: 'webhook.qrj.ai', service: 'http://localhost:9801' }, + { name: 'webapp', domain: 'app.example.com', service: 'http://localhost:3210' }, + { name: 'api', domain: 'api.example.com', service: 'http://localhost:30080' }, + { name: 'webhook', domain: 'hook.example.com', service: 'http://localhost:9801' }, ], }), cftunnel_action: () => true, @@ -151,16 +151,23 @@ export const api = { testModel: (baseUrl, apiKey, modelId) => invoke('test_model', { baseUrl, apiKey, modelId }), listRemoteModels: (baseUrl, apiKey) => invoke('list_remote_models', { baseUrl, apiKey }), + // Agent 管理 + listAgents: () => invoke('list_agents'), + addAgent: (name, model, workspace) => invoke('add_agent', { name, model, workspace: workspace || null }), + deleteAgent: (id) => invoke('delete_agent', { id }), + updateAgentIdentity: (id, name, emoji) => invoke('update_agent_identity', { id, name, emoji }), + backupAgent: (id) => invoke('backup_agent', { id }), + // 日志 readLogTail: (logName, lines = 100) => invoke('read_log_tail', { logName, lines }), searchLog: (logName, query, maxResults = 50) => invoke('search_log', { logName, query, maxResults }), // 记忆文件 - listMemoryFiles: (category) => invoke('list_memory_files', { category }), - readMemoryFile: (path) => invoke('read_memory_file', { path }), - writeMemoryFile: (path, content, category) => invoke('write_memory_file', { path, content, category: category || 'memory' }), - deleteMemoryFile: (path) => invoke('delete_memory_file', { path }), - exportMemoryZip: (category) => invoke('export_memory_zip', { category }), + listMemoryFiles: (category, agentId) => invoke('list_memory_files', { category, agent_id: agentId || null }), + readMemoryFile: (path, agentId) => invoke('read_memory_file', { path, agent_id: agentId || null }), + writeMemoryFile: (path, content, category, agentId) => invoke('write_memory_file', { path, content, category: category || 'memory', agent_id: agentId || null }), + deleteMemoryFile: (path, agentId) => invoke('delete_memory_file', { path, agent_id: agentId || null }), + exportMemoryZip: (category, agentId) => invoke('export_memory_zip', { category, agent_id: agentId || null }), // 安装/部署 checkInstallation: () => invoke('check_installation'), @@ -179,4 +186,7 @@ export const api = { getCftunnelLogs: (lines = 20) => invoke('get_cftunnel_logs', { lines }), getClawappStatus: () => invoke('get_clawapp_status'), installCftunnel: () => invoke('install_cftunnel'), + + // 设备密钥 + Gateway 握手 + createConnectFrame: (nonce, gatewayToken) => invoke('create_connect_frame', { nonce, gatewayToken }), } diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js new file mode 100644 index 0000000..a2ca198 --- /dev/null +++ b/src/lib/ws-client.js @@ -0,0 +1,348 @@ +/** + * WebSocket 客户端 - 直连 OpenClaw Gateway + * + * 协议流程(直连模式): + * 1. 连接 ws://host/ws?token=xxx + * 2. Gateway 发 connect.challenge(带 nonce) + * 3. 客户端调用 Tauri 后端生成 Ed25519 签名的 connect frame + * 4. Gateway 返回 connect 响应(带 snapshot) + * 5. 从 snapshot.sessionDefaults.mainSessionKey 获取 sessionKey + * 6. 开始正常通信 + */ +import { api } from './tauri-api.js' + +export function uuid() { + if (crypto.randomUUID) return crypto.randomUUID() + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = Math.random() * 16 | 0 + return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16) + }) +} + +const REQUEST_TIMEOUT = 30000 +const MAX_RECONNECT_DELAY = 30000 +const PING_INTERVAL = 25000 +const CHALLENGE_TIMEOUT = 5000 + +export class WsClient { + constructor() { + this._ws = null + this._url = '' + this._token = '' + this._pending = new Map() + this._eventListeners = [] + this._statusListeners = [] + this._readyCallbacks = [] + this._reconnectAttempts = 0 + this._reconnectTimer = null + this._connected = false + this._gatewayReady = false + this._handshaking = false + this._intentionalClose = false + this._snapshot = null + this._hello = null + this._sessionKey = null + this._pingTimer = null + this._challengeTimer = null + this._wsId = 0 + } + + get connected() { return this._connected } + get gatewayReady() { return this._gatewayReady } + get snapshot() { return this._snapshot } + get hello() { return this._hello } + get sessionKey() { return this._sessionKey } + + onStatusChange(fn) { + this._statusListeners.push(fn) + return () => { this._statusListeners = this._statusListeners.filter(cb => cb !== fn) } + } + + onReady(fn) { + this._readyCallbacks.push(fn) + return () => { this._readyCallbacks = this._readyCallbacks.filter(cb => cb !== fn) } + } + + connect(host, token) { + this._intentionalClose = false + this._token = token || '' + this._url = `ws://${host}/ws?token=${encodeURIComponent(this._token)}` + this._doConnect() + } + + disconnect() { + this._intentionalClose = true + this._stopPing() + this._clearReconnectTimer() + this._clearChallengeTimer() + this._flushPending() + this._closeWs() + this._setConnected(false) + this._gatewayReady = false + this._handshaking = false + } + + reconnect() { + if (!this._url) return + this._intentionalClose = false + this._reconnectAttempts = 0 + this._stopPing() + this._clearReconnectTimer() + this._clearChallengeTimer() + this._flushPending() + this._closeWs() + this._doConnect() + } + + _doConnect() { + this._closeWs() + this._gatewayReady = false + this._handshaking = false + this._setConnected(false, 'connecting') + const wsId = ++this._wsId + let ws + try { ws = new WebSocket(this._url) } catch { this._scheduleReconnect(); return } + this._ws = ws + + ws.onopen = () => { + if (wsId !== this._wsId) return + this._reconnectAttempts = 0 + this._setConnected(true) + this._startPing() + // 等 Gateway 发 connect.challenge,超时则主动发 + this._challengeTimer = setTimeout(() => { + if (!this._handshaking && !this._gatewayReady) { + console.log('[ws] 未收到 challenge,主动发 connect') + this._sendConnectFrame('') + } + }, CHALLENGE_TIMEOUT) + } + + ws.onmessage = (evt) => { + if (wsId !== this._wsId) return + let msg + try { msg = JSON.parse(evt.data) } catch { return } + this._handleMessage(msg) + } + + ws.onclose = (e) => { + if (wsId !== this._wsId) return + this._ws = null + this._clearChallengeTimer() + if (e.code === 4001) { + this._setConnected(false, 'auth_failed', 'Token 认证失败') + this._intentionalClose = true + this._flushPending() + return + } + this._setConnected(false) + this._gatewayReady = false + this._handshaking = false + this._stopPing() + this._flushPending() + if (!this._intentionalClose) this._scheduleReconnect() + } + + ws.onerror = () => {} + } + + _handleMessage(msg) { + // 握手阶段:connect.challenge + if (msg.type === 'event' && msg.event === 'connect.challenge') { + console.log('[ws] 收到 connect.challenge') + this._clearChallengeTimer() + const nonce = msg.payload?.nonce || '' + this._sendConnectFrame(nonce) + return + } + + // 握手响应:connect 的 res + if (msg.type === 'res' && msg.id?.startsWith('connect-')) { + this._clearChallengeTimer() + this._handshaking = false + if (!msg.ok || msg.error) { + const errMsg = msg.error?.message || 'Gateway 握手失败' + console.error('[ws] connect 失败:', errMsg) + this._setConnected(false, 'error', errMsg) + this._readyCallbacks.forEach(fn => { + try { fn(null, null, { error: true, message: errMsg }) } catch {} + }) + return + } + // 握手成功,提取 snapshot + this._handleConnectSuccess(msg.payload) + return + } + + // RPC 响应 + if (msg.type === 'res') { + const cb = this._pending.get(msg.id) + if (cb) { + this._pending.delete(msg.id) + clearTimeout(cb.timer) + if (msg.ok) cb.resolve(msg.payload) + else cb.reject(new Error(msg.error?.message || msg.error?.code || 'request failed')) + } + return + } + + // 事件转发 + if (msg.type === 'event') { + this._eventListeners.forEach(fn => { + try { fn(msg) } catch (e) { console.error('[ws] handler error:', e) } + }) + } + } + + async _sendConnectFrame(nonce) { + this._handshaking = true + try { + const frame = await api.createConnectFrame(nonce, this._token) + if (this._ws && this._ws.readyState === WebSocket.OPEN) { + console.log('[ws] 发送 connect frame') + this._ws.send(JSON.stringify(frame)) + } + } catch (e) { + console.error('[ws] 生成 connect frame 失败:', e) + this._handshaking = false + } + } + + _handleConnectSuccess(payload) { + this._hello = payload || null + this._snapshot = payload?.snapshot || null + const defaults = this._snapshot?.sessionDefaults + if (defaults?.mainSessionKey) { + this._sessionKey = defaults.mainSessionKey + } else { + const agentId = defaults?.defaultAgentId || 'main' + this._sessionKey = `agent:${agentId}:main` + } + this._gatewayReady = true + console.log('[ws] Gateway 就绪, sessionKey:', this._sessionKey) + this._setConnected(true, 'ready') + this._readyCallbacks.forEach(fn => { + try { fn(this._hello, this._sessionKey) } catch (e) { + console.error('[ws] ready cb error:', e) + } + }) + } + + _setConnected(val, status, errorMsg) { + this._connected = val + const s = status || (val ? 'connected' : 'disconnected') + this._statusListeners.forEach(fn => { + try { fn(s, errorMsg) } catch (e) { console.error('[ws] status listener error:', e) } + }) + } + + _closeWs() { + if (this._ws) { + const old = this._ws + this._ws = null + this._wsId++ + try { old.close() } catch {} + } + } + + _flushPending() { + for (const [, cb] of this._pending) { + clearTimeout(cb.timer) + cb.reject(new Error('连接已断开')) + } + this._pending.clear() + } + + _clearReconnectTimer() { + if (this._reconnectTimer) { + clearTimeout(this._reconnectTimer) + this._reconnectTimer = null + } + } + + _clearChallengeTimer() { + if (this._challengeTimer) { + clearTimeout(this._challengeTimer) + this._challengeTimer = null + } + } + + _scheduleReconnect() { + this._clearReconnectTimer() + const delay = this._reconnectAttempts < 3 + ? 1000 + : Math.min(1000 * Math.pow(2, this._reconnectAttempts - 2), MAX_RECONNECT_DELAY) + this._reconnectAttempts++ + this._setConnected(false, 'reconnecting') + this._reconnectTimer = setTimeout(() => this._doConnect(), delay) + } + + _startPing() { + this._stopPing() + this._pingTimer = setInterval(() => { + if (this._ws && this._ws.readyState === WebSocket.OPEN) { + try { this._ws.send('{"type":"ping"}') } catch {} + } + }, PING_INTERVAL) + } + + _stopPing() { + if (this._pingTimer) { + clearInterval(this._pingTimer) + this._pingTimer = null + } + } + + request(method, params = {}) { + return new Promise((resolve, reject) => { + 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(() => { + clearTimeout(waitTimeout); unsub() + this.request(method, params).then(resolve, reject) + }) + return + } + return reject(new Error('WebSocket 未连接')) + } + const id = uuid() + const timer = setTimeout(() => { this._pending.delete(id); reject(new Error('请求超时')) }, REQUEST_TIMEOUT) + this._pending.set(id, { resolve, reject, timer }) + this._ws.send(JSON.stringify({ type: 'req', id, method, params })) + }) + } + + chatSend(sessionKey, message) { + return this.request('chat.send', { sessionKey, message, deliver: false, idempotencyKey: uuid() }) + } + + chatHistory(sessionKey, limit = 200) { + return this.request('chat.history', { sessionKey, limit }) + } + + chatAbort(sessionKey, runId) { + const params = { sessionKey } + if (runId) params.runId = runId + return this.request('chat.abort', params) + } + + sessionsList(limit = 50) { + return this.request('sessions.list', { limit }) + } + + sessionsDelete(key) { + return this.request('sessions.delete', { key }) + } + + sessionsReset(key) { + return this.request('sessions.reset', { key }) + } + + onEvent(callback) { + this._eventListeners.push(callback) + return () => { this._eventListeners = this._eventListeners.filter(fn => fn !== callback) } + } +} + +export const wsClient = new WsClient() diff --git a/src/main.js b/src/main.js index b00805d..e588218 100644 --- a/src/main.js +++ b/src/main.js @@ -11,12 +11,16 @@ import './style/reset.css' import './style/layout.css' import './style/components.css' import './style/pages.css' +import './style/chat.css' +import './style/agents.css' // 注册页面路由(懒加载) registerRoute('/dashboard', () => import('./pages/dashboard.js')) +registerRoute('/chat', () => import('./pages/chat.js')) registerRoute('/services', () => import('./pages/services.js')) registerRoute('/logs', () => import('./pages/logs.js')) registerRoute('/models', () => import('./pages/models.js')) +registerRoute('/agents', () => import('./pages/agents.js')) registerRoute('/gateway', () => import('./pages/gateway.js')) registerRoute('/memory', () => import('./pages/memory.js')) registerRoute('/extensions', () => import('./pages/extensions.js')) diff --git a/src/pages/agents.js b/src/pages/agents.js new file mode 100644 index 0000000..b4a59d6 --- /dev/null +++ b/src/pages/agents.js @@ -0,0 +1,238 @@ +/** + * Agent 管理页面 + * Agent 增删改查 + 身份编辑 + */ +import { api } from '../lib/tauri-api.js' +import { toast } from '../components/toast.js' +import { showModal, showConfirm } from '../components/modal.js' + +export async function render() { + const page = document.createElement('div') + page.className = 'page' + + page.innerHTML = ` + +
      +
      +
      + ` + + const state = { agents: [] } + await loadAgents(page, state) + + page.querySelector('#btn-add-agent').addEventListener('click', () => showAddAgentDialog(page, state)) + + return page +} + +async function loadAgents(page, state) { + try { + state.agents = await api.listAgents() + renderAgents(page, state) + } catch (e) { + toast('加载 Agent 列表失败: ' + e, 'error') + } +} + +function renderAgents(page, state) { + const container = page.querySelector('#agents-list') + if (!state.agents.length) { + container.innerHTML = '
      暂无 Agent
      ' + return + } + + container.innerHTML = state.agents.map(a => { + const isDefault = a.isDefault || a.id === 'main' + const name = a.identityName ? a.identityName.split(',')[0].trim() : '无描述' + return ` +
      +
      +
      + ${a.id} + ${isDefault ? '默认' : ''} +
      +
      + + + ${!isDefault ? `` : ''} +
      +
      +
      +
      + 名称: + ${name} +
      +
      + 模型: + ${a.model || '未设置'} +
      +
      + 工作区: + ${a.workspace || '未设置'} +
      +
      +
      + ` + }).join('') + + // 事件委托 + container.addEventListener('click', async (e) => { + const btn = e.target.closest('[data-action]') + if (!btn) return + const action = btn.dataset.action + const id = btn.dataset.id + + if (action === 'edit') showEditAgentDialog(page, state, id) + else if (action === 'delete') await deleteAgent(page, state, id) + else if (action === 'backup') await backupAgent(id) + }) +} + +async function showAddAgentDialog(page, state) { + // 获取模型列表 + let models = [] + try { + const config = await api.readOpenclawConfig() + const providers = config?.models?.providers || {} + for (const [pk, pv] of Object.entries(providers)) { + for (const m of (pv.models || [])) { + const id = typeof m === 'string' ? m : m.id + if (id) models.push({ value: `${pk}/${id}`, label: `${pk}/${id}` }) + } + } + } catch { models = [{ value: 'newapi/claude-opus-4-6', label: 'newapi/claude-opus-4-6' }] } + + if (!models.length) { + toast('请先在模型配置页面添加模型', 'warning') + return + } + + showModal({ + title: '新建 Agent', + fields: [ + { name: 'id', label: 'Agent ID', value: '', placeholder: '例如:translator(小写字母、数字、下划线、连字符)' }, + { name: 'name', label: '名称', value: '', placeholder: '例如:翻译助手' }, + { name: 'emoji', label: 'Emoji', value: '', placeholder: '例如:🌐(可选)' }, + { name: 'model', label: '模型', type: 'select', value: models[0]?.value || '', options: models }, + { name: 'workspace', label: '工作区路径', value: '', placeholder: '留空则自动创建(可选,绝对路径)' }, + ], + onConfirm: async (result) => { + const id = (result.id || '').trim() + if (!id) { toast('请输入 Agent ID', 'warning'); return } + if (!/^[a-z0-9_-]+$/.test(id)) { toast('Agent ID 只能包含小写字母、数字、下划线和连字符', 'warning'); return } + + const name = (result.name || '').trim() + const emoji = (result.emoji || '').trim() + const model = result.model || models[0]?.value || '' + const workspace = (result.workspace || '').trim() + + try { + await api.addAgent(id, model, workspace || null) + if (name || emoji) { + await api.updateAgentIdentity(id, name || null, emoji || null) + } + toast('Agent 已创建', 'success') + await loadAgents(page, state) + } catch (e) { + toast('创建失败: ' + e, 'error') + } + } + }) +} + +async function showEditAgentDialog(page, state, id) { + const agent = state.agents.find(a => a.id === id) + if (!agent) return + + const name = agent.identityName ? agent.identityName.split(',')[0].trim() : '' + + // 获取模型列表用于下拉选择 + let models = [] + try { + const config = await api.readOpenclawConfig() + const providers = config?.models?.providers || {} + for (const [pk, pv] of Object.entries(providers)) { + for (const m of (pv.models || [])) { + const mid = typeof m === 'string' ? m : m.id + if (mid) models.push({ value: `${pk}/${mid}`, label: `${pk}/${mid}` }) + } + } + } catch { /* 忽略 */ } + + const fields = [ + { name: 'name', label: '名称', value: name, placeholder: '例如:翻译助手' }, + { name: 'emoji', label: 'Emoji', value: '', placeholder: '例如:🌐' }, + ] + + // 有模型列表时提供下拉选择 + if (models.length) { + fields.push({ + name: 'model', label: '模型', type: 'select', + value: agent.model || models[0]?.value || '', + options: models, + }) + } + + // 工作区只读展示 + fields.push({ + name: 'workspace', label: '工作区', + value: agent.workspace || '未设置', + placeholder: '创建时指定,不可修改', + readonly: true, + }) + + showModal({ + title: `编辑 Agent — ${id}`, + fields, + onConfirm: async (result) => { + const newName = (result.name || '').trim() + const emoji = (result.emoji || '').trim() + + try { + if (newName || emoji) { + await api.updateAgentIdentity(id, newName || null, emoji || null) + } + toast('已更新', 'success') + await loadAgents(page, state) + } catch (e) { + toast('更新失败: ' + e, 'error') + } + } + }) +} + +async function deleteAgent(page, state, id) { + const yes = await showConfirm(`确定删除 Agent「${id}」?\n\n此操作将删除该 Agent 的所有数据和会话。`) + if (!yes) return + + try { + await api.deleteAgent(id) + toast('已删除', 'success') + await loadAgents(page, state) + } catch (e) { + toast('删除失败: ' + e, 'error') + } +} + +async function backupAgent(id) { + toast(`正在备份 Agent「${id}」...`, 'info') + try { + const zipPath = await api.backupAgent(id) + try { + const { open } = await import('@tauri-apps/plugin-shell') + const dir = zipPath.substring(0, zipPath.lastIndexOf('/')) || zipPath + await open(dir) + } catch { /* fallback */ } + toast(`备份完成: ${zipPath.split('/').pop()}`, 'success') + } catch (e) { + toast('备份失败: ' + e, 'error') + } +} diff --git a/src/pages/chat.js b/src/pages/chat.js new file mode 100644 index 0000000..c20c4d6 --- /dev/null +++ b/src/pages/chat.js @@ -0,0 +1,730 @@ +/** + * 聊天页面 - 完整版,对接 OpenClaw Gateway + * 支持:流式响应、Markdown 渲染、会话管理、Agent 选择、快捷指令 + */ +import { api } from '../lib/tauri-api.js' +import { navigate } from '../router.js' +import { wsClient, uuid } from '../lib/ws-client.js' +import { renderMarkdown } from '../lib/markdown.js' +import { saveMessage, saveMessages, getLocalMessages, isStorageAvailable } from '../lib/message-db.js' +import { toast } from '../components/toast.js' +import { showModal, showConfirm } from '../components/modal.js' + +const RENDER_THROTTLE = 30 +const STORAGE_SESSION_KEY = 'clawpanel-last-session' + +const COMMANDS = [ + { title: '会话', commands: [ + { cmd: '/new', desc: '新建会话', action: 'exec' }, + { cmd: '/reset', desc: '重置当前会话', action: 'exec' }, + { cmd: '/stop', desc: '停止生成', action: 'exec' }, + ]}, + { title: '模型', commands: [ + { cmd: '/model ', desc: '切换模型(输入模型名)', action: 'fill' }, + { cmd: '/model list', desc: '查看可用模型', action: 'exec' }, + { cmd: '/model status', desc: '当前模型状态', action: 'exec' }, + ]}, + { title: '思考模式', commands: [ + { cmd: '/think off', desc: '关闭深度思考', action: 'exec' }, + { cmd: '/think low', desc: '轻度思考', action: 'exec' }, + { cmd: '/think medium', desc: '中度思考', action: 'exec' }, + { cmd: '/think high', desc: '深度思考', action: 'exec' }, + ]}, + { title: '信息', commands: [ + { cmd: '/help', desc: '帮助信息', action: 'exec' }, + { cmd: '/status', desc: '系统状态', action: 'exec' }, + { cmd: '/context', desc: '上下文信息', action: 'exec' }, + ]}, +] + +let _sessionKey = null, _page = null, _messagesEl = null, _textarea = null +let _sendBtn = null, _statusDot = null, _typingEl = null, _scrollBtn = null +let _sessionListEl = null, _cmdPanelEl = 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 + +export async function render() { + const page = document.createElement('div') + page.className = 'page chat-page' + _pageActive = true + _page = page + + page.innerHTML = ` +
      +
      + 会话列表 + +
      +
      +
      +
      +
      +
      + + + 聊天 +
      +
      + + +
      +
      +
      + +
      + + +
      +
      + +
      + +
      + +
      + ` + + _messagesEl = page.querySelector('#chat-messages') + _textarea = page.querySelector('#chat-input') + _sendBtn = page.querySelector('#chat-send-btn') + _statusDot = page.querySelector('#chat-status-dot') + _typingEl = page.querySelector('#typing-indicator') + _scrollBtn = page.querySelector('#chat-scroll-btn') + _sessionListEl = page.querySelector('#chat-session-list') + _cmdPanelEl = page.querySelector('#chat-cmd-panel') + + bindEvents(page) + await connectGateway() + return page +} + +// ── 事件绑定 ── + +function bindEvents(page) { + _textarea.addEventListener('input', () => { + _textarea.style.height = 'auto' + _textarea.style.height = Math.min(_textarea.scrollHeight, 150) + 'px' + updateSendState() + // 输入 / 时显示指令面板 + if (_textarea.value === '/') showCmdPanel() + else if (!_textarea.value.startsWith('/')) hideCmdPanel() + }) + + _textarea.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage() } + if (e.key === 'Escape') hideCmdPanel() + }) + + _sendBtn.addEventListener('click', () => { + if (_isStreaming) stopGeneration() + else sendMessage() + }) + + page.querySelector('#btn-toggle-sidebar').addEventListener('click', () => { + 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-reset-session').addEventListener('click', () => resetCurrentSession()) + + _messagesEl.addEventListener('scroll', () => { + const { scrollTop, scrollHeight, clientHeight } = _messagesEl + _scrollBtn.style.display = (scrollHeight - scrollTop - clientHeight < 80) ? 'none' : 'flex' + }) + _scrollBtn.addEventListener('click', () => scrollToBottom()) + _messagesEl.addEventListener('click', () => hideCmdPanel()) +} + +// ── Gateway 连接 ── + +async function connectGateway() { + try { + // 订阅状态变化(订阅式,返回 unsub) + _unsubStatus = wsClient.onStatusChange((status, errorMsg) => { + if (!_pageActive) return + updateStatusDot(status) + const bar = document.getElementById('chat-disconnect-bar') + if (!bar) return + if (status === 'reconnecting' || status === 'disconnected') { + bar.textContent = '连接已断开,正在重连...' + bar.style.display = 'flex' + } else if (status === 'error') { + bar.textContent = errorMsg || '连接错误' + bar.style.display = 'flex' + } else { + bar.style.display = 'none' + } + }) + + _unsubReady = wsClient.onReady((hello, sessionKey, err) => { + if (!_pageActive) return + if (err?.error) { toast(err.message || '连接失败', 'error'); return } + // 重连后恢复:保留当前 sessionKey,不重复加载历史 + if (!_sessionKey) { + const saved = localStorage.getItem(STORAGE_SESSION_KEY) + _sessionKey = saved || sessionKey + updateSessionTitle() + loadHistory() + refreshSessionList() + } + }) + + _unsubEvent = wsClient.onEvent((msg) => { + if (!_pageActive) return + handleEvent(msg) + }) + + // 如果已连接且 Gateway 就绪,直接复用 + if (wsClient.connected && wsClient.gatewayReady) { + const saved = localStorage.getItem(STORAGE_SESSION_KEY) + _sessionKey = saved || wsClient.sessionKey + updateStatusDot('ready') + updateSessionTitle() + loadHistory() + refreshSessionList() + return + } + + // 如果正在连接中(重连等),等待 onReady 回调即可 + if (wsClient.connected) return + + // 未连接,发起新连接 + const config = await api.readOpenclawConfig() + const gw = config?.gateway || {} + const host = `127.0.0.1:${gw.port || 18789}` + const token = gw.auth?.token || gw.authToken || '' + wsClient.connect(host, token) + } catch (e) { + toast('读取配置失败: ' + e.message, 'error') + } +} + +// ── 会话管理 ── + +async function refreshSessionList() { + if (!_sessionListEl || !wsClient.gatewayReady) return + try { + const result = await wsClient.sessionsList(50) + const sessions = result?.sessions || result || [] + renderSessionList(sessions) + } catch (e) { + console.error('[chat] refreshSessionList error:', e) + } +} + +function renderSessionList(sessions) { + if (!_sessionListEl) return + if (!sessions.length) { + _sessionListEl.innerHTML = '
      暂无会话
      ' + return + } + sessions.sort((a, b) => (b.updatedAt || b.lastActivity || 0) - (a.updatedAt || a.lastActivity || 0)) + _sessionListEl.innerHTML = sessions.map(s => { + const key = s.sessionKey || s.key || '' + const active = key === _sessionKey ? ' active' : '' + const label = parseSessionLabel(key) + return `
      + ${label} + +
      ` + }).join('') + + _sessionListEl.onclick = (e) => { + const delBtn = e.target.closest('[data-del]') + if (delBtn) { e.stopPropagation(); deleteSession(delBtn.dataset.del); return } + const item = e.target.closest('[data-key]') + if (item) switchSession(item.dataset.key) + } +} + +function parseSessionLabel(key) { + const parts = (key || '').split(':') + if (parts.length < 3) return key || '未知' + const agent = parts[1] || 'main' + const channel = parts.slice(2).join(':') + if (agent === 'main' && channel === 'main') return '主会话' + if (agent === 'main') return channel + return `${agent} / ${channel}` +} + +function switchSession(newKey) { + if (newKey === _sessionKey) return + _sessionKey = newKey + localStorage.setItem(STORAGE_SESSION_KEY, newKey) + _lastHistoryHash = '' + resetStreamState() + updateSessionTitle() + clearMessages() + loadHistory() + refreshSessionList() + _page?.querySelector('#chat-sidebar')?.classList.remove('open') +} + +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' }) + + showModal({ + title: '新建会话', + fields: [ + { name: 'name', label: '会话名称', value: '', placeholder: '例如:翻译助手' }, + { name: 'agent', label: '智能体', type: 'select', value: defaultAgent, options: agentOptions }, + ], + onConfirm: (result) => { + const name = (result.name || '').trim() + if (!name) { toast('请输入会话名称', 'warning'); return } + const agent = result.agent || defaultAgent + if (agent === '__new__') { + navigate('/agents') + toast('请在 Agent 管理页面创建新 Agent', 'info') + return + } + switchSession(`agent:${agent}:${name}`) + toast('会话已创建', 'success') + } + }) +} + +async function deleteSession(key) { + const mainKey = wsClient.snapshot?.sessionDefaults?.mainSessionKey || 'agent:main:main' + if (key === mainKey) { toast('主会话不能删除', 'warning'); return } + const label = parseSessionLabel(key) + const yes = await showConfirm(`确定删除会话「${label}」?`) + if (!yes) return + try { + await wsClient.sessionsDelete(key) + toast('会话已删除', 'success') + if (key === _sessionKey) switchSession(mainKey) + else refreshSessionList() + } catch (e) { + toast('删除失败: ' + e.message, 'error') + } +} + +async function resetCurrentSession() { + if (!_sessionKey) return + try { + await wsClient.sessionsReset(_sessionKey) + clearMessages() + _lastHistoryHash = '' + appendSystemMessage('会话已重置') + toast('会话已重置', 'success') + } catch (e) { + toast('重置失败: ' + e.message, 'error') + } +} + +function updateSessionTitle() { + const el = _page?.querySelector('#chat-title') + if (el) el.textContent = parseSessionLabel(_sessionKey) +} + +// ── 快捷指令面板 ── + +function showCmdPanel() { + if (!_cmdPanelEl) return + let html = '' + for (const group of COMMANDS) { + html += `
      ${group.title}
      ` + for (const c of group.commands) { + html += `
      + ${c.cmd} + ${c.desc} +
      ` + } + } + _cmdPanelEl.innerHTML = html + _cmdPanelEl.style.display = 'block' + _cmdPanelEl.onclick = (e) => { + const item = e.target.closest('.cmd-item') + if (!item) return + hideCmdPanel() + if (item.dataset.action === 'fill') { + _textarea.value = item.dataset.cmd + _textarea.focus() + updateSendState() + } else { + _textarea.value = item.dataset.cmd + sendMessage() + } + } +} + +function hideCmdPanel() { + if (_cmdPanelEl) _cmdPanelEl.style.display = 'none' +} + +function toggleCmdPanel() { + if (_cmdPanelEl?.style.display === 'block') hideCmdPanel() + else { _textarea.value = '/'; showCmdPanel(); _textarea.focus() } +} + +// ── 消息发送 ── + +function sendMessage() { + const text = _textarea.value.trim() + if (!text) return + hideCmdPanel() + _textarea.value = '' + _textarea.style.height = 'auto' + updateSendState() + if (_isSending || _isStreaming) { _messageQueue.push(text); return } + doSend(text) +} + +async function doSend(text) { + appendUserMessage(text) + saveMessage({ id: uuid(), sessionKey: _sessionKey, role: 'user', content: text, timestamp: Date.now() }) + showTyping(true) + _isSending = true + try { + await wsClient.chatSend(_sessionKey, text) + } catch (err) { + showTyping(false) + appendSystemMessage('发送失败: ' + err.message) + } finally { + _isSending = false + updateSendState() + } +} + +function processMessageQueue() { + if (_messageQueue.length === 0 || _isSending || _isStreaming) return + doSend(_messageQueue.shift()) +} + +function stopGeneration() { + if (_currentRunId) wsClient.chatAbort(_sessionKey, _currentRunId).catch(() => {}) +} + +// ── 事件处理(参照 clawapp 实现) ── + +function handleEvent(msg) { + const { event, payload } = msg + if (!payload) return + + if (event === 'chat') handleChatEvent(payload) +} + +function handleChatEvent(payload) { + // sessionKey 过滤 + if (payload.sessionKey && payload.sessionKey !== _sessionKey && _sessionKey) return + + const { state } = payload + + if (state === 'delta') { + const c = extractChatContent(payload.message) + if (c?.text && c.text.length > _currentAiText.length) { + showTyping(false) + if (!_currentAiBubble) { _currentAiBubble = createStreamBubble(); _currentRunId = payload.runId } + _currentAiText = c.text + throttledRender() + } + return + } + + if (state === 'final') { + const c = extractChatContent(payload.message) + const finalText = c?.text || '' + // 忽略空 final(Gateway 会为一条消息触发多个 run,部分是空 final) + if (!_currentAiBubble && !finalText) return + showTyping(false) + // 如果流式阶段没有创建 bubble,从 final message 中提取 + if (!_currentAiBubble && finalText) { + _currentAiBubble = createStreamBubble() + _currentAiText = finalText + } + if (_currentAiBubble && _currentAiText) { + _currentAiBubble.innerHTML = renderMarkdown(_currentAiText) + } + if (_currentAiText) { + saveMessage({ id: payload.runId || uuid(), sessionKey: _sessionKey, role: 'assistant', content: _currentAiText, timestamp: Date.now() }) + } + resetStreamState() + processMessageQueue() + return + } + + if (state === 'aborted') { + showTyping(false) + if (_currentAiBubble && _currentAiText) { + _currentAiBubble.innerHTML = renderMarkdown(_currentAiText) + } + appendSystemMessage('生成已停止') + resetStreamState() + processMessageQueue() + return + } + + if (state === 'error') { + const errMsg = payload.errorMessage || payload.error?.message || '未知错误' + if (_isStreaming) { + console.warn('[chat] 流式中临时错误,等待重试:', errMsg) + return + } + showTyping(false) + appendSystemMessage('错误: ' + errMsg) + resetStreamState() + processMessageQueue() + return + } +} + +/** 从 Gateway message 对象提取文本(参照 clawapp extractContent) */ +function extractChatContent(message) { + if (!message || typeof message !== 'object') return null + const content = message.content + if (typeof content === 'string') return { text: content } + if (Array.isArray(content)) { + const texts = [] + for (const block of content) { + if (block.type === 'text' && typeof block.text === 'string') texts.push(block.text) + } + const text = texts.length ? texts.join('\n') : '' + if (text) return { text } + } + if (typeof message.text === 'string') return { text: message.text } + return null +} + +/** 创建流式 AI 气泡 */ +function createStreamBubble() { + showTyping(false) + const wrap = document.createElement('div') + wrap.className = 'msg msg-ai' + const bubble = document.createElement('div') + bubble.className = 'msg-bubble' + bubble.innerHTML = '' + wrap.appendChild(bubble) + _messagesEl.insertBefore(wrap, _typingEl) + scrollToBottom() + return bubble +} + +// ── 流式渲染(节流) ── + +function throttledRender() { + if (_renderPending) return + const now = performance.now() + if (now - _lastRenderTime >= RENDER_THROTTLE) { + doRender() + } else { + _renderPending = true + requestAnimationFrame(() => { _renderPending = false; doRender() }) + } +} + +function doRender() { + _lastRenderTime = performance.now() + if (_currentAiBubble && _currentAiText) { + _currentAiBubble.innerHTML = renderMarkdown(_currentAiText) + scrollToBottom() + } +} + +// ensureAiBubble 已被 createStreamBubble 替代 + +function resetStreamState() { + clearTimeout(_streamSafetyTimer) + if (_currentAiBubble && _currentAiText) { + _currentAiBubble.innerHTML = renderMarkdown(_currentAiText) + } + _renderPending = false + _lastRenderTime = 0 + _currentAiBubble = null + _currentAiText = '' + _currentRunId = null + _isStreaming = false + showTyping(false) + updateSendState() +} + +// ── 历史消息加载 ── + +async function loadHistory() { + if (!_sessionKey) return + if (isStorageAvailable()) { + const local = await getLocalMessages(_sessionKey, 200) + if (local.length) { + clearMessages() + local.forEach(msg => { + if (msg.role === 'user') appendUserMessage(msg.content || '') + else appendAiMessage(msg.content || '') + }) + scrollToBottom() + } + } + if (!wsClient.gatewayReady) return + try { + const result = await wsClient.chatHistory(_sessionKey, 200) + if (!result?.messages?.length) { + if (!_messagesEl.querySelector('.msg')) appendSystemMessage('还没有消息,开始聊天吧') + return + } + const deduped = dedupeHistory(result.messages) + const hash = deduped.map(m => `${m.role}:${(m.text || '').length}`).join('|') + if (hash === _lastHistoryHash && _messagesEl.querySelector('.msg')) return + _lastHistoryHash = hash + clearMessages() + deduped.forEach(msg => { + if (msg.role === 'user') appendUserMessage(msg.text) + else if (msg.role === 'assistant') appendAiMessage(msg.text) + }) + saveMessages(result.messages.map(m => { + const c = extractContent(m) + return { id: m.id || uuid(), sessionKey: _sessionKey, role: m.role, content: c || '', timestamp: m.timestamp || Date.now() } + })) + scrollToBottom() + } catch (e) { + console.error('[chat] loadHistory error:', e) + if (!_messagesEl.querySelector('.msg')) appendSystemMessage('加载历史失败: ' + e.message) + } +} + +function dedupeHistory(messages) { + const deduped = [] + for (const msg of messages) { + if (msg.role === 'toolResult') continue + const text = extractContent(msg) + if (!text) continue + const last = deduped[deduped.length - 1] + if (last && last.role === msg.role) { + if (msg.role === 'user' && last.text === text) continue + if (msg.role === 'assistant') { + last.text = [last.text, text].filter(Boolean).join('\n') + continue + } + } + deduped.push({ role: msg.role, text, timestamp: msg.timestamp }) + } + return deduped +} + +function extractContent(msg) { + if (typeof msg.text === 'string') return msg.text + if (typeof msg.content === 'string') return msg.content + if (Array.isArray(msg.content)) { + return msg.content.filter(c => c.type === 'text').map(c => c.text).join('\n') + } + return '' +} + +// ── DOM 操作 ── + +function appendUserMessage(text) { + const wrap = document.createElement('div') + wrap.className = 'msg msg-user' + const bubble = document.createElement('div') + bubble.className = 'msg-bubble' + bubble.textContent = text + wrap.appendChild(bubble) + _messagesEl.insertBefore(wrap, _typingEl) + scrollToBottom() +} + +function appendAiMessage(text) { + const wrap = document.createElement('div') + wrap.className = 'msg msg-ai' + const bubble = document.createElement('div') + bubble.className = 'msg-bubble' + bubble.innerHTML = renderMarkdown(text) + wrap.appendChild(bubble) + _messagesEl.insertBefore(wrap, _typingEl) + scrollToBottom() +} + +function appendSystemMessage(text) { + const wrap = document.createElement('div') + wrap.className = 'msg msg-system' + wrap.textContent = text + _messagesEl.insertBefore(wrap, _typingEl) + scrollToBottom() +} + +function clearMessages() { + _messagesEl.querySelectorAll('.msg').forEach(m => m.remove()) +} + +function showTyping(show) { + if (_typingEl) _typingEl.style.display = show ? 'flex' : 'none' + if (show) scrollToBottom() +} + +function scrollToBottom() { + if (!_messagesEl) return + requestAnimationFrame(() => { _messagesEl.scrollTop = _messagesEl.scrollHeight }) +} + +function updateSendState() { + if (!_sendBtn || !_textarea) return + if (_isStreaming) { + _sendBtn.disabled = false + _sendBtn.innerHTML = '' + _sendBtn.title = '停止生成' + } else { + _sendBtn.disabled = !_textarea.value.trim() + _sendBtn.innerHTML = '' + _sendBtn.title = '发送' + } +} + +function updateStatusDot(status) { + if (!_statusDot) return + _statusDot.className = 'status-dot' + if (status === 'ready' || status === 'connected') _statusDot.classList.add('online') + else if (status === 'connecting' || status === 'reconnecting') _statusDot.classList.add('connecting') + else _statusDot.classList.add('offline') +} + +// ── 页面离开清理 ── + +export function cleanup() { + _pageActive = false + if (_unsubEvent) { _unsubEvent(); _unsubEvent = null } + if (_unsubReady) { _unsubReady(); _unsubReady = null } + if (_unsubStatus) { _unsubStatus(); _unsubStatus = null } + clearTimeout(_streamSafetyTimer) + // 不断开 wsClient —— 它是全局单例,保持连接供下次进入复用 + _sessionKey = null + _page = null + _messagesEl = null + _textarea = null + _sendBtn = null + _statusDot = null + _typingEl = null + _scrollBtn = null + _sessionListEl = null + _cmdPanelEl = null + _currentAiBubble = null + _currentAiText = '' + _currentRunId = null + _isStreaming = false + _isSending = false + _messageQueue = [] + _lastHistoryHash = '' +} diff --git a/src/pages/gateway.js b/src/pages/gateway.js index a747ed3..d19cc30 100644 --- a/src/pages/gateway.js +++ b/src/pages/gateway.js @@ -1,5 +1,5 @@ /** - * Gateway 配置页面 + * Gateway 配置页面 — 小白友好版 */ import { api } from '../lib/tauri-api.js' import { toast } from '../components/toast.js' @@ -11,11 +11,15 @@ export async function render() { page.innerHTML = `
      加载中...
      -
      - +
      + + 修改后点击保存,Gateway 会自动重载
      ` @@ -29,7 +33,7 @@ export async function render() { await saveConfig(page, state) } finally { btn.disabled = false - btn.textContent = '保存配置' + btn.innerHTML = ` 保存并生效` } } return page @@ -48,57 +52,117 @@ function renderConfig(page, state) { const el = page.querySelector('#gateway-config') const gw = state.config?.gateway || {} + // 端口 + 谁能访问 el.innerHTML = `
      -
      基础设置
      -
      -
      - - -
      Gateway 监听的本地端口号,范围 1024-65535。修改后需重载服务生效。
      -
      -
      - - -
      仅本机:只允许本机访问(127.0.0.1)。所有接口:允许局域网内其他设备通过 IP 访问。
      -
      +
      + + 服务端口
      - - -
      本地模式:Gateway 直接调用本机模型服务。远程模式:Gateway 转发请求到远程 API 端点。
      + + +
      应用通过这个端口连接 Gateway,默认 18789,一般不需要改
      -
      认证
      +
      + + 谁能访问 +
      +
      + + +
      +
      + +
      +
      + + 运行模式 +
      +
      + + +
      +
      + +
      +
      + + 安全认证 +
      - +
      - +
      -
      访问 Gateway 时需要携带的认证令牌。留空表示不启用认证,任何人都可以直接调用。建议在开放网络环境下设置。
      +
      设置后,应用调用时需要带上这个密钥才能通过。如果选了「局域网共享」,强烈建议设置
      -
      -
      Tailscale
      -
      - - -
      通过 Tailscale 组网暴露 Gateway 的地址。填写后,远程设备可通过此地址访问。留空表示不使用 Tailscale。
      +
      + + 高级选项 +
      + ` - // 切换密码可见 + bindConfigEvents(el) +} + +function bindConfigEvents(el) { + // 密码显示/隐藏 el.querySelector('#btn-toggle-token').onclick = () => { const input = el.querySelector('#gw-token') const btn = el.querySelector('#btn-toggle-token') @@ -110,29 +174,51 @@ function renderConfig(page, state) { btn.textContent = '显示' } } + + // 选项卡片点击高亮 + el.querySelectorAll('.gw-option-cards').forEach(group => { + group.querySelectorAll('.gw-option-card').forEach(card => { + card.addEventListener('click', () => { + group.querySelectorAll('.gw-option-card').forEach(c => c.classList.remove('selected')) + card.classList.add('selected') + }) + }) + }) + + // 高级选项折叠 + el.querySelector('#gw-advanced-toggle').onclick = () => { + const panel = el.querySelector('#gw-advanced-panel') + const toggle = el.querySelector('#gw-advanced-toggle') + const visible = panel.style.display !== 'none' + panel.style.display = visible ? 'none' : 'block' + toggle.classList.toggle('open', !visible) + } } async function saveConfig(page, state) { const port = parseInt(page.querySelector('#gw-port')?.value) || 18789 - const bind = page.querySelector('#gw-bind')?.value || 'loopback' - const mode = page.querySelector('#gw-mode')?.value || 'local' + const bindRadio = page.querySelector('input[name="gw-bind"]:checked') + const bind = bindRadio?.value || 'loopback' + const modeRadio = page.querySelector('input[name="gw-mode"]:checked') + const mode = modeRadio?.value || 'local' const authToken = page.querySelector('#gw-token')?.value || '' const tailscaleAddr = page.querySelector('#gw-tailscale')?.value || '' state.config.gateway = { ...state.config.gateway, - port, bind, mode, authToken, + port, bind, mode, + auth: { token: authToken }, tailscale: tailscaleAddr.trim() ? { address: tailscaleAddr.trim() } : undefined, } try { await api.writeOpenclawConfig(state.config) - toast('Gateway 配置已保存,正在重载服务...', 'info') + toast('配置已保存,正在重载 Gateway...', 'info') try { await api.reloadGateway() - toast('Gateway 已重载,配置已生效', 'success') + toast('Gateway 已重载,新配置已生效', 'success') } catch (e) { - toast('配置已保存,但重载 Gateway 失败: ' + e, 'warning') + toast('配置已保存,但重载失败: ' + e, 'warning') } } catch (e) { toast('保存失败: ' + e, 'error') diff --git a/src/pages/memory.js b/src/pages/memory.js index 9c63ba0..42022be 100644 --- a/src/pages/memory.js +++ b/src/pages/memory.js @@ -15,10 +15,24 @@ export async function render() { const page = document.createElement('div') page.className = 'page' + // 先获取 agent 列表 + let agents = [] + try { + agents = await api.listAgents() + } catch { agents = [{ id: 'main', identityName: '默认' }] } + + const agentOptions = agents.map(a => { + const label = a.identityName ? a.identityName.split(',')[0].trim() : a.id + return `` + }).join('') + page.innerHTML = `
      ${CATEGORIES.map((c, i) => `
      ${c.label}
      `).join('')} @@ -49,7 +63,15 @@ export async function render() {
      ` - const state = { category: 'memory', currentPath: null } + const state = { category: 'memory', currentPath: null, agentId: 'main' } + + // Agent 切换 + page.querySelector('#agent-select').onchange = (e) => { + state.agentId = e.target.value + state.currentPath = null + resetEditor(page) + loadFiles(page, state) + } // Tab 切换 page.querySelectorAll('.tab').forEach(tab => { @@ -79,7 +101,7 @@ export async function render() { onConfirm: async ({ filename }) => { if (!filename) return try { - await api.writeMemoryFile(filename, `# ${filename}\n\n`, state.category) + await api.writeMemoryFile(filename, `# ${filename}\n\n`, state.category, state.agentId) toast(`已创建 ${filename}`, 'success') loadFiles(page, state) } catch (e) { @@ -97,7 +119,7 @@ export async function render() { const yes = await showConfirm(`确定删除 ${name}?`) if (!yes) return try { - await api.deleteMemoryFile(state.currentPath) + await api.deleteMemoryFile(state.currentPath, state.agentId) toast(`已删除 ${name}`, 'success') state.currentPath = null resetEditor(page) @@ -122,7 +144,7 @@ async function loadFiles(page, state) { tree.innerHTML = '
      加载中...
      ' try { - const files = await api.listMemoryFiles(state.category) + const files = await api.listMemoryFiles(state.category, state.agentId) if (!files || !files.length) { tree.innerHTML = '
      暂无文件
      ' return @@ -170,7 +192,7 @@ async function loadFileContent(page, state) { btnPreview.textContent = '预览' try { - const content = await api.readMemoryFile(state.currentPath) + const content = await api.readMemoryFile(state.currentPath, state.agentId) editor.value = content || '' editor.disabled = false btnSave.disabled = false @@ -202,7 +224,7 @@ async function saveFile(page, state) { if (!state.currentPath) return const content = page.querySelector('#file-editor').value try { - await api.writeMemoryFile(state.currentPath, content) + await api.writeMemoryFile(state.currentPath, content, null, state.agentId) toast('文件已保存', 'success') } catch (e) { toast('保存失败: ' + e, 'error') @@ -273,7 +295,7 @@ async function downloadCurrentFile(page, state) { async function exportZip(state) { try { - const zipPath = await api.exportMemoryZip(state.category) + const zipPath = await api.exportMemoryZip(state.category, state.agentId) const label = CATEGORIES.find(c => c.key === state.category)?.label || state.category // 尝试用 Tauri shell open 打开文件所在目录 try { diff --git a/src/pages/models.js b/src/pages/models.js index 5492e8d..564ab7e 100644 --- a/src/pages/models.js +++ b/src/pages/models.js @@ -235,14 +235,15 @@ function renderProviders(page, state) {
      排序: +
      ` : ''}
      @@ -285,8 +286,9 @@ function renderModelCards(providerKey, models, primary, search) { const testTime = m.lastTestAt ? formatTestTime(m.lastTestAt) : '' if (testTime) meta.push(testTime) return ` -
      +
      + ⋮⋮
      @@ -343,6 +345,7 @@ async function undo(page, state) { // 自动保存(防抖 300ms) let _saveTimer = null +let _batchTestAbort = null // 批量测试终止控制器 function autoSave(state) { clearTimeout(_saveTimer) _saveTimer = setTimeout(() => doAutoSave(state), 300) @@ -353,7 +356,7 @@ async function doAutoSave(state) { const primary = getCurrentPrimary(state.config) if (primary) applyDefaultModel(state) await api.writeOpenclawConfig(state.config) - try { await api.reloadGateway() } catch {} + // Gateway 会自动检测配置变化并热重载,无需手动 kickstart toast('已自动保存', 'success') } catch (e) { toast('自动保存失败: ' + e, 'error') @@ -374,11 +377,79 @@ function bindProviderButtons(listEl, page, state) { // 绑定排序下拉框 listEl.querySelectorAll('select[data-action="sort-models"]').forEach(select => { select.onchange = (e) => { - state.sortBy = e.target.value - renderProviders(page, state) + const val = e.target.value + const section = select.closest('[data-provider]') + if (!section) return + const providerKey = section.dataset.provider + const provider = state.config.models.providers[providerKey] + + if (val === 'default') { + state.sortBy = 'default' + renderProviders(page, state) + } else { + // 将排序固化到底层数据并保存 + pushUndo(state) + provider.models = sortModels(provider.models, val) + // 恢复下拉框显示 "默认顺序",因为新顺序已经变成了默认顺序 + state.sortBy = 'default' + renderProviders(page, state) + autoSave(state) + toast('排序已保存', 'success') + } } }) + // 绑定拖拽排序 (Drag & Drop) + listEl.querySelectorAll('.provider-models').forEach(container => { + let dragged = null + container.addEventListener('dragstart', e => { + dragged = e.target.closest('.model-card') + if (dragged) { + dragged.style.opacity = '0.5' + e.dataTransfer.effectAllowed = 'move' + } + }) + container.addEventListener('dragend', e => { + if (dragged) { + dragged.style.opacity = '1' + dragged = null + } + }) + container.addEventListener('dragover', e => { + e.preventDefault() + const targetCard = e.target.closest('.model-card') + if (dragged && targetCard && dragged !== targetCard) { + const bounding = targetCard.getBoundingClientRect() + const offset = bounding.y + bounding.height / 2 + if (e.clientY > offset) { + targetCard.after(dragged) + } else { + targetCard.before(dragged) + } + } + }) + container.addEventListener('drop', e => { + e.preventDefault() + if (!dragged) return + + const section = container.closest('[data-provider]') + if (!section) return + const providerKey = section.dataset.provider + const provider = state.config.models.providers[providerKey] + if (!provider) return + + // 获取新的顺序 + const newOrderIds = [...container.querySelectorAll('.model-card')].map(c => c.dataset.modelId) + + pushUndo(state) + const oldModels = [...provider.models] + provider.models = newOrderIds.map(id => oldModels.find(m => (typeof m === 'string' ? m : m.id) === id)) + + // 更新状态不重新渲染以保持列表稳定 + autoSave(state) + }) + }) + // 绑定按钮 listEl.querySelectorAll('button[data-action], input[data-action]').forEach(btn => { const action = btn.dataset.action @@ -389,9 +460,16 @@ function bindProviderButtons(listEl, page, state) { if (!provider) return const card = btn.closest('.model-card') - btn.onclick = (e) => { - e.stopPropagation() - handleAction(action, btn, card, section, providerKey, provider, page, state) + // checkbox 改变时不需要阻止冒泡,由 handleAction 内部处理 + if (btn.type === 'checkbox') { + btn.onchange = (e) => { + handleAction(action, btn, card, section, providerKey, provider, page, state) + } + } else { + btn.onclick = (e) => { + e.stopPropagation() + handleAction(action, btn, card, section, providerKey, provider, page, state) + } } }) } @@ -814,6 +892,13 @@ async function handleBatchDelete(section, page, state, providerKey) { // 批量测试:勾选的模型,没勾选则测试全部(记录耗时和状态) async function handleBatchTest(section, state, providerKey) { + // 如果正在测试,点击则终止 + if (_batchTestAbort) { + _batchTestAbort.abort = true + toast('正在终止批量测试...', 'warning') + return + } + const provider = state.config.models.providers[providerKey] const checked = [...section.querySelectorAll('.model-checkbox:checked')] const ids = checked.length @@ -823,11 +908,24 @@ async function handleBatchTest(section, state, providerKey) { if (!ids.length) { toast('没有可测试的模型', 'warning'); return } const batchBtn = section.querySelector('[data-action="batch-test"]') - if (batchBtn) { batchBtn.disabled = true; batchBtn.textContent = '测试中...' } + const ctrl = { abort: false } + _batchTestAbort = ctrl + if (batchBtn) { + batchBtn.textContent = '终止测试' + batchBtn.classList.remove('btn-secondary') + batchBtn.classList.add('btn-danger') + } + const page = section.closest('.page') let ok = 0, fail = 0 for (const modelId of ids) { + if (ctrl.abort) break + const model = (provider.models || []).find(m => (typeof m === 'string' ? m : m.id) === modelId) + // 标记当前正在测试的卡片 + const card = section.querySelector(`.model-card[data-model-id="${modelId}"]`) + if (card) card.style.outline = '2px solid var(--accent)' + const start = Date.now() try { await api.testModel(provider.baseUrl, provider.apiKey || '', modelId) @@ -839,7 +937,6 @@ async function handleBatchTest(section, state, providerKey) { 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') { @@ -849,18 +946,37 @@ async function handleBatchTest(section, state, providerKey) { model.testError = String(e).slice(0, 100) } fail++ - toast(`${modelId} 不可用 ${(elapsed / 1000).toFixed(1)}s: ${e}`, 'error') } + + // 每测完一个实时刷新卡片 + if (page) { + renderProviders(page, state) + renderDefaultBar(page, state) + } + // 进度 toast + const status = model?.testStatus === 'ok' ? '✓' : '✗' + const latStr = model?.latency != null ? ` ${(model.latency / 1000).toFixed(1)}s` : '' + toast(`${status} ${modelId}${latStr} (${ok + fail}/${ids.length})`, model?.testStatus === 'ok' ? 'success' : 'error') } - if (batchBtn) { batchBtn.disabled = false; batchBtn.textContent = '批量测试' } - // 刷新卡片显示最新状态 - const page = section.closest('.page') - if (page) { - renderProviders(page, state) - renderDefaultBar(page, state) + // 恢复按钮 + _batchTestAbort = null + // 重新查找按钮(renderProviders 后 DOM 已更新) + const newSection = page?.querySelector(`[data-provider="${providerKey}"]`) + const newBtn = newSection?.querySelector('[data-action="batch-test"]') + if (newBtn) { + newBtn.textContent = '批量测试' + newBtn.classList.remove('btn-danger') + newBtn.classList.add('btn-secondary') + } + + const aborted = ctrl.abort + autoSave(state) + if (aborted) { + toast(`批量测试已终止:${ok} 成功,${fail} 失败,${ids.length - ok - fail} 跳过`, 'warning') + } else { + toast(`批量测试完成:${ok} 成功,${fail} 失败`, ok === ids.length ? 'success' : 'warning') } - toast(`批量测试完成:${ok} 成功,${fail} 失败`, ok === ids.length ? 'success' : 'warning') } // 从服务商远程获取模型列表 @@ -998,5 +1114,7 @@ async function testModel(btn, state, providerKey, idx) { renderProviders(page, state) renderDefaultBar(page, state) } + // 持久化测试结果 + autoSave(state) } } diff --git a/src/pages/services.js b/src/pages/services.js index 50a6d8b..479ba1e 100644 --- a/src/pages/services.js +++ b/src/pages/services.js @@ -33,6 +33,7 @@ export async function render() {
      配置备份
      +
      备份范围:openclaw.json 主配置文件(含模型、Provider、Gateway 设置)。Agent 数据和记忆文件不在此备份范围内。
      diff --git a/src/router.js b/src/router.js index 81fa514..8887a40 100644 --- a/src/router.js +++ b/src/router.js @@ -39,6 +39,16 @@ async function loadRoute() { } _contentEl.innerHTML = '' + + // 显示加载动画 + const loader_el = document.createElement('div') + loader_el.className = 'page-loader' + loader_el.innerHTML = ` +
      +
      加载中...
      + ` + _contentEl.appendChild(loader_el) + const mod = await loader() // 如果加载期间路由又变了,丢弃本次结果 @@ -47,6 +57,8 @@ async function loadRoute() { const page = mod.render ? await mod.render() : mod.default ? await mod.default() : mod if (thisLoad !== _loadId) return + // 移除加载动画,插入页面内容 + _contentEl.innerHTML = '' if (typeof page === 'string') { _contentEl.innerHTML = page } else if (page instanceof HTMLElement) { diff --git a/src/style/agents.css b/src/style/agents.css new file mode 100644 index 0000000..675285b --- /dev/null +++ b/src/style/agents.css @@ -0,0 +1,66 @@ +/* Agent 管理页面样式 */ +.agent-card { + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + padding: 16px; + margin-bottom: 12px; +} + +.agent-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.agent-card-title { + display: flex; + align-items: center; + gap: 8px; +} + +.agent-id { + font-family: var(--font-mono); + font-size: var(--font-size-base); + font-weight: 500; +} + +.badge { + font-size: var(--font-size-xs); + padding: 2px 8px; + border-radius: var(--radius-sm); +} + +.badge-success { + background: var(--success-muted); + color: var(--success); +} + +.agent-card-actions { + display: flex; + gap: 6px; +} + +.agent-card-body { + display: flex; + flex-direction: column; + gap: 8px; +} + +.agent-info-row { + display: flex; + gap: 12px; + font-size: var(--font-size-sm); +} + +.agent-info-label { + color: var(--text-tertiary); + min-width: 60px; + flex-shrink: 0; +} + +.agent-info-value { + color: var(--text-secondary); + word-break: break-all; +} diff --git a/src/style/chat.css b/src/style/chat.css new file mode 100644 index 0000000..e47ebcc --- /dev/null +++ b/src/style/chat.css @@ -0,0 +1,550 @@ +/** + * 聊天页面样式 + * 使用 clawpanel CSS 变量 + */ + +/* 页面布局 - 双栏 */ +.chat-page { + display: flex; + flex-direction: row; + height: 100%; + overflow: hidden; + padding: 0 !important; +} + +.chat-main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + position: relative; +} + +/* 会话侧边栏 */ +.chat-sidebar { + width: 220px; + border-right: 1px solid var(--border); + background: var(--bg-primary); + display: flex; + flex-direction: column; + flex-shrink: 0; +} + +.chat-sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + border-bottom: 1px solid var(--border); +} + +.chat-sidebar-btn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: flex; +} + +.chat-sidebar-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +/* 顶部栏 */ +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-sm) var(--space-md); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.chat-status { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.chat-title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); +} + +/* 状态指示点 */ +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-muted, #999); + flex-shrink: 0; +} + +.status-dot.online { + background: #22c55e; + box-shadow: 0 0 6px rgba(34, 197, 94, 0.4); +} + +.status-dot.connecting { + background: #f59e0b; + animation: pulse-dot 1.2s ease-in-out infinite; +} + +.status-dot.offline { + background: #ef4444; +} + +@keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* 消息区域 */ +.chat-messages { + flex: 1; + overflow-y: auto; + padding: var(--space-md); + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +/* 消息通用 */ +.msg { + display: flex; + max-width: 85%; + animation: msg-in 0.2s ease-out; +} + +.msg-user { + align-self: flex-end; +} + +.msg-ai { + align-self: flex-start; +} + +.msg-system { + align-self: center; + font-size: 12px; + color: var(--text-muted, #999); + padding: var(--space-xs) var(--space-sm); + max-width: 100%; +} + +@keyframes msg-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* 消息气泡 */ +.msg-bubble { + padding: var(--space-sm) var(--space-md); + border-radius: var(--radius-lg, 12px); + font-size: 14px; + line-height: 1.6; + word-break: break-word; +} + +.msg-user .msg-bubble { + background: var(--accent); + color: #fff; + border-bottom-right-radius: 4px; +} + +.msg-ai .msg-bubble { + background: var(--bg-secondary, var(--bg-card)); + color: var(--text-primary); + border-bottom-left-radius: 4px; +} + +/* AI 气泡内 Markdown 排版 */ +.msg-ai .msg-bubble p { + margin: 0 0 8px 0; +} + +.msg-ai .msg-bubble p:last-child { + margin-bottom: 0; +} + +.msg-ai .msg-bubble h1, +.msg-ai .msg-bubble h2, +.msg-ai .msg-bubble h3 { + margin: 12px 0 6px 0; + font-weight: 600; +} + +.msg-ai .msg-bubble h1 { font-size: 1.2em; } +.msg-ai .msg-bubble h2 { font-size: 1.1em; } +.msg-ai .msg-bubble h3 { font-size: 1em; } + +.msg-ai .msg-bubble ul, +.msg-ai .msg-bubble ol { + margin: 4px 0; + padding-left: 20px; +} + +.msg-ai .msg-bubble li { + margin: 2px 0; +} + +.msg-ai .msg-bubble a { + color: var(--accent); + text-decoration: none; +} + +.msg-ai .msg-bubble a:hover { + text-decoration: underline; +} + +/* 代码块 */ +.msg-ai .msg-bubble pre { + background: var(--bg-primary, #1a1a2e); + border-radius: var(--radius-md, 8px); + padding: 0; + margin: 8px 0; + overflow: hidden; + position: relative; + font-size: 13px; +} + +.msg-ai .msg-bubble pre code { + display: block; + padding: 12px 14px; + overflow-x: auto; + line-height: 1.5; + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; +} + +.msg-ai .msg-bubble pre .code-lang { + position: absolute; + top: 6px; + left: 12px; + font-size: 11px; + color: var(--text-muted, #888); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.msg-ai .msg-bubble pre .code-copy-btn { + position: absolute; + top: 6px; + right: 8px; + background: transparent; + border: 1px solid var(--border); + color: var(--text-secondary); + font-size: 11px; + padding: 2px 8px; + border-radius: 4px; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s; +} + +.msg-ai .msg-bubble pre:hover .code-copy-btn { + opacity: 1; +} + +.msg-ai .msg-bubble pre .code-copy-btn:hover { + background: var(--bg-hover); +} + +/* 代码高亮 */ +.hl-keyword { color: #c678dd; } +.hl-string { color: #98c379; } +.hl-comment { color: #5c6370; font-style: italic; } +.hl-number { color: #d19a66; } +.hl-func { color: #61afef; } +.hl-type { color: #e5c07b; } + +/* 行内代码 */ +.msg-ai .msg-bubble > code { + background: var(--bg-hover, rgba(255,255,255,0.08)); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.9em; + font-family: 'SF Mono', 'Fira Code', monospace; +} + +/* 流式光标 */ +.stream-cursor::after { + content: '▊'; + animation: blink-cursor 0.8s step-end infinite; + color: var(--accent); +} + +@keyframes blink-cursor { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +/* 打字指示器 */ +.typing-indicator { + display: flex; + align-items: center; + gap: 4px; + padding: 8px 14px; + align-self: flex-start; +} + +.typing-indicator span { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-muted, #888); + animation: typing-bounce 1.4s ease-in-out infinite; +} + +.typing-indicator span:nth-child(2) { animation-delay: 0.2s; } +.typing-indicator span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes typing-bounce { + 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } + 30% { transform: translateY(-6px); opacity: 1; } +} + +/* 输入区域 */ +.chat-input-area { + display: flex; + align-items: flex-end; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + border-top: 1px solid var(--border); + flex-shrink: 0; + background: var(--bg-primary); +} + +.chat-input-wrapper { + flex: 1; + min-width: 0; +} + +.chat-input-wrapper textarea { + width: 100%; + resize: none; + border: 1px solid var(--border); + border-radius: var(--radius-md, 8px); + padding: 10px 14px; + font-size: 14px; + line-height: 1.5; + background: var(--bg-secondary, var(--bg-card)); + color: var(--text-primary); + outline: none; + transition: border-color 0.15s; + max-height: 150px; + font-family: inherit; +} + +.chat-input-wrapper textarea:focus { + border-color: var(--accent); +} + +.chat-input-wrapper textarea::placeholder { + color: var(--text-muted, #888); +} + +/* 发送按钮 */ +.chat-send-btn { + width: 40px; + height: 40px; + border-radius: var(--radius-md, 8px); + border: none; + background: var(--accent); + color: #fff; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: opacity 0.15s, background 0.15s; +} + +.chat-send-btn:hover:not(:disabled) { + opacity: 0.85; +} + +.chat-send-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* 回到底部按钮 */ +.chat-scroll-btn { + position: absolute; + bottom: 80px; + right: 24px; + width: 36px; + height: 36px; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--bg-primary); + color: var(--text-secondary); + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); + transition: opacity 0.15s; + z-index: 10; +} + +.chat-scroll-btn:hover { + background: var(--bg-hover); +} + +/* 断连横幅 */ +.chat-disconnect-bar { + display: flex; + align-items: center; + justify-content: center; + padding: 6px; + background: #f59e0b; + color: #000; + font-size: 12px; + font-weight: 500; + flex-shrink: 0; +} + +/* 会话列表 */ +.chat-session-list { + flex: 1; + overflow-y: auto; + padding: 6px; +} + +.chat-session-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + border-radius: var(--radius-md, 6px); + cursor: pointer; + font-size: 13px; + color: var(--text-secondary); + transition: background 0.12s; +} + +.chat-session-item:hover { + background: var(--bg-hover); +} + +.chat-session-item.active { + background: var(--bg-hover); + color: var(--text-primary); + font-weight: 500; +} + +.chat-session-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.chat-session-del { + background: none; + border: none; + color: var(--text-muted, #999); + cursor: pointer; + font-size: 16px; + padding: 0 2px; + opacity: 0; + transition: opacity 0.12s; + flex-shrink: 0; +} + +.chat-session-item:hover .chat-session-del { + opacity: 1; +} + +.chat-session-del:hover { + color: #ef4444; +} + +.chat-session-empty { + text-align: center; + color: var(--text-muted, #999); + font-size: 12px; + padding: 20px 0; +} + +/* 头部操作区 */ +.chat-header-actions { + display: flex; + align-items: center; + gap: 4px; +} + +.chat-toggle-sidebar { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: flex; +} + +.chat-toggle-sidebar:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +/* 快捷指令面板 */ +.chat-cmd-panel { + position: absolute; + bottom: 70px; + left: var(--space-md); + right: var(--space-md); + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius-md, 8px); + box-shadow: 0 4px 16px rgba(0,0,0,0.15); + max-height: 280px; + overflow-y: auto; + padding: 6px; + z-index: 20; +} + +.cmd-group-title { + font-size: 11px; + font-weight: 600; + color: var(--text-muted, #888); + padding: 6px 8px 2px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.cmd-item { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 8px; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + transition: background 0.1s; +} + +.cmd-item:hover { + background: var(--bg-hover); +} + +.cmd-name { + color: var(--accent); + font-family: 'SF Mono', monospace; + font-size: 12px; + min-width: 100px; +} + +.cmd-desc { + color: var(--text-secondary); +} diff --git a/src/style/components.css b/src/style/components.css index 7ada37f..d5b1872 100644 --- a/src/style/components.css +++ b/src/style/components.css @@ -251,3 +251,37 @@ mark { from { opacity: 0; } to { opacity: 1; } } + +/* 页面加载动画 */ +.page-loader { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 60vh; + gap: var(--space-md); + animation: fadeInLoader 0.3s ease; +} + +.page-loader-spinner { + width: 32px; + height: 32px; + border: 3px solid var(--border-primary); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.page-loader-text { + font-size: var(--font-size-sm); + color: var(--text-tertiary); +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@keyframes fadeInLoader { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} diff --git a/src/style/layout.css b/src/style/layout.css index 29a948c..1815307 100644 --- a/src/style/layout.css +++ b/src/style/layout.css @@ -124,6 +124,13 @@ margin-bottom: var(--space-xl); } +.page-header:has(.page-actions) { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-md); +} + .page-title { font-size: var(--font-size-2xl); font-weight: 700; diff --git a/src/style/pages.css b/src/style/pages.css index a939dd0..151494b 100644 --- a/src/style/pages.css +++ b/src/style/pages.css @@ -94,6 +94,9 @@ } .config-section-title { + display: flex; + align-items: center; + gap: var(--space-sm); font-size: var(--font-size-lg); font-weight: 600; margin-bottom: var(--space-lg); @@ -314,3 +317,124 @@ font-size: 11px; color: var(--text-secondary); } + +/* Gateway 配置页 — 选项卡片 */ +.gw-option-cards { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-md); +} + +.gw-option-card { + display: flex; + align-items: flex-start; + gap: var(--space-md); + padding: var(--space-lg); + background: var(--bg-secondary); + border: 2px solid var(--border-primary); + border-radius: var(--radius-lg); + cursor: pointer; + transition: all var(--transition-fast); + user-select: none; +} + +.gw-option-card:hover { + background: var(--bg-card-hover); + border-color: var(--border-focus); +} + +.gw-option-card.selected { + border-color: var(--accent); + background: var(--accent-muted); +} + +.gw-option-icon { + width: 36px; + height: 36px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-tertiary); + border-radius: var(--radius-md); + color: var(--text-secondary); + transition: all var(--transition-fast); +} + +.gw-option-card.selected .gw-option-icon { + background: var(--accent); + color: white; +} + +.gw-option-icon svg { + width: 20px; + height: 20px; +} + +.gw-option-text { + flex: 1; + min-width: 0; +} + +.gw-option-title { + font-weight: 600; + font-size: var(--font-size-md); + color: var(--text-primary); + margin-bottom: 2px; +} + +.gw-option-desc { + font-size: var(--font-size-xs); + color: var(--text-tertiary); + line-height: 1.5; +} + +/* Gateway — 高级选项折叠 */ +.gw-advanced-toggle { + display: flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-sm) 0; + font-size: var(--font-size-sm); + color: var(--text-tertiary); + cursor: pointer; + user-select: none; + transition: color var(--transition-fast); +} + +.gw-advanced-toggle:hover { + color: var(--text-primary); +} + +.gw-advanced-toggle svg { + transition: transform var(--transition-fast); +} + +.gw-advanced-toggle.open svg { + transform: rotate(180deg); +} + +.gw-advanced-panel { + animation: fadeIn 200ms ease; +} + +/* Gateway — 保存栏 */ +.gw-save-bar { + display: flex; + align-items: center; + gap: var(--space-md); + margin-top: var(--space-lg); + padding-top: var(--space-lg); + border-top: 1px solid var(--border-secondary); +} + +.gw-save-bar .btn { + display: inline-flex; + align-items: center; + gap: var(--space-xs); +} + +.gw-save-hint { + font-size: var(--font-size-xs); + color: var(--text-tertiary); +}