feat: 聊天页面 + Agent 管理 + 模型排序固化 + 多项增强

- 新增聊天页面(WebSocket 实时通信、会话管理、Markdown 渲染)
- 新增 Agent 管理页面(CRUD、备份、工作区配置、模型选择)
- 新增设备认证模块(Ed25519 签名)
- 模型排序固化到配置文件(拖拽排序 + 下拉排序均写入底层数据)
- 记忆文件页面支持 Agent 切换
- Gateway 配置页重构为选项卡片布局
- 页面路由加载动画
- 批量测试支持终止、实时刷新卡片
- 服务管理备份说明文案
- Modal 组件支持 readonly 字段
- 脱敏 mock 数据中的内部地址
This commit is contained in:
晴天
2026-03-01 12:19:21 +08:00
parent 9b6d572ec8
commit 3705ab8081
27 changed files with 3212 additions and 129 deletions

113
src-tauri/Cargo.lock generated
View File

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

View File

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

View File

@@ -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<Value, String> {
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<String>) -> Result<Value, String> {
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<String, String> {
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<String>,
emoji: Option<String>,
) -> Result<String, String> {
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<String, String> {
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<fs::File>,
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(())
}

View File

@@ -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<Vec<u8>, 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<Value, String> {
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::<u16>()),
"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)
}

View File

@@ -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<PathBuf, String> {
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<PathBuf, String> {
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<Vec<String>, String> {
let dir = memory_dir(&category);
pub fn list_memory_files(category: String, agent_id: Option<String>) -> Result<Vec<String>, 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<String, String> {
// 安全检查:路径不能包含 ..、绝对路径、空字节
pub fn read_memory_file(path: String, agent_id: Option<String>) -> Result<String, 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::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<String, String> {
}
#[tauri::command]
pub fn write_memory_file(path: String, content: String, category: Option<String>) -> Result<(), String> {
// 安全检查:路径不能包含 ..、绝对路径、空字节
pub fn write_memory_file(path: String, content: String, category: Option<String>, agent_id: Option<String>) -> 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<String>
}
#[tauri::command]
pub fn delete_memory_file(path: String) -> Result<(), String> {
// 安全检查:路径不能包含 ..、绝对路径、空字节
pub fn delete_memory_file(path: String, agent_id: Option<String>) -> 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<String, String> {
let dir = memory_dir(&category);
pub fn export_memory_zip(category: String, agent_id: Option<String>) -> Result<String, String> {
let aid = agent_id.as_deref().unwrap_or("main");
let dir = memory_dir_for_agent(aid, &category)?;
if !dir.exists() {
return Err("目录不存在".to_string());
}

View File

@@ -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;

View File

@@ -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 失败");

View File

@@ -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": {

View File

@@ -81,7 +81,7 @@ export function showModal({ title, fields, onConfirm }) {
return `
<div class="form-group">
<label class="form-label">${f.label}</label>
<input class="form-input" data-name="${f.name}" value="${escapeAttr(f.value)}" placeholder="${escapeAttr(f.placeholder)}">
<input class="form-input" data-name="${f.name}" value="${escapeAttr(f.value)}" placeholder="${escapeAttr(f.placeholder)}"${f.readonly ? ' readonly style="opacity:0.6;cursor:not-allowed"' : ''}>
${f.hint ? `<div class="form-hint">${f.hint}</div>` : ''}
</div>`
}).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 关闭

View File

@@ -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: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>',
chat: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>',
services: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>',
logs: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>',
models: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/><path d="M3.27 6.96L12 12.01l8.73-5.05M12 22.08V12"/></svg>',
agents: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/></svg>',
gateway: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>',
memory: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z"/><path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z"/></svg>',
extensions: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>',

136
src/lib/markdown.js Normal file
View File

@@ -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, '<span class="hl-number">$1</span>')
.replace(/(\/\/.*$|#.*$)/gm, '<span class="hl-comment">$1</span>')
.replace(/(\/\*[\s\S]*?\*\/)/g, '<span class="hl-comment">$1</span>')
.replace(/(&quot;(?:[^&]|&(?!quot;))*?&quot;|&#x27;(?:[^&]|&(?!#x27;))*?&#x27;|`[^`]*`)/g,
'<span class="hl-string">$1</span>')
.replace(/\b([A-Z][a-zA-Z0-9_]*)\b/g, (m, w) =>
KEYWORDS.has(w) ? m : `<span class="hl-type">${w}</span>`)
.replace(/\b(\w+)(?=\s*\()/g, (m, w) =>
KEYWORDS.has(w) ? m : `<span class="hl-func">${w}</span>`)
.replace(/\b(\w+)\b/g, (m, w) =>
KEYWORDS.has(w) ? `<span class="hl-keyword">${w}</span>` : m)
}
function escapeHtml(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;')
}
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 ? `<span class="code-lang">${escapeHtml(lang)}</span>` : ''
return `<pre data-lang="${escapeHtml(lang)}">${langLabel}<button class="code-copy-btn" onclick="window.__copyCode(this)">Copy</button><code>${highlighted}</code></pre>`
})
// 行内代码
html = html.replace(/`([^`\n]+)`/g, (_, code) => `<code>${escapeHtml(code)}</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('<pre')) {
result.push(line)
while (i < lines.length - 1 && !lines[i].includes('</pre>')) { i++; result.push(lines[i]) }
continue
}
// 标题
const headingMatch = line.match(/^(#{1,3})\s+(.+)$/)
if (headingMatch) {
if (inList) { result.push(`</${listType}>`); inList = false }
const level = headingMatch[1].length
result.push(`<h${level}>${inlineFormat(headingMatch[2])}</h${level}>`)
continue
}
// 无序列表
const ulMatch = line.match(/^[\s]*[-*]\s+(.+)$/)
if (ulMatch) {
if (!inList || listType !== 'ul') {
if (inList) result.push(`</${listType}>`)
result.push('<ul>'); inList = true; listType = 'ul'
}
result.push(`<li>${inlineFormat(ulMatch[1])}</li>`)
continue
}
// 有序列表
const olMatch = line.match(/^[\s]*\d+\.\s+(.+)$/)
if (olMatch) {
if (!inList || listType !== 'ol') {
if (inList) result.push(`</${listType}>`)
result.push('<ol>'); inList = true; listType = 'ol'
}
result.push(`<li>${inlineFormat(olMatch[1])}</li>`)
continue
}
if (inList) { result.push(`</${listType}>`); inList = false }
if (line.trim() === '') { result.push(''); continue }
if (!line.startsWith('<')) { result.push(`<p>${inlineFormat(line)}</p>`) }
else { result.push(line) }
}
if (inList) result.push(`</${listType}>`)
return result.join('\n')
}
function inlineFormat(text) {
return text
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/__(.+?)__/g, '<strong>$1</strong>')
.replace(/_(.+?)_/g, '<em>$1</em>')
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" class="msg-img" />')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
const safe = /^https?:|^mailto:/i.test(url.trim()) ? url : '#'
return `<a href="${safe}" target="_blank" rel="noopener">${label}</a>`
})
}
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)
})
}

112
src/lib/message-db.js Normal file
View File

@@ -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 }
}

View File

@@ -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 }),
}

348
src/lib/ws-client.js Normal file
View File

@@ -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()

View File

@@ -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'))

238
src/pages/agents.js Normal file
View File

@@ -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 = `
<div class="page-header">
<div>
<h1 class="page-title">Agent 管理</h1>
<p class="page-desc">创建和管理 OpenClaw 智能体,配置身份、模型和工作区</p>
</div>
<div class="page-actions">
<button class="btn btn-primary" id="btn-add-agent">+ 新建 Agent</button>
</div>
</div>
<div class="page-content">
<div id="agents-list"></div>
</div>
`
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 = '<div style="color:var(--text-tertiary);padding:20px;text-align:center">暂无 Agent</div>'
return
}
container.innerHTML = state.agents.map(a => {
const isDefault = a.isDefault || a.id === 'main'
const name = a.identityName ? a.identityName.split(',')[0].trim() : '无描述'
return `
<div class="agent-card" data-id="${a.id}">
<div class="agent-card-header">
<div class="agent-card-title">
<span class="agent-id">${a.id}</span>
${isDefault ? '<span class="badge badge-success">默认</span>' : ''}
</div>
<div class="agent-card-actions">
<button class="btn btn-sm btn-secondary" data-action="backup" data-id="${a.id}">备份</button>
<button class="btn btn-sm btn-secondary" data-action="edit" data-id="${a.id}">编辑</button>
${!isDefault ? `<button class="btn btn-sm btn-danger" data-action="delete" data-id="${a.id}">删除</button>` : ''}
</div>
</div>
<div class="agent-card-body">
<div class="agent-info-row">
<span class="agent-info-label">名称:</span>
<span class="agent-info-value">${name}</span>
</div>
<div class="agent-info-row">
<span class="agent-info-label">模型:</span>
<span class="agent-info-value">${a.model || '未设置'}</span>
</div>
<div class="agent-info-row">
<span class="agent-info-label">工作区:</span>
<span class="agent-info-value" style="font-family:var(--font-mono);font-size:var(--font-size-xs)">${a.workspace || '未设置'}</span>
</div>
</div>
</div>
`
}).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')
}
}

730
src/pages/chat.js Normal file
View File

@@ -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 = `
<div class="chat-sidebar" id="chat-sidebar">
<div class="chat-sidebar-header">
<span>会话列表</span>
<button class="chat-sidebar-btn" id="btn-new-session" title="新建会话">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
<div class="chat-session-list" id="chat-session-list"></div>
</div>
<div class="chat-main">
<div class="chat-header">
<div class="chat-status">
<button class="chat-toggle-sidebar" id="btn-toggle-sidebar" title="会话列表">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<span class="status-dot" id="chat-status-dot"></span>
<span class="chat-title" id="chat-title">聊天</span>
</div>
<div class="chat-header-actions">
<button class="btn btn-sm btn-ghost" id="btn-cmd" title="快捷指令">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M18 3a3 3 0 00-3 3v12a3 3 0 003 3 3 3 0 003-3 3 3 0 00-3-3H6a3 3 0 00-3 3 3 3 0 003 3 3 3 0 003-3V6a3 3 0 00-3-3 3 3 0 00-3 3 3 3 0 003 3h12a3 3 0 003-3 3 3 0 00-3-3z"/></svg>
</button>
<button class="btn btn-sm btn-ghost" id="btn-reset-session" title="重置会话">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg>
</button>
</div>
</div>
<div class="chat-messages" id="chat-messages">
<div class="typing-indicator" id="typing-indicator" style="display:none">
<span></span><span></span><span></span>
</div>
</div>
<button class="chat-scroll-btn" id="chat-scroll-btn" style="display:none">↓</button>
<div class="chat-cmd-panel" id="chat-cmd-panel" style="display:none"></div>
<div class="chat-input-area">
<div class="chat-input-wrapper">
<textarea id="chat-input" rows="1" placeholder="输入消息Enter 发送,/ 打开指令"></textarea>
</div>
<button class="chat-send-btn" id="chat-send-btn" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
<div class="chat-disconnect-bar" id="chat-disconnect-bar" style="display:none">连接已断开,正在重连...</div>
</div>
`
_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 = '<div class="chat-session-empty">暂无会话</div>'
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 `<div class="chat-session-item${active}" data-key="${key}">
<span class="chat-session-label">${label}</span>
<button class="chat-session-del" data-del="${key}" title="删除">×</button>
</div>`
}).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 += `<div class="cmd-group-title">${group.title}</div>`
for (const c of group.commands) {
html += `<div class="cmd-item" data-cmd="${c.cmd}" data-action="${c.action}">
<span class="cmd-name">${c.cmd}</span>
<span class="cmd-desc">${c.desc}</span>
</div>`
}
}
_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 || ''
// 忽略空 finalGateway 会为一条消息触发多个 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 = '<span class="stream-cursor"></span>'
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 = '<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>'
_sendBtn.title = '停止生成'
} else {
_sendBtn.disabled = !_textarea.value.trim()
_sendBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>'
_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 = ''
}

View File

@@ -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 = `
<div class="page-header">
<h1 class="page-title">Gateway 配置</h1>
<p class="page-desc">配置 OpenClaw Gateway 端口、绑定和认证</p>
<p class="page-desc">Gateway 是 AI 模型的统一入口,所有应用通过它来调用模型服务</p>
</div>
<div id="gateway-config">加载中...</div>
<div style="margin-top:16px">
<button class="btn btn-primary" id="btn-save-gw">保存配置</button>
<div class="gw-save-bar">
<button class="btn btn-primary" id="btn-save-gw">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>
保存并生效
</button>
<span class="gw-save-hint">修改后点击保存Gateway 会自动重载</span>
</div>
`
@@ -29,7 +33,7 @@ export async function render() {
await saveConfig(page, state)
} finally {
btn.disabled = false
btn.textContent = '保存配置'
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg> 保存并生效`
}
}
return page
@@ -48,57 +52,117 @@ function renderConfig(page, state) {
const el = page.querySelector('#gateway-config')
const gw = state.config?.gateway || {}
// 端口 + 谁能访问
el.innerHTML = `
<div class="config-section">
<div class="config-section-title">基础设置</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div class="form-group">
<label class="form-label">端口</label>
<input class="form-input" id="gw-port" type="number" value="${gw.port || 18789}" min="1024" max="65535">
<div class="form-hint">Gateway 监听的本地端口号,范围 1024-65535。修改后需重载服务生效。</div>
</div>
<div class="form-group">
<label class="form-label">绑定模式</label>
<select class="form-input" id="gw-bind">
<option value="loopback" ${gw.bind === 'loopback' ? 'selected' : ''}>仅本机 (Loopback)</option>
<option value="all" ${gw.bind === 'all' ? 'selected' : ''}>所有接口 (All)</option>
</select>
<div class="form-hint">仅本机只允许本机访问127.0.0.1)。所有接口:允许局域网内其他设备通过 IP 访问。</div>
</div>
<div class="config-section-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
服务端口
</div>
<div class="form-group">
<label class="form-label">运行模式</label>
<select class="form-input" id="gw-mode">
<option value="local" ${gw.mode === 'local' ? 'selected' : ''}>本地模式</option>
<option value="remote" ${gw.mode === 'remote' ? 'selected' : ''}>远程模式</option>
</select>
<div class="form-hint">本地模式Gateway 直接调用本机模型服务。远程模式Gateway 转发请求到远程 API 端点。</div>
<label class="form-label">端口号</label>
<input class="form-input" id="gw-port" type="number" value="${gw.port || 18789}" min="1024" max="65535" style="max-width:200px">
<div class="form-hint">应用通过这个端口连接 Gateway默认 18789一般不需要改</div>
</div>
</div>
<div class="config-section">
<div class="config-section-title">认证</div>
<div class="config-section-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
谁能访问
</div>
<div class="gw-option-cards">
<label class="gw-option-card ${gw.bind === 'all' ? '' : 'selected'}" data-bind="loopback">
<input type="radio" name="gw-bind" value="loopback" ${gw.bind === 'all' ? '' : 'checked'} hidden>
<div class="gw-option-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<div class="gw-option-text">
<div class="gw-option-title">仅本机使用</div>
<div class="gw-option-desc">只有这台电脑上的应用能访问,最安全</div>
</div>
</label>
<label class="gw-option-card ${gw.bind === 'all' ? 'selected' : ''}" data-bind="all">
<input type="radio" name="gw-bind" value="all" ${gw.bind === 'all' ? 'checked' : ''} hidden>
<div class="gw-option-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="7" height="10" rx="1"/><rect x="9" y="3" width="6" height="14" rx="1"/><rect x="16" y="6" width="7" height="10" rx="1"/><line x1="8" y1="12" x2="9" y2="12"/><line x1="15" y1="12" x2="16" y2="12"/></svg>
</div>
<div class="gw-option-text">
<div class="gw-option-title">局域网共享</div>
<div class="gw-option-desc">同一网络下的手机、平板等设备也能用</div>
</div>
</label>
</div>
</div>
<div class="config-section">
<div class="config-section-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
运行模式
</div>
<div class="gw-option-cards">
<label class="gw-option-card ${gw.mode === 'remote' ? '' : 'selected'}" data-mode="local">
<input type="radio" name="gw-mode" value="local" ${gw.mode === 'remote' ? '' : 'checked'} hidden>
<div class="gw-option-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
</div>
<div class="gw-option-text">
<div class="gw-option-title">本地模式</div>
<div class="gw-option-desc">模型跑在这台电脑上(如 Ollama不需要联网</div>
</div>
</label>
<label class="gw-option-card ${gw.mode === 'remote' ? 'selected' : ''}" data-mode="remote">
<input type="radio" name="gw-mode" value="remote" ${gw.mode === 'remote' ? 'checked' : ''} hidden>
<div class="gw-option-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
</div>
<div class="gw-option-text">
<div class="gw-option-title">云端模式</div>
<div class="gw-option-desc">调用线上 AI 服务OpenAI、Claude 等),大多数人选这个</div>
</div>
</label>
</div>
</div>
<div class="config-section">
<div class="config-section-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
安全认证
</div>
<div class="form-group">
<label class="form-label">Auth Token</label>
<label class="form-label">访问密钥</label>
<div style="display:flex;gap:8px">
<input class="form-input" id="gw-token" type="password" value="${gw.authToken || ''}" placeholder="留空则无认证" style="flex:1">
<input class="form-input" id="gw-token" type="password" value="${gw.auth?.token || gw.authToken || ''}" placeholder="不设置则任何人都能调用" style="flex:1">
<button class="btn btn-sm btn-secondary" id="btn-toggle-token">显示</button>
</div>
<div class="form-hint">访问 Gateway 时需要携带的认证令牌。留空表示不启用认证,任何人都可以直接调用。建议在开放网络环境下设置</div>
<div class="form-hint">设置后,应用调用时需要带上这个密钥才能通过。如果选了「局域网共享」,强烈建议设置</div>
</div>
</div>
<div class="config-section">
<div class="config-section-title">Tailscale</div>
<div class="form-group">
<label class="form-label">Tailscale 地址</label>
<input class="form-input" id="gw-tailscale" value="${gw.tailscale?.address || ''}" placeholder="如 100.x.x.x:18789">
<div class="form-hint">通过 Tailscale 组网暴露 Gateway 的地址。填写后,远程设备可通过此地址访问。留空表示不使用 Tailscale。</div>
<div class="gw-advanced-toggle" id="gw-advanced-toggle">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="6 9 12 15 18 9"/></svg>
高级选项
</div>
<div class="gw-advanced-panel" id="gw-advanced-panel" style="display:none">
<div class="config-section">
<div class="config-section-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 11-7.778 7.778 5.5 5.5 0 017.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
Tailscale 组网
</div>
<div class="form-group">
<label class="form-label">Tailscale 地址</label>
<input class="form-input" id="gw-tailscale" value="${gw.tailscale?.address || ''}" placeholder="例如 100.x.x.x:18789">
<div class="form-hint">如果你用 Tailscale 虚拟局域网,填上地址后远程设备就能通过它访问 Gateway。不用可以留空</div>
</div>
</div>
</div>
`
// 切换密码可见
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')

View File

@@ -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 `<option value="${a.id}">${a.id}${a.id !== label ? ' — ' + label : ''}</option>`
}).join('')
page.innerHTML = `
<div class="page-header">
<h1 class="page-title">记忆文件</h1>
<p class="page-desc">管理 OpenClaw 工作记忆和归档文件</p>
<div class="page-actions" style="display:flex;align-items:center;gap:var(--space-sm)">
<label style="font-size:var(--font-size-sm);color:var(--text-tertiary)">Agent:</label>
<select class="form-input" id="agent-select" style="width:auto;min-width:140px">${agentOptions}</select>
</div>
</div>
<div class="tab-bar">
${CATEGORIES.map((c, i) => `<div class="tab${i === 0 ? ' active' : ''}" data-tab="${c.key}">${c.label}</div>`).join('')}
@@ -49,7 +63,15 @@ export async function render() {
</div>
`
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 = '<div style="color:var(--text-tertiary);padding:12px">加载中...</div>'
try {
const files = await api.listMemoryFiles(state.category)
const files = await api.listMemoryFiles(state.category, state.agentId)
if (!files || !files.length) {
tree.innerHTML = '<div style="color:var(--text-tertiary);padding:12px">暂无文件</div>'
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 {

View File

@@ -235,14 +235,15 @@ function renderProviders(page, state) {
<div style="margin-left:auto;display:flex;gap:6px;align-items:center">
<span style="font-size:var(--font-size-xs);color:var(--text-tertiary)">排序:</span>
<select class="form-input" data-action="sort-models" style="padding:4px 8px;font-size:var(--font-size-xs);width:auto">
<option value="default" ${sortBy === 'default' ? 'selected' : ''}>默认顺序</option>
<option value="name-asc" ${sortBy === 'name-asc' ? 'selected' : ''}>名称 A-Z</option>
<option value="name-desc" ${sortBy === 'name-desc' ? 'selected' : ''}>名称 Z-A</option>
<option value="latency-asc" ${sortBy === 'latency-asc' ? 'selected' : ''}>延迟 低→高</option>
<option value="latency-desc" ${sortBy === 'latency-desc' ? 'selected' : ''}>延迟 高→低</option>
<option value="context-asc" ${sortBy === 'context-asc' ? 'selected' : ''}>上下文 小→大</option>
<option value="context-desc" ${sortBy === 'context-desc' ? 'selected' : ''}>上下文 大→小</option>
<option value="default">默认顺序 (拖拽调整)</option>
<option value="name-asc">名称 A-Z (固化到底层)</option>
<option value="name-desc">名称 Z-A (固化到底层)</option>
<option value="latency-asc">延迟 低→高 (固化到底层)</option>
<option value="latency-desc">延迟 高→低 (固化到底层)</option>
<option value="context-asc">上下文 小→大 (固化到底层)</option>
<option value="context-desc">上下文 大→小 (固化到底层)</option>
</select>
<button class="btn btn-sm btn-secondary" data-action="apply-sort" style="display:none">保存当前排序</button>
</div>
</div>` : ''}
<div class="provider-models">
@@ -285,8 +286,9 @@ function renderModelCards(providerKey, models, primary, search) {
const testTime = m.lastTestAt ? formatTestTime(m.lastTestAt) : ''
if (testTime) meta.push(testTime)
return `
<div class="model-card" data-model-id="${id}" data-full="${full}"
style="background:${bgColor};border:1px solid ${borderColor};padding:10px 14px;border-radius:var(--radius-md);margin-bottom:8px;display:flex;align-items:center;gap:10px">
<div class="model-card" data-model-id="${id}" data-full="${full}" draggable="true"
style="background:${bgColor};border:1px solid ${borderColor};padding:10px 14px;border-radius:var(--radius-md);margin-bottom:8px;display:flex;align-items:center;gap:10px;cursor:grab">
<span style="color:var(--text-tertiary);cursor:grab;user-select:none;font-size:16px;padding-right:4px">⋮⋮</span>
<input type="checkbox" class="model-checkbox" data-model-id="${id}" style="flex-shrink:0;cursor:pointer">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:8px">
@@ -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)
}
}

View File

@@ -33,6 +33,7 @@ export async function render() {
</div>
<div class="config-section" id="backup-section">
<div class="config-section-title">配置备份</div>
<div class="form-hint" style="margin-bottom:var(--space-sm)">备份范围openclaw.json 主配置文件含模型、Provider、Gateway 设置。Agent 数据和记忆文件不在此备份范围内。</div>
<div id="backup-actions" style="margin-bottom:var(--space-md)">
<button class="btn btn-primary btn-sm" data-action="create-backup">创建备份</button>
</div>

View File

@@ -39,6 +39,16 @@ async function loadRoute() {
}
_contentEl.innerHTML = ''
// 显示加载动画
const loader_el = document.createElement('div')
loader_el.className = 'page-loader'
loader_el.innerHTML = `
<div class="page-loader-spinner"></div>
<div class="page-loader-text">加载中...</div>
`
_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) {

66
src/style/agents.css Normal file
View File

@@ -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;
}

550
src/style/chat.css Normal file
View File

@@ -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);
}

View File

@@ -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); }
}

View File

@@ -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;

View File

@@ -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);
}