mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-07-21 12:42:53 +08:00
feat: 聊天页面 + Agent 管理 + 模型排序固化 + 多项增强
- 新增聊天页面(WebSocket 实时通信、会话管理、Markdown 渲染) - 新增 Agent 管理页面(CRUD、备份、工作区配置、模型选择) - 新增设备认证模块(Ed25519 签名) - 模型排序固化到配置文件(拖拽排序 + 下拉排序均写入底层数据) - 记忆文件页面支持 Agent 切换 - Gateway 配置页重构为选项卡片布局 - 页面路由加载动画 - 批量测试支持终止、实时刷新卡片 - 服务管理备份说明文案 - Modal 组件支持 readonly 字段 - 脱敏 mock 数据中的内部地址
This commit is contained in:
113
src-tauri/Cargo.lock
generated
113
src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
180
src-tauri/src/commands/agent.rs
Normal file
180
src-tauri/src/commands/agent.rs
Normal 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(())
|
||||
}
|
||||
133
src-tauri/src/commands/device.rs
Normal file
133
src-tauri/src/commands/device.rs
Normal 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)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 失败");
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 关闭
|
||||
|
||||
@@ -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
136
src/lib/markdown.js
Normal 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;))*?"|'(?:[^&]|&(?!#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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
export function renderMarkdown(text) {
|
||||
if (!text) return ''
|
||||
let html = text
|
||||
|
||||
// 代码块
|
||||
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
|
||||
const highlighted = highlightCode(code.trimEnd(), lang)
|
||||
const langLabel = lang ? `<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
112
src/lib/message-db.js
Normal 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 }
|
||||
}
|
||||
@@ -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
348
src/lib/ws-client.js
Normal 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()
|
||||
@@ -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
238
src/pages/agents.js
Normal 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
730
src/pages/chat.js
Normal 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 || ''
|
||||
// 忽略空 final(Gateway 会为一条消息触发多个 run,部分是空 final)
|
||||
if (!_currentAiBubble && !finalText) return
|
||||
showTyping(false)
|
||||
// 如果流式阶段没有创建 bubble,从 final message 中提取
|
||||
if (!_currentAiBubble && finalText) {
|
||||
_currentAiBubble = createStreamBubble()
|
||||
_currentAiText = finalText
|
||||
}
|
||||
if (_currentAiBubble && _currentAiText) {
|
||||
_currentAiBubble.innerHTML = renderMarkdown(_currentAiText)
|
||||
}
|
||||
if (_currentAiText) {
|
||||
saveMessage({ id: payload.runId || uuid(), sessionKey: _sessionKey, role: 'assistant', content: _currentAiText, timestamp: Date.now() })
|
||||
}
|
||||
resetStreamState()
|
||||
processMessageQueue()
|
||||
return
|
||||
}
|
||||
|
||||
if (state === 'aborted') {
|
||||
showTyping(false)
|
||||
if (_currentAiBubble && _currentAiText) {
|
||||
_currentAiBubble.innerHTML = renderMarkdown(_currentAiText)
|
||||
}
|
||||
appendSystemMessage('生成已停止')
|
||||
resetStreamState()
|
||||
processMessageQueue()
|
||||
return
|
||||
}
|
||||
|
||||
if (state === 'error') {
|
||||
const errMsg = payload.errorMessage || payload.error?.message || '未知错误'
|
||||
if (_isStreaming) {
|
||||
console.warn('[chat] 流式中临时错误,等待重试:', errMsg)
|
||||
return
|
||||
}
|
||||
showTyping(false)
|
||||
appendSystemMessage('错误: ' + errMsg)
|
||||
resetStreamState()
|
||||
processMessageQueue()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 Gateway message 对象提取文本(参照 clawapp extractContent) */
|
||||
function extractChatContent(message) {
|
||||
if (!message || typeof message !== 'object') return null
|
||||
const content = message.content
|
||||
if (typeof content === 'string') return { text: content }
|
||||
if (Array.isArray(content)) {
|
||||
const texts = []
|
||||
for (const block of content) {
|
||||
if (block.type === 'text' && typeof block.text === 'string') texts.push(block.text)
|
||||
}
|
||||
const text = texts.length ? texts.join('\n') : ''
|
||||
if (text) return { text }
|
||||
}
|
||||
if (typeof message.text === 'string') return { text: message.text }
|
||||
return null
|
||||
}
|
||||
|
||||
/** 创建流式 AI 气泡 */
|
||||
function createStreamBubble() {
|
||||
showTyping(false)
|
||||
const wrap = document.createElement('div')
|
||||
wrap.className = 'msg msg-ai'
|
||||
const bubble = document.createElement('div')
|
||||
bubble.className = 'msg-bubble'
|
||||
bubble.innerHTML = '<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 = ''
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
66
src/style/agents.css
Normal 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
550
src/style/chat.css
Normal 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);
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user