mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-06 20:02:49 +08:00
fix: 修复多项关键 Bug,与 openclaw 上游协议对齐
- main.js: wsClient.connect 传参格式错误(完整 ws:// URL → host:port) - ws-client.js: request() 等待重连时不处理 onReady 握手失败 - gateway.js: bind 写入非法值 'all',改为 openclaw 合法值 'lan' - device.rs: connect payload 从 v2 升级到 v3,补充 platform/deviceFamily - config.rs: macOS reload_gateway 在 async fn 中用同步 Command 阻塞 tokio - service.rs: Windows check_service_status 端口硬编码 18789,改为读配置 - extensions.rs: parse_cftunnel_status 全角冒号解析失败,添加 split_after_colon - tauri-api.js: cachedInvoke miss 时 logRequest 被记录两次 - tauri-api.js: mock 补充 list_agents / restart_gateway - chat.js: 附件对象冗余 data 字段(双倍内存)+ 缩进修复 - services.js: 服务操作缺少操作中 toast 反馈
This commit is contained in:
@@ -2,14 +2,15 @@
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use crate::utils::openclaw_command;
|
||||
use crate::utils::openclaw_command_async;
|
||||
|
||||
/// 获取 agent 列表
|
||||
#[tauri::command]
|
||||
pub fn list_agents() -> Result<Value, String> {
|
||||
let output = openclaw_command()
|
||||
pub async fn list_agents() -> Result<Value, String> {
|
||||
let output = openclaw_command_async()
|
||||
.args(["agents", "list", "--json"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("执行失败: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
@@ -24,7 +25,7 @@ pub fn list_agents() -> Result<Value, String> {
|
||||
|
||||
/// 创建新 agent
|
||||
#[tauri::command]
|
||||
pub fn add_agent(name: String, model: String, workspace: Option<String>) -> Result<Value, String> {
|
||||
pub async 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()
|
||||
@@ -48,9 +49,10 @@ pub fn add_agent(name: String, model: String, workspace: Option<String>) -> Resu
|
||||
args.push(model);
|
||||
}
|
||||
|
||||
let output = openclaw_command()
|
||||
let output = openclaw_command_async()
|
||||
.args(&args)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("执行失败: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
@@ -61,19 +63,20 @@ pub fn add_agent(name: String, model: String, workspace: Option<String>) -> Resu
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
serde_json::from_str(&stdout).unwrap_or(Value::String("ok".into()));
|
||||
// 返回最新列表
|
||||
list_agents()
|
||||
list_agents().await
|
||||
}
|
||||
|
||||
/// 删除 agent
|
||||
#[tauri::command]
|
||||
pub fn delete_agent(id: String) -> Result<String, String> {
|
||||
pub async fn delete_agent(id: String) -> Result<String, String> {
|
||||
if id == "main" {
|
||||
return Err("不能删除默认 Agent".into());
|
||||
}
|
||||
|
||||
let output = openclaw_command()
|
||||
let output = openclaw_command_async()
|
||||
.args(["agents", "delete", &id])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("执行失败: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
@@ -91,35 +94,69 @@ pub fn update_agent_identity(
|
||||
name: Option<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(),
|
||||
];
|
||||
let path = super::openclaw_dir().join("openclaw.json");
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|e| format!("读取配置失败: {e}"))?;
|
||||
let mut config: Value = serde_json::from_str(&content)
|
||||
.map_err(|e| format!("解析 JSON 失败: {e}"))?;
|
||||
|
||||
let agents_list = config
|
||||
.get_mut("agents")
|
||||
.and_then(|a| a.get_mut("list"))
|
||||
.and_then(|l| l.as_array_mut())
|
||||
.ok_or("配置格式错误")?;
|
||||
|
||||
let agent = agents_list
|
||||
.iter_mut()
|
||||
.find(|a| a.get("id").and_then(|v| v.as_str()) == Some(&id))
|
||||
.ok_or(format!("Agent「{id}」不存在"))?;
|
||||
|
||||
// 确保 identity 字段存在且为对象
|
||||
if !agent.get("identity").and_then(|i| i.as_object()).is_some() {
|
||||
agent.as_object_mut()
|
||||
.ok_or("Agent 格式错误")?
|
||||
.insert("identity".to_string(), serde_json::json!({}));
|
||||
}
|
||||
|
||||
let identity = agent
|
||||
.get_mut("identity")
|
||||
.and_then(|i| i.as_object_mut())
|
||||
.ok_or("identity 格式错误")?;
|
||||
|
||||
if let Some(n) = name {
|
||||
if !n.is_empty() {
|
||||
args.push("--name".to_string());
|
||||
args.push(n);
|
||||
identity.insert("name".to_string(), Value::String(n));
|
||||
}
|
||||
}
|
||||
if let Some(e) = emoji {
|
||||
if !e.is_empty() {
|
||||
args.push("--emoji".to_string());
|
||||
args.push(e);
|
||||
identity.insert("emoji".to_string(), Value::String(e));
|
||||
}
|
||||
}
|
||||
|
||||
let output = openclaw_command()
|
||||
.args(&args)
|
||||
.output()
|
||||
.map_err(|e| format!("执行失败: {e}"))?;
|
||||
// 提前提取 workspace 路径(克隆为 String,避免借用冲突)
|
||||
let workspace_path = agent.get("workspace")
|
||||
.and_then(|w| w.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
config.get("agents")
|
||||
.and_then(|a| a.get("defaults"))
|
||||
.and_then(|d| d.get("workspace"))
|
||||
.and_then(|w| w.as_str())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("更新失败: {stderr}"));
|
||||
let json = serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| format!("序列化失败: {e}"))?;
|
||||
fs::write(&path, json)
|
||||
.map_err(|e| format!("写入配置失败: {e}"))?;
|
||||
|
||||
// 删除 IDENTITY.md 文件,让配置文件生效
|
||||
if let Some(ws_str) = workspace_path {
|
||||
let identity_file = std::path::PathBuf::from(ws_str).join("IDENTITY.md");
|
||||
if identity_file.exists() {
|
||||
let _ = fs::remove_file(&identity_file);
|
||||
}
|
||||
}
|
||||
|
||||
Ok("已更新".into())
|
||||
@@ -178,3 +215,36 @@ fn collect_dir_to_zip(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新 agent 模型配置
|
||||
#[tauri::command]
|
||||
pub fn update_agent_model(id: String, model: String) -> Result<String, String> {
|
||||
let path = super::openclaw_dir().join("openclaw.json");
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|e| format!("读取配置失败: {e}"))?;
|
||||
let mut config: Value = serde_json::from_str(&content)
|
||||
.map_err(|e| format!("解析 JSON 失败: {e}"))?;
|
||||
|
||||
let agents_list = config
|
||||
.get_mut("agents")
|
||||
.and_then(|a| a.get_mut("list"))
|
||||
.and_then(|l| l.as_array_mut())
|
||||
.ok_or("配置格式错误")?;
|
||||
|
||||
let agent = agents_list
|
||||
.iter_mut()
|
||||
.find(|a| a.get("id").and_then(|v| v.as_str()) == Some(&id))
|
||||
.ok_or(format!("Agent「{id}」不存在"))?;
|
||||
|
||||
let model_obj = serde_json::json!({ "primary": model });
|
||||
agent.as_object_mut()
|
||||
.ok_or("Agent 格式错误")?
|
||||
.insert("model".to_string(), model_obj);
|
||||
|
||||
let json = serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| format!("序列化失败: {e}"))?;
|
||||
fs::write(&path, json)
|
||||
.map_err(|e| format!("写入配置失败: {e}"))?;
|
||||
|
||||
Ok("已更新".into())
|
||||
}
|
||||
|
||||
@@ -51,8 +51,21 @@ pub fn read_openclaw_config() -> Result<Value, String> {
|
||||
let path = super::openclaw_dir().join("openclaw.json");
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|e| format!("读取配置失败: {e}"))?;
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| format!("解析 JSON 失败: {e}"))
|
||||
let mut config: Value = serde_json::from_str(&content)
|
||||
.map_err(|e| format!("解析 JSON 失败: {e}"))?;
|
||||
|
||||
// 自动清理 UI 专属字段,防止污染配置导致 CLI 启动失败
|
||||
if has_ui_fields(&config) {
|
||||
config = strip_ui_fields(config);
|
||||
// 静默写回清理后的配置
|
||||
let bak = super::openclaw_dir().join("openclaw.json.bak");
|
||||
let _ = fs::copy(&path, &bak);
|
||||
let json = serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| format!("序列化失败: {e}"))?;
|
||||
let _ = fs::write(&path, json);
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -70,27 +83,61 @@ pub fn write_openclaw_config(config: Value) -> Result<(), String> {
|
||||
.map_err(|e| format!("写入失败: {e}"))
|
||||
}
|
||||
|
||||
/// 递归清理 models 数组中的 UI 专属字段(lastTestAt, latency, testStatus, testError)
|
||||
/// 并为缺少 name 字段的模型自动补上 name = id
|
||||
fn strip_ui_fields(mut val: Value) -> Value {
|
||||
if let Some(obj) = val.as_object_mut() {
|
||||
// 递归处理 providers -> xxx -> models 数组
|
||||
if let Some(models) = obj.get("models") {
|
||||
if let Some(providers) = models.as_object() {
|
||||
let mut new_models = providers.clone();
|
||||
for (_key, provider) in new_models.iter_mut() {
|
||||
if let Some(pobj) = provider.as_object_mut() {
|
||||
if let Some(Value::Array(arr)) = pobj.get_mut("models") {
|
||||
for model in arr.iter_mut() {
|
||||
if let Some(mobj) = model.as_object_mut() {
|
||||
mobj.remove("lastTestAt");
|
||||
mobj.remove("latency");
|
||||
mobj.remove("testStatus");
|
||||
mobj.remove("testError");
|
||||
// 补上 name 字段(CLI 要求)
|
||||
if !mobj.contains_key("name") {
|
||||
if let Some(id) = mobj.get("id").and_then(|v| v.as_str()) {
|
||||
mobj.insert("name".into(), Value::String(id.to_string()));
|
||||
/// 检测配置中是否包含 UI 专属字段
|
||||
fn has_ui_fields(val: &Value) -> bool {
|
||||
if let Some(obj) = val.as_object() {
|
||||
if let Some(models_val) = obj.get("models") {
|
||||
if let Some(models_obj) = models_val.as_object() {
|
||||
if let Some(providers_val) = models_obj.get("providers") {
|
||||
if let Some(providers_obj) = providers_val.as_object() {
|
||||
for (_provider_name, provider_val) in providers_obj.iter() {
|
||||
if let Some(provider_obj) = provider_val.as_object() {
|
||||
if let Some(Value::Array(arr)) = provider_obj.get("models") {
|
||||
for model in arr.iter() {
|
||||
if let Some(mobj) = model.as_object() {
|
||||
if mobj.contains_key("lastTestAt")
|
||||
|| mobj.contains_key("latency")
|
||||
|| mobj.contains_key("testStatus")
|
||||
|| mobj.contains_key("testError")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 递归清理 models 数组中的 UI 专属字段(lastTestAt, latency, testStatus, testError)
|
||||
/// 并为缺少 name 字段的模型自动补上 name = id
|
||||
fn strip_ui_fields(mut val: Value) -> Value {
|
||||
if let Some(obj) = val.as_object_mut() {
|
||||
// 处理 models.providers.xxx.models 结构
|
||||
if let Some(models_val) = obj.get_mut("models") {
|
||||
if let Some(models_obj) = models_val.as_object_mut() {
|
||||
if let Some(providers_val) = models_obj.get_mut("providers") {
|
||||
if let Some(providers_obj) = providers_val.as_object_mut() {
|
||||
for (_provider_name, provider_val) in providers_obj.iter_mut() {
|
||||
if let Some(provider_obj) = provider_val.as_object_mut() {
|
||||
if let Some(Value::Array(arr)) = provider_obj.get_mut("models") {
|
||||
for model in arr.iter_mut() {
|
||||
if let Some(mobj) = model.as_object_mut() {
|
||||
mobj.remove("lastTestAt");
|
||||
mobj.remove("latency");
|
||||
mobj.remove("testStatus");
|
||||
mobj.remove("testError");
|
||||
if !mobj.contains_key("name") {
|
||||
if let Some(id) = mobj.get("id").and_then(|v| v.as_str()) {
|
||||
mobj.insert("name".into(), Value::String(id.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,7 +145,6 @@ fn strip_ui_fields(mut val: Value) -> Value {
|
||||
}
|
||||
}
|
||||
}
|
||||
obj.insert("models".into(), Value::Object(new_models));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,10 +172,10 @@ pub fn write_mcp_config(config: Value) -> Result<(), String> {
|
||||
.map_err(|e| format!("写入失败: {e}"))
|
||||
}
|
||||
|
||||
/// 获取本地安装的 openclaw 版本号
|
||||
/// 获取本地安装的 openclaw 版本号(异步版本)
|
||||
/// macOS: 优先从 npm 包的 package.json 读取(含完整后缀),fallback 到 CLI
|
||||
/// Windows/Linux: 直接用 CLI
|
||||
fn get_local_version() -> Option<String> {
|
||||
/// Windows/Linux: 优先读文件系统,fallback 到 CLI
|
||||
async fn get_local_version() -> Option<String> {
|
||||
// macOS: 通过 symlink 找到包目录,读 package.json 的 version
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
@@ -167,8 +213,9 @@ fn get_local_version() -> Option<String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 所有平台通用 fallback: CLI 输出
|
||||
let output = openclaw_command().arg("--version").output().ok()?;
|
||||
// 所有平台通用 fallback: CLI 输出(异步)
|
||||
use crate::utils::openclaw_command_async;
|
||||
let output = openclaw_command_async().arg("--version").output().await.ok()?;
|
||||
let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
raw.split_whitespace().last().filter(|s| !s.is_empty()).map(String::from)
|
||||
}
|
||||
@@ -236,7 +283,7 @@ fn detect_installed_source() -> String {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_version_info() -> Result<VersionInfo, String> {
|
||||
let current = get_local_version();
|
||||
let current = get_local_version().await;
|
||||
let source = detect_installed_source();
|
||||
let latest = get_latest_version_for(&source).await;
|
||||
let parse_ver = |v: &str| -> Vec<u32> {
|
||||
@@ -272,7 +319,8 @@ pub async fn upgrade_openclaw(app: tauri::AppHandle, source: String) -> Result<S
|
||||
use tauri::Emitter;
|
||||
|
||||
let current_source = detect_installed_source();
|
||||
let pkg = format!("{}@latest", npm_package_name(&source));
|
||||
let pkg_name = npm_package_name(&source);
|
||||
let pkg = format!("{}@latest", pkg_name);
|
||||
|
||||
// 切换源时,或者未安装时(检测 source 和 target,或者目前未安装)
|
||||
// 如果系统里已经安装了别的源,先卸载
|
||||
@@ -289,8 +337,22 @@ pub async fn upgrade_openclaw(app: tauri::AppHandle, source: String) -> Result<S
|
||||
let _ = app.emit("upgrade-log", format!("$ npm install -g {pkg}"));
|
||||
let _ = app.emit("upgrade-progress", 10);
|
||||
|
||||
// 汉化版只支持官方源和淘宝源
|
||||
let configured_registry = get_configured_registry();
|
||||
let registry = if pkg_name.contains("openclaw-zh") {
|
||||
// 汉化版:淘宝源或官方源
|
||||
if configured_registry.contains("npmmirror.com") || configured_registry.contains("taobao.org") {
|
||||
configured_registry.as_str()
|
||||
} else {
|
||||
"https://registry.npmjs.org"
|
||||
}
|
||||
} else {
|
||||
// 官方版:使用用户配置的镜像源
|
||||
configured_registry.as_str()
|
||||
};
|
||||
|
||||
let mut child = npm_command()
|
||||
.args(["install", "-g", &pkg, "--verbose"])
|
||||
.args(["install", "-g", &pkg, "--registry", registry, "--verbose"])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
@@ -349,9 +411,11 @@ pub async fn upgrade_openclaw(app: tauri::AppHandle, source: String) -> Result<S
|
||||
.output();
|
||||
}
|
||||
// 重新安装
|
||||
let gw_out = openclaw_command()
|
||||
use crate::utils::openclaw_command_async;
|
||||
let gw_out = openclaw_command_async()
|
||||
.args(["gateway", "install"])
|
||||
.output();
|
||||
.output()
|
||||
.await;
|
||||
match gw_out {
|
||||
Ok(o) if o.status.success() => {
|
||||
let _ = app.emit("upgrade-log", "Gateway 服务已重装");
|
||||
@@ -362,7 +426,7 @@ pub async fn upgrade_openclaw(app: tauri::AppHandle, source: String) -> Result<S
|
||||
}
|
||||
}
|
||||
|
||||
let new_ver = get_local_version().unwrap_or_else(|| "未知".into());
|
||||
let new_ver = get_local_version().await.unwrap_or_else(|| "未知".into());
|
||||
let msg = format!("✅ 升级成功,当前版本: {new_ver}");
|
||||
let _ = app.emit("upgrade-log", &msg);
|
||||
Ok(msg)
|
||||
@@ -554,14 +618,15 @@ fn get_uid() -> Result<u32, String> {
|
||||
/// macOS: launchctl kickstart -k
|
||||
/// Windows/Linux: openclaw gateway restart
|
||||
#[tauri::command]
|
||||
pub fn reload_gateway() -> Result<String, String> {
|
||||
pub async fn reload_gateway() -> Result<String, String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let uid = get_uid()?;
|
||||
let target = format!("gui/{uid}/ai.openclaw.gateway");
|
||||
let output = Command::new("launchctl")
|
||||
let output = tokio::process::Command::new("launchctl")
|
||||
.args(["kickstart", "-k", &target])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("重载失败: {e}"))?;
|
||||
if output.status.success() {
|
||||
Ok("Gateway 已重载".to_string())
|
||||
@@ -572,14 +637,16 @@ pub fn reload_gateway() -> Result<String, String> {
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let cli_check = openclaw_command().arg("--version").output();
|
||||
use crate::utils::openclaw_command_async;
|
||||
let cli_check = openclaw_command_async().arg("--version").output().await;
|
||||
match cli_check {
|
||||
Ok(o) if o.status.success() => {}
|
||||
_ => return Err("openclaw CLI 未安装,无法重载 Gateway".into()),
|
||||
}
|
||||
let output = openclaw_command()
|
||||
let output = openclaw_command_async()
|
||||
.args(["gateway", "restart"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("重载失败: {e}"))?;
|
||||
if output.status.success() {
|
||||
Ok("Gateway 已重载".to_string())
|
||||
@@ -590,6 +657,13 @@ pub fn reload_gateway() -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 重启 Gateway 服务(与 reload_gateway 相同实现)
|
||||
#[tauri::command]
|
||||
pub async fn restart_gateway() -> Result<String, String> {
|
||||
reload_gateway().await
|
||||
}
|
||||
|
||||
|
||||
/// 测试模型连通性:向 provider 发送一个简单的 chat completion 请求
|
||||
#[tauri::command]
|
||||
pub async fn test_model(
|
||||
@@ -730,9 +804,10 @@ pub async fn list_remote_models(
|
||||
|
||||
/// 安装 Gateway 服务(执行 openclaw gateway install)
|
||||
#[tauri::command]
|
||||
pub fn install_gateway() -> Result<String, String> {
|
||||
pub async fn install_gateway() -> Result<String, String> {
|
||||
use crate::utils::openclaw_command_async;
|
||||
// 先检测 openclaw CLI 是否可用
|
||||
let cli_check = openclaw_command().arg("--version").output();
|
||||
let cli_check = openclaw_command_async().arg("--version").output().await;
|
||||
match cli_check {
|
||||
Ok(o) if o.status.success() => {}
|
||||
_ => {
|
||||
@@ -744,9 +819,10 @@ pub fn install_gateway() -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
let output = openclaw_command()
|
||||
let output = openclaw_command_async()
|
||||
.args(["gateway", "install"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("安装失败: {e}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
|
||||
@@ -92,9 +92,13 @@ pub fn create_connect_frame(nonce: String, gateway_token: String) -> Result<Valu
|
||||
.unwrap()
|
||||
.as_millis();
|
||||
|
||||
let platform = std::env::consts::OS; // "windows" | "macos" | "linux"
|
||||
let device_family = "desktop";
|
||||
|
||||
let scopes_str = SCOPES.join(",");
|
||||
// v3 格式:v3|deviceId|clientId|clientMode|role|scopes|signedAt|token|nonce|platform|deviceFamily
|
||||
let payload_str = format!(
|
||||
"v2|{device_id}|gateway-client|backend|operator|{scopes_str}|{signed_at}|{gateway_token}|{nonce}"
|
||||
"v3|{device_id}|gateway-client|backend|operator|{scopes_str}|{signed_at}|{gateway_token}|{nonce}|{platform}|{device_family}"
|
||||
);
|
||||
|
||||
let signature = signing_key.sign(payload_str.as_bytes());
|
||||
@@ -110,7 +114,8 @@ pub fn create_connect_frame(nonce: String, gateway_token: String) -> Result<Valu
|
||||
"client": {
|
||||
"id": "gateway-client",
|
||||
"version": "1.0.0",
|
||||
"platform": "desktop",
|
||||
"platform": platform,
|
||||
"deviceFamily": device_family,
|
||||
"mode": "backend"
|
||||
},
|
||||
"role": "operator",
|
||||
|
||||
@@ -4,20 +4,35 @@ use std::process::Command;
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
/// 按第一个冒号(半角 ':' 或全角 ':')分割,返回冒号之后的内容(trim 后)
|
||||
fn split_after_colon(s: &str) -> &str {
|
||||
// 半角冒号是 1 字节,全角冒号是 3 字节(UTF-8: \xef\xbc\x9a)
|
||||
if let Some(pos) = s.find(':') {
|
||||
return s[pos + 1..].trim();
|
||||
}
|
||||
if let Some(pos) = s.find(':') {
|
||||
return s[pos + ':'.len_utf8()..].trim();
|
||||
}
|
||||
""
|
||||
}
|
||||
|
||||
/// 解析 cftunnel status 输出
|
||||
fn parse_cftunnel_status(output: &str) -> serde_json::Map<String, Value> {
|
||||
let mut map = serde_json::Map::new();
|
||||
for line in output.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with("隧道:") || line.starts_with("隧道:") {
|
||||
let rest = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
let rest = split_after_colon(line);
|
||||
let name = rest.split('(').next().unwrap_or(rest).trim();
|
||||
map.insert("tunnel_name".into(), Value::String(name.to_string()));
|
||||
} else if line.starts_with("状态:") || line.starts_with("状态:") {
|
||||
let rest = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
let rest = split_after_colon(line);
|
||||
let running = rest.contains("运行中");
|
||||
map.insert("running".into(), Value::Bool(running));
|
||||
if let Some(pid_str) = rest.split("PID:").nth(1) {
|
||||
// 匹配英文和全角 'PID:' / 'PID:'
|
||||
let pid_rest = rest.split("PID:").nth(1)
|
||||
.or_else(|| rest.split("PID:").nth(1));
|
||||
if let Some(pid_str) = pid_rest {
|
||||
let pid = pid_str.trim().trim_end_matches(')').trim();
|
||||
if let Ok(p) = pid.parse::<u64>() {
|
||||
map.insert("pid".into(), Value::Number(p.into()));
|
||||
@@ -228,6 +243,10 @@ pub fn get_cftunnel_logs(lines: Option<u32>) -> Result<String, String> {
|
||||
pub fn get_clawapp_status() -> Result<Value, String> {
|
||||
let mut result = serde_json::Map::new();
|
||||
|
||||
// 检测是否已安装(检查 npm 全局包)
|
||||
let installed = check_clawapp_installed();
|
||||
result.insert("installed".into(), Value::Bool(installed));
|
||||
|
||||
// 跨平台方式:尝试连接端口检测是否在运行
|
||||
let running = std::net::TcpStream::connect_timeout(
|
||||
&"127.0.0.1:3210".parse().unwrap(),
|
||||
@@ -259,6 +278,30 @@ pub fn get_clawapp_status() -> Result<Value, String> {
|
||||
Ok(Value::Object(result))
|
||||
}
|
||||
|
||||
/// 检测 ClawApp 是否已安装
|
||||
fn check_clawapp_installed() -> bool {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let mut cmd = Command::new("cmd");
|
||||
cmd.args(["/c", "npm", "list", "-g", "clawapp"]);
|
||||
cmd.creation_flags(0x08000000);
|
||||
if let Ok(out) = cmd.output() {
|
||||
return out.status.success();
|
||||
}
|
||||
false
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
if let Ok(out) = Command::new("npm")
|
||||
.args(["list", "-g", "clawapp"])
|
||||
.output()
|
||||
{
|
||||
return out.status.success();
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 一键安装 cftunnel
|
||||
/// macOS/Linux: bash 脚本安装
|
||||
/// Windows: PowerShell 下载安装
|
||||
@@ -358,3 +401,75 @@ Write-Output '安装完成'
|
||||
let _ = app.emit("install-log", "✅ cftunnel 安装成功");
|
||||
Ok("安装成功".into())
|
||||
}
|
||||
|
||||
/// 一键安装 ClawApp(通过 npm)
|
||||
#[tauri::command]
|
||||
pub async fn install_clawapp(app: tauri::AppHandle) -> Result<String, String> {
|
||||
use std::process::Stdio;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use tauri::Emitter;
|
||||
|
||||
let _ = app.emit("install-log", "开始安装 ClawApp...");
|
||||
let _ = app.emit("install-progress", 10);
|
||||
|
||||
let _ = app.emit("install-log", "通过 npm 安装 clawapp...");
|
||||
let _ = app.emit("install-progress", 30);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let mut child = {
|
||||
let mut cmd = Command::new("cmd");
|
||||
cmd.args(["/c", "npm", "install", "-g", "clawapp"]);
|
||||
cmd.creation_flags(0x08000000);
|
||||
cmd.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动安装进程失败: {e}"))?
|
||||
};
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let mut child = {
|
||||
Command::new("npm")
|
||||
.args(["install", "-g", "clawapp"])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动安装进程失败: {e}"))?
|
||||
};
|
||||
|
||||
let stderr = child.stderr.take();
|
||||
let stdout = child.stdout.take();
|
||||
|
||||
let app2 = app.clone();
|
||||
let handle = std::thread::spawn(move || {
|
||||
if let Some(pipe) = stderr {
|
||||
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
|
||||
let _ = app2.emit("install-log", &line);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut progress = 40;
|
||||
if let Some(pipe) = stdout {
|
||||
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
|
||||
let _ = app.emit("install-log", &line);
|
||||
if progress < 90 {
|
||||
progress += 5;
|
||||
let _ = app.emit("install-progress", progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = handle.join();
|
||||
let _ = app.emit("install-progress", 95);
|
||||
|
||||
let status = child.wait().map_err(|e| format!("等待安装进程失败: {e}"))?;
|
||||
let _ = app.emit("install-progress", 100);
|
||||
|
||||
if !status.success() {
|
||||
let _ = app.emit("install-log", "❌ 安装失败");
|
||||
return Err("安装失败,请查看日志".into());
|
||||
}
|
||||
|
||||
let _ = app.emit("install-log", "✅ ClawApp 安装成功");
|
||||
Ok("安装成功".into())
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use crate::utils::openclaw_command;
|
||||
use crate::utils::openclaw_command_async;
|
||||
|
||||
/// 检查路径是否包含不安全字符(目录遍历、绝对路径等)
|
||||
fn is_unsafe_path(path: &str) -> bool {
|
||||
@@ -13,12 +13,13 @@ fn is_unsafe_path(path: &str) -> bool {
|
||||
|| (path.len() >= 2 && path.as_bytes()[1] == b':') // Windows 绝对路径 C:\
|
||||
}
|
||||
|
||||
/// 根据 agent_id 获取 workspace 路径
|
||||
/// 根据 agent_id 获取 workspace 路径(异步版本)
|
||||
/// 调用 openclaw agents list --json 解析
|
||||
fn agent_workspace(agent_id: &str) -> Result<PathBuf, String> {
|
||||
let output = openclaw_command()
|
||||
async fn agent_workspace(agent_id: &str) -> Result<PathBuf, String> {
|
||||
let output = openclaw_command_async()
|
||||
.args(["agents", "list", "--json"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("执行 openclaw 失败: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
@@ -42,8 +43,8 @@ fn agent_workspace(agent_id: &str) -> Result<PathBuf, String> {
|
||||
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)?;
|
||||
async fn memory_dir_for_agent(agent_id: &str, category: &str) -> Result<PathBuf, String> {
|
||||
let ws = agent_workspace(agent_id).await?;
|
||||
Ok(match category {
|
||||
"memory" => ws.join("memory"),
|
||||
"archive" => {
|
||||
@@ -62,9 +63,9 @@ fn memory_dir_for_agent(agent_id: &str, category: &str) -> Result<PathBuf, Strin
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_memory_files(category: String, agent_id: Option<String>) -> Result<Vec<String>, String> {
|
||||
pub async 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)?;
|
||||
let dir = memory_dir_for_agent(aid, &category).await?;
|
||||
if !dir.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
@@ -105,16 +106,16 @@ fn collect_files(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn read_memory_file(path: String, agent_id: Option<String>) -> Result<String, String> {
|
||||
pub async fn read_memory_file(path: String, agent_id: Option<String>) -> Result<String, String> {
|
||||
if is_unsafe_path(&path) {
|
||||
return Err("非法路径".to_string());
|
||||
}
|
||||
|
||||
let aid = agent_id.as_deref().unwrap_or("main");
|
||||
let candidates = [
|
||||
memory_dir_for_agent(aid, "memory"),
|
||||
memory_dir_for_agent(aid, "archive"),
|
||||
memory_dir_for_agent(aid, "core"),
|
||||
memory_dir_for_agent(aid, "memory").await,
|
||||
memory_dir_for_agent(aid, "archive").await,
|
||||
memory_dir_for_agent(aid, "core").await,
|
||||
];
|
||||
|
||||
for c in &candidates {
|
||||
@@ -131,14 +132,14 @@ pub fn read_memory_file(path: String, agent_id: Option<String>) -> Result<String
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn write_memory_file(path: String, content: String, category: Option<String>, agent_id: Option<String>) -> Result<(), String> {
|
||||
pub async fn write_memory_file(path: String, content: String, category: Option<String>, agent_id: Option<String>) -> Result<(), String> {
|
||||
if is_unsafe_path(&path) {
|
||||
return Err("非法路径".to_string());
|
||||
}
|
||||
|
||||
let aid = agent_id.as_deref().unwrap_or("main");
|
||||
let cat = category.unwrap_or_else(|| "memory".to_string());
|
||||
let base = memory_dir_for_agent(aid, &cat)?;
|
||||
let base = memory_dir_for_agent(aid, &cat).await?;
|
||||
|
||||
let full_path = base.join(&path);
|
||||
if let Some(parent) = full_path.parent() {
|
||||
@@ -148,16 +149,16 @@ pub fn write_memory_file(path: String, content: String, category: Option<String>
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_memory_file(path: String, agent_id: Option<String>) -> Result<(), String> {
|
||||
pub async fn delete_memory_file(path: String, agent_id: Option<String>) -> Result<(), String> {
|
||||
if is_unsafe_path(&path) {
|
||||
return Err("非法路径".to_string());
|
||||
}
|
||||
|
||||
let aid = agent_id.as_deref().unwrap_or("main");
|
||||
let candidates = [
|
||||
memory_dir_for_agent(aid, "memory"),
|
||||
memory_dir_for_agent(aid, "archive"),
|
||||
memory_dir_for_agent(aid, "core"),
|
||||
memory_dir_for_agent(aid, "memory").await,
|
||||
memory_dir_for_agent(aid, "archive").await,
|
||||
memory_dir_for_agent(aid, "core").await,
|
||||
];
|
||||
|
||||
for c in &candidates {
|
||||
@@ -174,9 +175,9 @@ pub fn delete_memory_file(path: String, agent_id: Option<String>) -> Result<(),
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn export_memory_zip(category: String, agent_id: Option<String>) -> Result<String, String> {
|
||||
pub async 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)?;
|
||||
let dir = memory_dir_for_agent(aid, &category).await?;
|
||||
if !dir.exists() {
|
||||
return Err("目录不存在".to_string());
|
||||
}
|
||||
|
||||
@@ -211,6 +211,8 @@ mod platform {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod platform {
|
||||
use tokio::process::Command as TokioCommand;
|
||||
|
||||
/// Windows 不需要 UID
|
||||
pub fn current_uid() -> Result<u32, String> {
|
||||
Ok(0)
|
||||
@@ -230,10 +232,27 @@ mod platform {
|
||||
vec!["ai.openclaw.gateway".to_string()]
|
||||
}
|
||||
|
||||
/// 从 openclaw.json 读取 gateway 端口,fallback 到 18789
|
||||
fn read_gateway_port() -> u16 {
|
||||
let config_path = crate::commands::openclaw_dir().join("openclaw.json");
|
||||
if let Ok(content) = std::fs::read_to_string(&config_path) {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
if let Some(port) = val.get("gateway").and_then(|g| g.get("port")).and_then(|p| p.as_u64()) {
|
||||
if port > 0 && port < 65536 {
|
||||
return port as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18789
|
||||
}
|
||||
|
||||
/// 通过端口探测检测 Gateway 状态
|
||||
pub fn check_service_status(_uid: u32, _label: &str) -> (bool, Option<u32>) {
|
||||
let port = read_gateway_port();
|
||||
let addr = format!("127.0.0.1:{port}");
|
||||
match std::net::TcpStream::connect_timeout(
|
||||
&"127.0.0.1:18789".parse().unwrap(),
|
||||
&addr.parse().unwrap_or_else(|_| "127.0.0.1:18789".parse().unwrap()),
|
||||
std::time::Duration::from_millis(150),
|
||||
) {
|
||||
Ok(_) => (true, None),
|
||||
@@ -242,23 +261,42 @@ mod platform {
|
||||
}
|
||||
|
||||
/// 以前台模式 spawn Gateway(不需要管理员权限)
|
||||
pub fn start_service_impl(_label: &str) -> Result<(), String> {
|
||||
pub async fn start_service_impl(_label: &str) -> Result<(), String> {
|
||||
if !is_cli_installed() {
|
||||
return Err("openclaw CLI 未安装,请先通过 npm install -g @qingchencloud/openclaw-zh 安装".into());
|
||||
}
|
||||
if check_service_status(0, "").0 {
|
||||
return Ok(());
|
||||
}
|
||||
crate::utils::openclaw_command()
|
||||
|
||||
let log_dir = dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.join(".openclaw")
|
||||
.join("logs");
|
||||
std::fs::create_dir_all(&log_dir).ok();
|
||||
|
||||
let stdout_log = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(log_dir.join("gateway.log"))
|
||||
.map_err(|e| format!("创建日志文件失败: {e}"))?;
|
||||
|
||||
let stderr_log = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(log_dir.join("gateway.err.log"))
|
||||
.map_err(|e| format!("创建错误日志文件失败: {e}"))?;
|
||||
|
||||
crate::utils::openclaw_command_async()
|
||||
.arg("gateway")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.stdout(stdout_log)
|
||||
.stderr(stderr_log)
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动 Gateway 失败: {e}"))?;
|
||||
|
||||
for _ in 0..25 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
if check_service_status(0, "").0 {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -266,28 +304,29 @@ mod platform {
|
||||
Err("Gateway 启动超时,请检查日志".into())
|
||||
}
|
||||
|
||||
pub fn stop_service_impl(_label: &str) -> Result<(), String> {
|
||||
let _ = crate::utils::openclaw_command()
|
||||
pub async fn stop_service_impl(_label: &str) -> Result<(), String> {
|
||||
let _ = crate::utils::openclaw_command_async()
|
||||
.args(["gateway", "stop"])
|
||||
.output();
|
||||
.output()
|
||||
.await;
|
||||
if check_service_status(0, "").0 {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
let _ = std::process::Command::new("cmd")
|
||||
let _ = TokioCommand::new("cmd")
|
||||
.args(["/c", "taskkill", "/f", "/im", "node.exe", "/fi", "WINDOWTITLE eq openclaw*"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output();
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn restart_service_impl(_label: &str) -> Result<(), String> {
|
||||
let _ = stop_service_impl(_label);
|
||||
pub async fn restart_service_impl(_label: &str) -> Result<(), String> {
|
||||
let _ = stop_service_impl(_label).await;
|
||||
for _ in 0..10 {
|
||||
if !check_service_status(0, "").0 { break; }
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
}
|
||||
start_service_impl(_label)
|
||||
start_service_impl(_label).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,10 +334,10 @@ mod platform {
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod platform {
|
||||
use std::process::Command;
|
||||
use tokio::process::Command;
|
||||
|
||||
pub fn current_uid() -> Result<u32, String> {
|
||||
let output = Command::new("id")
|
||||
let output = std::process::Command::new("id")
|
||||
.arg("-u")
|
||||
.output()
|
||||
.map_err(|e| format!("获取 UID 失败: {e}"))?;
|
||||
@@ -306,10 +345,11 @@ mod platform {
|
||||
uid_str.parse::<u32>().map_err(|e| format!("解析 UID 失败: {e}"))
|
||||
}
|
||||
|
||||
pub fn is_cli_installed() -> bool {
|
||||
pub async fn is_cli_installed() -> bool {
|
||||
Command::new("openclaw")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@@ -318,14 +358,14 @@ mod platform {
|
||||
vec!["ai.openclaw.gateway".to_string()]
|
||||
}
|
||||
|
||||
pub fn check_service_status(_uid: u32, _label: &str) -> (bool, Option<u32>) {
|
||||
pub async fn check_service_status(_uid: u32, _label: &str) -> (bool, Option<u32>) {
|
||||
match std::net::TcpStream::connect_timeout(
|
||||
&"127.0.0.1:18789".parse().unwrap(),
|
||||
std::time::Duration::from_secs(2),
|
||||
) {
|
||||
Ok(_) => (true, None),
|
||||
Err(_) => {
|
||||
if let Ok(output) = Command::new("openclaw").arg("health").output() {
|
||||
if let Ok(output) = Command::new("openclaw").arg("health").output().await {
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
if output.status.success() && !text.contains("not running") {
|
||||
return (true, None);
|
||||
@@ -336,13 +376,14 @@ mod platform {
|
||||
}
|
||||
}
|
||||
|
||||
fn gateway_command(action: &str) -> Result<(), String> {
|
||||
if !is_cli_installed() {
|
||||
async fn gateway_command(action: &str) -> Result<(), String> {
|
||||
if !is_cli_installed().await {
|
||||
return Err("openclaw CLI 未安装,请先通过 npm install -g @qingchencloud/openclaw-zh 安装".into());
|
||||
}
|
||||
let output = crate::utils::openclaw_command()
|
||||
let output = crate::utils::openclaw_command_async()
|
||||
.args(["gateway", action])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("执行 openclaw gateway {action} 失败: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
@@ -352,31 +393,40 @@ mod platform {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn start_service_impl(_label: &str) -> Result<(), String> {
|
||||
gateway_command("start")
|
||||
pub async fn start_service_impl(_label: &str) -> Result<(), String> {
|
||||
gateway_command("start").await
|
||||
}
|
||||
|
||||
pub fn stop_service_impl(_label: &str) -> Result<(), String> {
|
||||
gateway_command("stop")
|
||||
pub async fn stop_service_impl(_label: &str) -> Result<(), String> {
|
||||
gateway_command("stop").await
|
||||
}
|
||||
|
||||
pub fn restart_service_impl(_label: &str) -> Result<(), String> {
|
||||
gateway_command("restart")
|
||||
pub async fn restart_service_impl(_label: &str) -> Result<(), String> {
|
||||
gateway_command("restart").await
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 跨平台公共接口 =====
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_services_status() -> Result<Vec<ServiceStatus>, String> {
|
||||
pub async fn get_services_status() -> Result<Vec<ServiceStatus>, String> {
|
||||
let uid = platform::current_uid()?;
|
||||
let labels = platform::scan_service_labels();
|
||||
let desc_map = description_map();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let cli_installed = platform::is_cli_installed().await;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let cli_installed = platform::is_cli_installed();
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
for label in &labels {
|
||||
#[cfg(target_os = "linux")]
|
||||
let (running, pid) = platform::check_service_status(uid, label).await;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let (running, pid) = platform::check_service_status(uid, label);
|
||||
|
||||
results.push(ServiceStatus {
|
||||
label: label.clone(),
|
||||
pid,
|
||||
@@ -393,16 +443,16 @@ pub fn get_services_status() -> Result<Vec<ServiceStatus>, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn start_service(label: String) -> Result<(), String> {
|
||||
platform::start_service_impl(&label)
|
||||
pub async fn start_service(label: String) -> Result<(), String> {
|
||||
platform::start_service_impl(&label).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn stop_service(label: String) -> Result<(), String> {
|
||||
platform::stop_service_impl(&label)
|
||||
pub async fn stop_service(label: String) -> Result<(), String> {
|
||||
platform::stop_service_impl(&label).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn restart_service(label: String) -> Result<(), String> {
|
||||
platform::restart_service_impl(&label)
|
||||
pub async fn restart_service(label: String) -> Result<(), String> {
|
||||
platform::restart_service_impl(&label).await
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ pub fn run() {
|
||||
config::restore_backup,
|
||||
config::delete_backup,
|
||||
config::reload_gateway,
|
||||
config::restart_gateway,
|
||||
config::test_model,
|
||||
config::list_remote_models,
|
||||
config::upgrade_openclaw,
|
||||
@@ -59,11 +60,13 @@ pub fn run() {
|
||||
extensions::get_cftunnel_logs,
|
||||
extensions::get_clawapp_status,
|
||||
extensions::install_cftunnel,
|
||||
extensions::install_clawapp,
|
||||
// Agent 管理
|
||||
agent::list_agents,
|
||||
agent::add_agent,
|
||||
agent::delete_agent,
|
||||
agent::update_agent_identity,
|
||||
agent::update_agent_model,
|
||||
agent::backup_agent,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::process::Command;
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
/// 跨平台获取 openclaw 命令的方法
|
||||
/// 跨平台获取 openclaw 命令的方法(同步版本)
|
||||
/// 在 Windows 上使用 `cmd /c openclaw` 以兼容全局 npm 路径下的 `.cmd` 脚本
|
||||
pub fn openclaw_command() -> Command {
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -18,3 +18,19 @@ pub fn openclaw_command() -> Command {
|
||||
Command::new("openclaw")
|
||||
}
|
||||
}
|
||||
|
||||
/// 异步版本的 openclaw 命令(推荐使用,避免阻塞 UI)
|
||||
pub fn openclaw_command_async() -> tokio::process::Command {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
let mut cmd = tokio::process::Command::new("cmd");
|
||||
cmd.arg("/c").arg("openclaw");
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
cmd
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
tokio::process::Command::new("openclaw")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,23 @@ function ensureContainer() {
|
||||
return _container
|
||||
}
|
||||
|
||||
export function toast(message, type = 'info', duration = 3000) {
|
||||
export function toast(message, type = 'info', options = {}) {
|
||||
const duration = options.duration || 3000
|
||||
const action = options.action // 可选的操作按钮(DOM 元素)
|
||||
|
||||
const container = ensureContainer()
|
||||
const el = document.createElement('div')
|
||||
el.className = `toast ${type}`
|
||||
el.textContent = message
|
||||
|
||||
const textSpan = document.createElement('span')
|
||||
textSpan.textContent = message
|
||||
el.appendChild(textSpan)
|
||||
|
||||
// 如果有操作按钮,添加到 toast 中
|
||||
if (action instanceof HTMLElement) {
|
||||
el.appendChild(action)
|
||||
}
|
||||
|
||||
container.appendChild(el)
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -49,10 +49,7 @@ function cachedInvoke(cmd, args = {}, ttl = CACHE_TTL) {
|
||||
logRequest(cmd, args, 0, true)
|
||||
return Promise.resolve(cached.val)
|
||||
}
|
||||
const start = Date.now()
|
||||
return invoke(cmd, args).then(val => {
|
||||
const duration = Date.now() - start
|
||||
logRequest(cmd, args, duration, false)
|
||||
_cache.set(key, { val, ts: Date.now() })
|
||||
return val
|
||||
})
|
||||
@@ -65,6 +62,9 @@ function invalidate(...cmds) {
|
||||
}
|
||||
}
|
||||
|
||||
// 导出 invalidate 供外部使用
|
||||
export { invalidate }
|
||||
|
||||
async function invoke(cmd, args = {}) {
|
||||
const start = Date.now()
|
||||
if (_invokeReady) {
|
||||
@@ -164,6 +164,10 @@ function mockInvoke(cmd, args) {
|
||||
stop_service: () => true,
|
||||
restart_service: () => true,
|
||||
reload_gateway: () => 'Gateway 已重载',
|
||||
restart_gateway: () => 'Gateway 已重启',
|
||||
list_agents: () => [
|
||||
{ id: 'main', isDefault: true, identityName: null, model: null, workspace: null },
|
||||
],
|
||||
upgrade_openclaw: () => '升级成功,当前版本: 2026.2.26-zh.3 (mock)',
|
||||
install_gateway: () => 'Gateway 服务已安装 (mock)',
|
||||
uninstall_gateway: () => 'Gateway 服务已卸载 (mock)',
|
||||
@@ -211,6 +215,7 @@ export const api = {
|
||||
readMcpConfig: () => cachedInvoke('read_mcp_config'),
|
||||
writeMcpConfig: (config) => { invalidate('read_mcp_config'); return invoke('write_mcp_config', { config }) },
|
||||
reloadGateway: () => invoke('reload_gateway'),
|
||||
restartGateway: () => invoke('restart_gateway'),
|
||||
upgradeOpenclaw: (source = 'chinese') => invoke('upgrade_openclaw', { source }),
|
||||
installGateway: () => invoke('install_gateway'),
|
||||
uninstallGateway: () => invoke('uninstall_gateway'),
|
||||
@@ -224,6 +229,7 @@ export const api = {
|
||||
addAgent: (name, model, workspace) => { invalidate('list_agents'); return invoke('add_agent', { name, model, workspace: workspace || null }) },
|
||||
deleteAgent: (id) => { invalidate('list_agents'); return invoke('delete_agent', { id }) },
|
||||
updateAgentIdentity: (id, name, emoji) => { invalidate('list_agents'); return invoke('update_agent_identity', { id, name, emoji }) },
|
||||
updateAgentModel: (id, model) => { invalidate('list_agents'); return invoke('update_agent_model', { id, model }) },
|
||||
backupAgent: (id) => invoke('backup_agent', { id }),
|
||||
|
||||
// 日志(短缓存)
|
||||
@@ -255,6 +261,7 @@ export const api = {
|
||||
getCftunnelLogs: (lines = 20) => cachedInvoke('get_cftunnel_logs', { lines }, 5000),
|
||||
getClawappStatus: () => cachedInvoke('get_clawapp_status', {}, 5000),
|
||||
installCftunnel: () => invoke('install_cftunnel'),
|
||||
installClawapp: () => invoke('install_clawapp'),
|
||||
|
||||
// 设备密钥 + Gateway 握手
|
||||
createConnectFrame: (nonce, gatewayToken) => invoke('create_connect_frame', { nonce, gatewayToken }),
|
||||
|
||||
@@ -326,8 +326,9 @@ export class WsClient {
|
||||
if (!this._ws || this._ws.readyState !== WebSocket.OPEN || !this._gatewayReady) {
|
||||
if (!this._intentionalClose && (this._reconnectAttempts > 0 || !this._gatewayReady)) {
|
||||
const waitTimeout = setTimeout(() => { unsub(); reject(new Error('等待重连超时')) }, 15000)
|
||||
const unsub = this.onReady(() => {
|
||||
const unsub = this.onReady((hello, sessionKey, err) => {
|
||||
clearTimeout(waitTimeout); unsub()
|
||||
if (err?.error) { reject(new Error(err.message || 'Gateway 握手失败')); return }
|
||||
this.request(method, params).then(resolve, reject)
|
||||
})
|
||||
return
|
||||
@@ -341,8 +342,14 @@ export class WsClient {
|
||||
})
|
||||
}
|
||||
|
||||
chatSend(sessionKey, message) {
|
||||
return this.request('chat.send', { sessionKey, message, deliver: false, idempotencyKey: uuid() })
|
||||
chatSend(sessionKey, message, attachments) {
|
||||
const params = { sessionKey, message, deliver: false, idempotencyKey: uuid() }
|
||||
if (attachments && attachments.length > 0) {
|
||||
params.attachments = attachments
|
||||
console.log('[ws] 发送附件:', attachments.length, '个')
|
||||
console.log('[ws] 附件详情:', attachments.map(a => ({ type: a.type, mime: a.mimeType, name: a.fileName, size: a.content?.length })))
|
||||
}
|
||||
return this.request('chat.send', params)
|
||||
}
|
||||
|
||||
chatHistory(sessionKey, limit = 200) {
|
||||
|
||||
@@ -83,7 +83,7 @@ async function autoConnectWebSocket() {
|
||||
return
|
||||
}
|
||||
|
||||
wsClient.connect(`ws://127.0.0.1:${port}/ws`, token)
|
||||
wsClient.connect(`127.0.0.1:${port}`, token)
|
||||
console.log('[main] WebSocket 连接已启动')
|
||||
} catch (e) {
|
||||
console.error('[main] 自动连接 WebSocket 失败:', e)
|
||||
|
||||
@@ -145,7 +145,7 @@ const PROJECTS = [
|
||||
{
|
||||
name: 'OpenClaw',
|
||||
desc: 'AI Agent 框架,支持多模型协作、工具调用、记忆管理',
|
||||
url: 'https://github.com/openclaw-labs/openclaw',
|
||||
url: 'https://github.com/openclaw/openclaw',
|
||||
},
|
||||
{
|
||||
name: 'ClawApp',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Agent 管理页面
|
||||
* Agent 增删改查 + 身份编辑
|
||||
*/
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { api, invalidate } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { showModal, showConfirm } from '../components/modal.js'
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function render() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-content">
|
||||
<div id="agents-list" class="loading-text">加载中...</div>
|
||||
<div id="agents-list"></div>
|
||||
</div>
|
||||
`
|
||||
|
||||
@@ -39,6 +39,12 @@ async function loadAgents(page, state) {
|
||||
try {
|
||||
state.agents = await api.listAgents()
|
||||
renderAgents(page, state)
|
||||
|
||||
// 只在第一次加载时绑定事件(避免重复绑定)
|
||||
if (!state.eventsAttached) {
|
||||
attachAgentEvents(page, state)
|
||||
state.eventsAttached = true
|
||||
}
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div style="color:var(--error);padding:20px">加载失败: ' + e + '</div>'
|
||||
toast('加载 Agent 列表失败: ' + e, 'error')
|
||||
@@ -85,8 +91,10 @@ function renderAgents(page, state) {
|
||||
</div>
|
||||
`
|
||||
}).join('')
|
||||
}
|
||||
|
||||
// 事件委托
|
||||
function attachAgentEvents(page, state) {
|
||||
const container = page.querySelector('#agents-list')
|
||||
container.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('[data-action]')
|
||||
if (!btn) return
|
||||
@@ -143,6 +151,9 @@ async function showAddAgentDialog(page, state) {
|
||||
await api.updateAgentIdentity(id, name || null, emoji || null)
|
||||
}
|
||||
toast('Agent 已创建', 'success')
|
||||
|
||||
// 强制清除缓存并重新加载
|
||||
invalidate('list_agents')
|
||||
await loadAgents(page, state)
|
||||
} catch (e) {
|
||||
toast('创建失败: ' + e, 'error')
|
||||
@@ -157,7 +168,7 @@ async function showEditAgentDialog(page, state, id) {
|
||||
|
||||
const name = agent.identityName ? agent.identityName.split(',')[0].trim() : ''
|
||||
|
||||
// 获取模型列表用于下拉选择
|
||||
// 获取模型列表
|
||||
let models = []
|
||||
try {
|
||||
const config = await api.readOpenclawConfig()
|
||||
@@ -168,23 +179,29 @@ async function showEditAgentDialog(page, state, id) {
|
||||
if (mid) models.push({ value: `${pk}/${mid}`, label: `${pk}/${mid}` })
|
||||
}
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
console.log('[Agent编辑] 获取到模型列表:', models.length, '个')
|
||||
} catch (e) {
|
||||
console.error('[Agent编辑] 获取模型列表失败:', e)
|
||||
}
|
||||
|
||||
const fields = [
|
||||
{ name: 'name', label: '名称', value: name, placeholder: '例如:翻译助手' },
|
||||
{ name: 'emoji', label: 'Emoji', value: '', placeholder: '例如:🌐' },
|
||||
{ name: 'emoji', label: 'Emoji', value: agent.identityEmoji || '', placeholder: '例如:🌐' },
|
||||
]
|
||||
|
||||
// 有模型列表时提供下拉选择
|
||||
if (models.length) {
|
||||
fields.push({
|
||||
const modelField = {
|
||||
name: 'model', label: '模型', type: 'select',
|
||||
value: agent.model || models[0]?.value || '',
|
||||
options: models,
|
||||
})
|
||||
}
|
||||
fields.push(modelField)
|
||||
console.log('[Agent编辑] 当前模型:', agent.model)
|
||||
console.log('[Agent编辑] 模型选项:', models)
|
||||
} else {
|
||||
console.warn('[Agent编辑] 模型列表为空,不显示模型选择器')
|
||||
}
|
||||
|
||||
// 工作区只读展示
|
||||
fields.push({
|
||||
name: 'workspace', label: '工作区',
|
||||
value: agent.workspace || '未设置',
|
||||
@@ -196,16 +213,30 @@ async function showEditAgentDialog(page, state, id) {
|
||||
title: `编辑 Agent — ${id}`,
|
||||
fields,
|
||||
onConfirm: async (result) => {
|
||||
console.log('[Agent编辑] 保存数据:', result)
|
||||
const newName = (result.name || '').trim()
|
||||
const emoji = (result.emoji || '').trim()
|
||||
const model = (result.model || '').trim()
|
||||
|
||||
try {
|
||||
if (newName || emoji) {
|
||||
console.log('[Agent编辑] 更新身份信息...')
|
||||
await api.updateAgentIdentity(id, newName || null, emoji || null)
|
||||
}
|
||||
if (model && model !== agent.model) {
|
||||
console.log('[Agent编辑] 更新模型:', agent.model, '->', model)
|
||||
await api.updateAgentModel(id, model)
|
||||
}
|
||||
|
||||
// 手动更新 state 并重新渲染,确保立即生效
|
||||
if (newName) agent.identityName = newName
|
||||
if (emoji) agent.identityEmoji = emoji
|
||||
if (model) agent.model = model
|
||||
renderAgents(page, state)
|
||||
|
||||
toast('已更新', 'success')
|
||||
await loadAgents(page, state)
|
||||
} catch (e) {
|
||||
console.error('[Agent编辑] 保存失败:', e)
|
||||
toast('更新失败: ' + e, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@ export async function render() {
|
||||
|
||||
async function loadDebugInfo(page) {
|
||||
const el = page.querySelector('#debug-content')
|
||||
el.innerHTML = '<div class="loading-text">检测中...</div>'
|
||||
|
||||
const info = {
|
||||
timestamp: new Date().toLocaleString('zh-CN'),
|
||||
|
||||
@@ -39,12 +39,14 @@ const COMMANDS = [
|
||||
|
||||
let _sessionKey = null, _page = null, _messagesEl = null, _textarea = null
|
||||
let _sendBtn = null, _statusDot = null, _typingEl = null, _scrollBtn = null
|
||||
let _sessionListEl = null, _cmdPanelEl = null
|
||||
let _sessionListEl = null, _cmdPanelEl = null, _attachPreviewEl = null, _fileInputEl = null
|
||||
let _currentAiBubble = null, _currentAiText = '', _currentRunId = null
|
||||
let _isStreaming = false, _isSending = false, _messageQueue = []
|
||||
let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = ''
|
||||
let _streamSafetyTimer = null, _unsubEvent = null, _unsubReady = null, _unsubStatus = null
|
||||
let _pageActive = false
|
||||
let _errorTimer = null, _lastErrorMsg = null
|
||||
let _attachments = []
|
||||
|
||||
export async function render() {
|
||||
const page = document.createElement('div')
|
||||
@@ -87,7 +89,12 @@ export async function render() {
|
||||
</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-attachments-preview" id="chat-attachments-preview" style="display:none"></div>
|
||||
<div class="chat-input-area">
|
||||
<input type="file" id="chat-file-input" accept="image/*" multiple style="display:none">
|
||||
<button class="chat-attach-btn" id="chat-attach-btn" title="上传图片">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>
|
||||
</button>
|
||||
<div class="chat-input-wrapper">
|
||||
<textarea id="chat-input" rows="1" placeholder="输入消息,Enter 发送,/ 打开指令"></textarea>
|
||||
</div>
|
||||
@@ -107,6 +114,8 @@ export async function render() {
|
||||
_scrollBtn = page.querySelector('#chat-scroll-btn')
|
||||
_sessionListEl = page.querySelector('#chat-session-list')
|
||||
_cmdPanelEl = page.querySelector('#chat-cmd-panel')
|
||||
_attachPreviewEl = page.querySelector('#chat-attachments-preview')
|
||||
_fileInputEl = page.querySelector('#chat-file-input')
|
||||
|
||||
bindEvents(page)
|
||||
// 非阻塞:先返回 DOM,后台连接 Gateway
|
||||
@@ -140,9 +149,13 @@ function bindEvents(page) {
|
||||
page.querySelector('#chat-sidebar').classList.toggle('open')
|
||||
})
|
||||
page.querySelector('#btn-new-session').addEventListener('click', () => showNewSessionDialog())
|
||||
page.querySelector('#btn-cmd').addEventListener('click', () => toggleCmdPanel())
|
||||
page.querySelector('#btn-cmd').addEventListener('click', () => toggleCmdPanel())
|
||||
page.querySelector('#btn-reset-session').addEventListener('click', () => resetCurrentSession())
|
||||
|
||||
// 文件上传
|
||||
page.querySelector('#chat-attach-btn').addEventListener('click', () => _fileInputEl.click())
|
||||
_fileInputEl.addEventListener('change', handleFileSelect)
|
||||
|
||||
_messagesEl.addEventListener('scroll', () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = _messagesEl
|
||||
_scrollBtn.style.display = (scrollHeight - scrollTop - clientHeight < 80) ? 'none' : 'flex'
|
||||
@@ -151,6 +164,73 @@ page.querySelector('#btn-cmd').addEventListener('click', () => toggleCmdPanel())
|
||||
_messagesEl.addEventListener('click', () => hideCmdPanel())
|
||||
}
|
||||
|
||||
// ── 文件上传 ──
|
||||
|
||||
async function handleFileSelect(e) {
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (!files.length) return
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast('仅支持图片文件', 'warning')
|
||||
continue
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast(`${file.name} 超过 5MB 限制`, 'warning')
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const base64 = await fileToBase64(file)
|
||||
_attachments.push({
|
||||
type: 'image',
|
||||
mimeType: file.type,
|
||||
fileName: file.name,
|
||||
content: base64,
|
||||
})
|
||||
renderAttachments()
|
||||
} catch (e) {
|
||||
toast(`读取 ${file.name} 失败`, 'error')
|
||||
}
|
||||
}
|
||||
_fileInputEl.value = ''
|
||||
}
|
||||
|
||||
function fileToBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const dataUrl = reader.result
|
||||
const base64 = dataUrl.split(',')[1]
|
||||
resolve(base64)
|
||||
}
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
function renderAttachments() {
|
||||
if (!_attachments.length) {
|
||||
_attachPreviewEl.style.display = 'none'
|
||||
return
|
||||
}
|
||||
_attachPreviewEl.style.display = 'flex'
|
||||
_attachPreviewEl.innerHTML = _attachments.map((att, idx) => `
|
||||
<div class="chat-attachment-item">
|
||||
<img src="data:${att.mimeType};base64,${att.content}" alt="${att.fileName}">
|
||||
<button class="chat-attachment-del" data-idx="${idx}">×</button>
|
||||
</div>
|
||||
`).join('')
|
||||
|
||||
_attachPreviewEl.querySelectorAll('.chat-attachment-del').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const idx = parseInt(btn.dataset.idx)
|
||||
_attachments.splice(idx, 1)
|
||||
renderAttachments()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ── Gateway 连接 ──
|
||||
|
||||
async function connectGateway() {
|
||||
@@ -175,14 +255,16 @@ async function connectGateway() {
|
||||
_unsubReady = wsClient.onReady((hello, sessionKey, err) => {
|
||||
if (!_pageActive) return
|
||||
if (err?.error) { toast(err.message || '连接失败', 'error'); return }
|
||||
showTyping(false) // Gateway 就绪后关闭加载动画
|
||||
// 重连后恢复:保留当前 sessionKey,不重复加载历史
|
||||
if (!_sessionKey) {
|
||||
const saved = localStorage.getItem(STORAGE_SESSION_KEY)
|
||||
_sessionKey = saved || sessionKey
|
||||
updateSessionTitle()
|
||||
loadHistory()
|
||||
refreshSessionList()
|
||||
}
|
||||
// 始终刷新会话列表(无论是否有 sessionKey)
|
||||
refreshSessionList()
|
||||
})
|
||||
|
||||
_unsubEvent = wsClient.onEvent((msg) => {
|
||||
@@ -195,6 +277,7 @@ async function connectGateway() {
|
||||
const saved = localStorage.getItem(STORAGE_SESSION_KEY)
|
||||
_sessionKey = saved || wsClient.sessionKey
|
||||
updateStatusDot('ready')
|
||||
showTyping(false) // 确保关闭加载动画
|
||||
updateSessionTitle()
|
||||
loadHistory()
|
||||
refreshSessionList()
|
||||
@@ -278,23 +361,18 @@ function switchSession(newKey) {
|
||||
|
||||
async function showNewSessionDialog() {
|
||||
const defaultAgent = wsClient.snapshot?.sessionDefaults?.defaultAgentId || 'main'
|
||||
// 获取 agent 列表
|
||||
let agents = []
|
||||
try {
|
||||
agents = await api.listAgents()
|
||||
} catch { agents = [{ id: 'main', identityName: '默认', isDefault: true }] }
|
||||
|
||||
const agentOptions = agents.map(a => ({
|
||||
value: a.id,
|
||||
label: `${a.id}${a.isDefault ? ' (默认)' : ''}${a.identityName ? ' — ' + a.identityName.split(',')[0] : ''}`
|
||||
}))
|
||||
agentOptions.push({ value: '__new__', label: '+ 新建 Agent' })
|
||||
// 先用默认选项立即显示弹窗
|
||||
const initialOptions = [
|
||||
{ value: 'main', label: 'main (默认)' },
|
||||
{ value: '__new__', label: '+ 新建 Agent' }
|
||||
]
|
||||
|
||||
showModal({
|
||||
title: '新建会话',
|
||||
fields: [
|
||||
{ name: 'name', label: '会话名称', value: '', placeholder: '例如:翻译助手' },
|
||||
{ name: 'agent', label: '智能体', type: 'select', value: defaultAgent, options: agentOptions },
|
||||
{ name: 'agent', label: '智能体', type: 'select', value: defaultAgent, options: initialOptions },
|
||||
],
|
||||
onConfirm: (result) => {
|
||||
const name = (result.name || '').trim()
|
||||
@@ -309,6 +387,27 @@ async function showNewSessionDialog() {
|
||||
toast('会话已创建', 'success')
|
||||
}
|
||||
})
|
||||
|
||||
// 异步加载完整 Agent 列表并更新下拉框
|
||||
try {
|
||||
const agents = await api.listAgents()
|
||||
const agentOptions = agents.map(a => ({
|
||||
value: a.id,
|
||||
label: `${a.id}${a.isDefault ? ' (默认)' : ''}${a.identityName ? ' — ' + a.identityName.split(',')[0] : ''}`
|
||||
}))
|
||||
agentOptions.push({ value: '__new__', label: '+ 新建 Agent' })
|
||||
|
||||
// 更新弹窗中的下拉框选项
|
||||
const selectEl = document.querySelector('.modal-overlay [data-name="agent"]')
|
||||
if (selectEl) {
|
||||
const currentValue = selectEl.value
|
||||
selectEl.innerHTML = agentOptions.map(o =>
|
||||
`<option value="${o.value}" ${o.value === currentValue ? 'selected' : ''}>${o.label}</option>`
|
||||
).join('')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[chat] 加载 Agent 列表失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession(key) {
|
||||
@@ -389,22 +488,25 @@ function toggleCmdPanel() {
|
||||
|
||||
function sendMessage() {
|
||||
const text = _textarea.value.trim()
|
||||
if (!text) return
|
||||
if (!text && !_attachments.length) return
|
||||
hideCmdPanel()
|
||||
_textarea.value = ''
|
||||
_textarea.style.height = 'auto'
|
||||
updateSendState()
|
||||
if (_isSending || _isStreaming) { _messageQueue.push(text); return }
|
||||
doSend(text)
|
||||
const attachments = [..._attachments]
|
||||
_attachments = []
|
||||
renderAttachments()
|
||||
if (_isSending || _isStreaming) { _messageQueue.push({ text, attachments }); return }
|
||||
doSend(text, attachments)
|
||||
}
|
||||
|
||||
async function doSend(text) {
|
||||
appendUserMessage(text)
|
||||
async function doSend(text, attachments = []) {
|
||||
appendUserMessage(text, attachments)
|
||||
saveMessage({ id: uuid(), sessionKey: _sessionKey, role: 'user', content: text, timestamp: Date.now() })
|
||||
showTyping(true)
|
||||
_isSending = true
|
||||
try {
|
||||
await wsClient.chatSend(_sessionKey, text)
|
||||
await wsClient.chatSend(_sessionKey, text, attachments.length ? attachments : undefined)
|
||||
} catch (err) {
|
||||
showTyping(false)
|
||||
appendSystemMessage('发送失败: ' + err.message)
|
||||
@@ -416,7 +518,9 @@ async function doSend(text) {
|
||||
|
||||
function processMessageQueue() {
|
||||
if (_messageQueue.length === 0 || _isSending || _isStreaming) return
|
||||
doSend(_messageQueue.shift())
|
||||
const msg = _messageQueue.shift()
|
||||
if (typeof msg === 'string') doSend(msg, [])
|
||||
else doSend(msg.text, msg.attachments || [])
|
||||
}
|
||||
|
||||
function stopGeneration() {
|
||||
@@ -442,7 +546,12 @@ function handleChatEvent(payload) {
|
||||
const c = extractChatContent(payload.message)
|
||||
if (c?.text && c.text.length > _currentAiText.length) {
|
||||
showTyping(false)
|
||||
if (!_currentAiBubble) { _currentAiBubble = createStreamBubble(); _currentRunId = payload.runId }
|
||||
if (!_currentAiBubble) {
|
||||
_currentAiBubble = createStreamBubble()
|
||||
_currentRunId = payload.runId
|
||||
_isStreaming = true
|
||||
updateSendState()
|
||||
}
|
||||
_currentAiText = c.text
|
||||
throttledRender()
|
||||
}
|
||||
@@ -484,10 +593,22 @@ function handleChatEvent(payload) {
|
||||
|
||||
if (state === 'error') {
|
||||
const errMsg = payload.errorMessage || payload.error?.message || '未知错误'
|
||||
if (_isStreaming) {
|
||||
console.warn('[chat] 流式中临时错误,等待重试:', errMsg)
|
||||
|
||||
// 防抖:如果是相同错误且在 2 秒内,忽略(避免重复显示)
|
||||
const now = Date.now()
|
||||
if (_lastErrorMsg === errMsg && _errorTimer && (now - _errorTimer < 2000)) {
|
||||
console.warn('[chat] 忽略重复错误:', errMsg)
|
||||
return
|
||||
}
|
||||
_lastErrorMsg = errMsg
|
||||
_errorTimer = now
|
||||
|
||||
// 如果正在流式输出,说明消息已经部分成功,不显示错误
|
||||
if (_isStreaming || _currentAiBubble) {
|
||||
console.warn('[chat] 流式中收到错误,但消息已部分成功,忽略错误提示:', errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
showTyping(false)
|
||||
appendSystemMessage('错误: ' + errMsg)
|
||||
resetStreamState()
|
||||
@@ -561,6 +682,8 @@ function resetStreamState() {
|
||||
_currentAiText = ''
|
||||
_currentRunId = null
|
||||
_isStreaming = false
|
||||
_lastErrorMsg = null
|
||||
_errorTimer = null
|
||||
showTyping(false)
|
||||
updateSendState()
|
||||
}
|
||||
@@ -637,12 +760,30 @@ function extractContent(msg) {
|
||||
|
||||
// ── DOM 操作 ──
|
||||
|
||||
function appendUserMessage(text) {
|
||||
function appendUserMessage(text, attachments = []) {
|
||||
const wrap = document.createElement('div')
|
||||
wrap.className = 'msg msg-user'
|
||||
const bubble = document.createElement('div')
|
||||
bubble.className = 'msg-bubble'
|
||||
bubble.textContent = text
|
||||
|
||||
if (attachments.length > 0) {
|
||||
const imgContainer = document.createElement('div')
|
||||
imgContainer.style.cssText = 'display:flex;gap:4px;margin-bottom:8px;flex-wrap:wrap'
|
||||
attachments.forEach(att => {
|
||||
const img = document.createElement('img')
|
||||
img.src = `data:${att.mimeType};base64,${att.content}`
|
||||
img.style.cssText = 'max-width:200px;max-height:200px;border-radius:4px'
|
||||
imgContainer.appendChild(img)
|
||||
})
|
||||
bubble.appendChild(imgContainer)
|
||||
}
|
||||
|
||||
if (text) {
|
||||
const textNode = document.createElement('div')
|
||||
textNode.textContent = text
|
||||
bubble.appendChild(textNode)
|
||||
}
|
||||
|
||||
wrap.appendChild(bubble)
|
||||
_messagesEl.insertBefore(wrap, _typingEl)
|
||||
scrollToBottom()
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function render() {
|
||||
</div>
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">最近日志</div>
|
||||
<div class="log-viewer" id="recent-logs" style="max-height:300px"><div class="loading-text">加载中...</div></div>
|
||||
<div class="log-viewer" id="recent-logs" style="max-height:300px"></div>
|
||||
</div>
|
||||
`
|
||||
|
||||
|
||||
@@ -27,12 +27,12 @@ export async function render() {
|
||||
<div id="cftunnel-card" class="config-section">
|
||||
<div class="config-section-title">cftunnel 内网穿透</div>
|
||||
<div class="form-hint" style="margin-bottom:var(--space-md)">通过 Cloudflare Tunnel 将本地服务暴露到公网,无需公网 IP 和端口映射。</div>
|
||||
<div id="cftunnel-content" class="loading-text">加载中...</div>
|
||||
<div id="cftunnel-content"></div>
|
||||
</div>
|
||||
<div id="clawapp-card" class="config-section">
|
||||
<div class="config-section-title">ClawApp 移动客户端</div>
|
||||
<div class="form-hint" style="margin-bottom:var(--space-md)">基于 LobeChat 的 AI 对话客户端,通过 Gateway 连接模型服务。支持本地和外网访问。</div>
|
||||
<div id="clawapp-content" class="loading-text">加载中...</div>
|
||||
<div id="clawapp-content"></div>
|
||||
</div>
|
||||
`
|
||||
|
||||
@@ -52,7 +52,6 @@ async function loadAll(page) {
|
||||
|
||||
async function loadCftunnel(page) {
|
||||
const el = page.querySelector('#cftunnel-content')
|
||||
el.innerHTML = '<div class="loading-text">加载中...</div>'
|
||||
try {
|
||||
const status = await api.getCftunnelStatus()
|
||||
renderCftunnel(el, status)
|
||||
@@ -146,7 +145,6 @@ function renderRoutes(routes) {
|
||||
|
||||
async function loadClawapp(page) {
|
||||
const el = page.querySelector('#clawapp-content')
|
||||
el.innerHTML = '<div class="loading-text">加载中...</div>'
|
||||
try {
|
||||
const status = await api.getClawappStatus()
|
||||
renderClawapp(el, status)
|
||||
@@ -156,6 +154,18 @@ async function loadClawapp(page) {
|
||||
}
|
||||
|
||||
function renderClawapp(el, s) {
|
||||
if (!s.installed) {
|
||||
el.innerHTML = `
|
||||
<div style="color:var(--text-tertiary);margin-bottom:var(--space-md)">ClawApp 未安装</div>
|
||||
<div style="display:flex;gap:var(--space-sm);align-items:center">
|
||||
<button class="btn btn-primary btn-sm" data-action="install-clawapp">一键安装</button>
|
||||
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawapp" target="_blank" rel="noopener">查看文档</a>
|
||||
</div>
|
||||
<div id="install-clawapp-progress-area"></div>
|
||||
`
|
||||
return
|
||||
}
|
||||
|
||||
const running = s.running
|
||||
el.innerHTML = `
|
||||
<div class="stat-cards" style="margin-bottom:var(--space-md)">
|
||||
@@ -208,6 +218,9 @@ function bindEvents(page) {
|
||||
case 'install-cftunnel':
|
||||
await handleInstallCftunnel(page)
|
||||
break
|
||||
case 'install-clawapp':
|
||||
await handleInstallClawapp(page)
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -302,3 +315,56 @@ async function handleInstallCftunnel(page) {
|
||||
unlistenProgress?.()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInstallClawapp(page) {
|
||||
const area = page.querySelector('#install-clawapp-progress-area')
|
||||
if (!area) return
|
||||
|
||||
area.innerHTML = `
|
||||
<div style="margin-top:var(--space-lg)">
|
||||
<div class="upgrade-progress-wrap">
|
||||
<div class="upgrade-progress-bar">
|
||||
<div class="upgrade-progress-fill" id="install-clawapp-progress-fill" style="width:0%"></div>
|
||||
</div>
|
||||
<div class="upgrade-progress-text" id="install-clawapp-progress-text">准备安装...</div>
|
||||
</div>
|
||||
<div class="upgrade-log-box" id="install-clawapp-log-box"></div>
|
||||
</div>
|
||||
`
|
||||
|
||||
const progressFill = area.querySelector('#install-clawapp-progress-fill')
|
||||
const progressText = area.querySelector('#install-clawapp-progress-text')
|
||||
const logBox = area.querySelector('#install-clawapp-log-box')
|
||||
|
||||
let unlistenLog, unlistenProgress
|
||||
try {
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
|
||||
unlistenLog = await listen('install-log', (e) => {
|
||||
logBox.textContent += e.payload + '\n'
|
||||
logBox.scrollTop = logBox.scrollHeight
|
||||
})
|
||||
|
||||
unlistenProgress = await listen('install-progress', (e) => {
|
||||
const progress = e.payload
|
||||
progressFill.style.width = progress + '%'
|
||||
progressText.textContent = `安装中... ${progress}%`
|
||||
})
|
||||
|
||||
await api.installClawapp()
|
||||
|
||||
progressFill.classList.add('done')
|
||||
progressText.textContent = '✅ 安装完成'
|
||||
toast('ClawApp 安装成功', 'success')
|
||||
|
||||
setTimeout(() => loadClawapp(page), 3000)
|
||||
} catch (e) {
|
||||
progressFill.classList.add('error')
|
||||
progressText.textContent = '❌ 安装失败'
|
||||
logBox.textContent += '\n错误: ' + e
|
||||
toast('安装失败: ' + e, 'error')
|
||||
} finally {
|
||||
unlistenLog?.()
|
||||
unlistenProgress?.()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function render() {
|
||||
<h1 class="page-title">Gateway 配置</h1>
|
||||
<p class="page-desc">Gateway 是 AI 模型的统一入口,所有应用通过它来调用模型服务</p>
|
||||
</div>
|
||||
<div id="gateway-config" class="loading-text">加载中...</div>
|
||||
<div id="gateway-config"></div>
|
||||
<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>
|
||||
@@ -42,7 +42,6 @@ export async function render() {
|
||||
|
||||
async function loadConfig(page, state) {
|
||||
const el = page.querySelector('#gateway-config')
|
||||
el.innerHTML = '<div class="loading-text">加载中...</div>'
|
||||
try {
|
||||
state.config = await api.readOpenclawConfig()
|
||||
renderConfig(page, state)
|
||||
@@ -76,8 +75,8 @@ function renderConfig(page, state) {
|
||||
谁能访问
|
||||
</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>
|
||||
<label class="gw-option-card ${(gw.bind === 'lan' || gw.bind === 'all') ? '' : 'selected'}" data-bind="loopback">
|
||||
<input type="radio" name="gw-bind" value="loopback" ${(gw.bind === 'lan' || 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>
|
||||
@@ -86,8 +85,8 @@ function renderConfig(page, state) {
|
||||
<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>
|
||||
<label class="gw-option-card ${(gw.bind === 'lan' || gw.bind === 'all') ? 'selected' : ''}" data-bind="lan">
|
||||
<input type="radio" name="gw-bind" value="lan" ${(gw.bind === 'lan' || 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>
|
||||
|
||||
@@ -33,7 +33,7 @@ export async function render() {
|
||||
<input type="checkbox" id="log-autoscroll" checked> 自动滚动
|
||||
</label>
|
||||
</div>
|
||||
<div class="log-viewer" id="log-content" style="height:calc(100vh - 280px)"><div class="loading-text">加载中...</div></div>
|
||||
<div class="log-viewer" id="log-content" style="height:calc(100vh - 280px)"></div>
|
||||
`
|
||||
|
||||
let currentTab = 'gateway'
|
||||
@@ -75,7 +75,6 @@ export function cleanup() {
|
||||
|
||||
async function loadLog(page, logName) {
|
||||
const el = page.querySelector('#log-content')
|
||||
el.innerHTML = '<div class="loading-text">加载中...</div>'
|
||||
try {
|
||||
const content = await api.readLogTail(logName, 200)
|
||||
if (!content || !content.trim()) {
|
||||
@@ -95,7 +94,6 @@ async function loadLog(page, logName) {
|
||||
|
||||
async function searchLog(page, logName, query) {
|
||||
const el = page.querySelector('#log-content')
|
||||
el.innerHTML = '<div class="loading-text">搜索中...</div>'
|
||||
try {
|
||||
const results = await api.searchLog(logName, query)
|
||||
if (!results || !results.length) {
|
||||
|
||||
@@ -36,7 +36,7 @@ export async function render() {
|
||||
<div style="padding:0 var(--space-sm) var(--space-sm)">
|
||||
<button class="btn btn-sm btn-secondary" id="btn-export-zip" style="width:100%">打包下载全部</button>
|
||||
</div>
|
||||
<div id="file-tree" class="loading-text">加载中...</div>
|
||||
<div id="file-tree"></div>
|
||||
</div>
|
||||
<div class="memory-editor">
|
||||
<div class="editor-toolbar">
|
||||
@@ -54,15 +54,18 @@ export async function render() {
|
||||
|
||||
const state = { category: 'memory', currentPath: null, agentId: 'main' }
|
||||
|
||||
// 非阻塞加载 agent 列表,然后填充下拉框
|
||||
// 先用默认选项填充下拉框,立即显示页面
|
||||
const agentSelect = page.querySelector('#agent-select')
|
||||
agentSelect.innerHTML = '<option value="main">main</option>'
|
||||
|
||||
// 异步加载 agent 列表并更新下拉框
|
||||
api.listAgents().then(agents => {
|
||||
const select = page.querySelector('#agent-select')
|
||||
if (!select) return
|
||||
if (!agentSelect) return
|
||||
const options = 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('')
|
||||
select.innerHTML = options
|
||||
agentSelect.innerHTML = options
|
||||
}).catch(() => {})
|
||||
|
||||
// Agent 切换
|
||||
@@ -141,7 +144,6 @@ export async function render() {
|
||||
|
||||
async function loadFiles(page, state) {
|
||||
const tree = page.querySelector('#file-tree')
|
||||
tree.innerHTML = '<div style="color:var(--text-tertiary);padding:12px">加载中...</div>'
|
||||
|
||||
try {
|
||||
const files = await api.listMemoryFiles(state.category, state.agentId)
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function render() {
|
||||
<div style="margin-bottom:var(--space-md)">
|
||||
<input class="form-input" id="model-search" placeholder="搜索模型(按 ID 或名称过滤)" style="max-width:360px">
|
||||
</div>
|
||||
<div id="providers-list" class="loading-text">加载中...</div>
|
||||
<div id="providers-list"></div>
|
||||
`
|
||||
|
||||
const state = { config: null, search: '', undoStack: [] }
|
||||
@@ -83,7 +83,6 @@ export async function render() {
|
||||
|
||||
async function loadConfig(page, state) {
|
||||
const listEl = page.querySelector('#providers-list')
|
||||
listEl.innerHTML = '<div class="loading-text">加载中...</div>'
|
||||
try {
|
||||
state.config = await api.readOpenclawConfig()
|
||||
renderDefaultBar(page, state)
|
||||
@@ -360,8 +359,23 @@ async function doAutoSave(state) {
|
||||
const primary = getCurrentPrimary(state.config)
|
||||
if (primary) applyDefaultModel(state)
|
||||
await api.writeOpenclawConfig(state.config)
|
||||
// Gateway 会自动检测配置变化并热重载,无需手动 kickstart
|
||||
toast('已自动保存', 'success')
|
||||
|
||||
// 提示用户需要重启 Gateway
|
||||
const restartBtn = document.createElement('button')
|
||||
restartBtn.className = 'btn btn-sm btn-primary'
|
||||
restartBtn.textContent = '立即重启'
|
||||
restartBtn.style.marginLeft = '8px'
|
||||
restartBtn.onclick = async () => {
|
||||
try {
|
||||
toast('正在重启 Gateway...', 'info')
|
||||
await api.restartGateway()
|
||||
toast('Gateway 重启成功', 'success')
|
||||
} catch (e) {
|
||||
toast('重启失败: ' + e.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
toast('配置已保存,需要重启 Gateway 生效', 'warning', { action: restartBtn })
|
||||
} catch (e) {
|
||||
toast('自动保存失败: ' + e, 'error')
|
||||
}
|
||||
|
||||
@@ -26,10 +26,10 @@ export async function render() {
|
||||
<p class="page-desc">管理 OpenClaw 服务、检查更新、配置备份</p>
|
||||
</div>
|
||||
<div id="version-bar"></div>
|
||||
<div id="services-list" class="loading-text">加载中...</div>
|
||||
<div id="services-list"></div>
|
||||
<div class="config-section" id="registry-section">
|
||||
<div class="config-section-title">npm 源设置</div>
|
||||
<div id="registry-bar" class="loading-text">加载中...</div>
|
||||
<div id="registry-bar"></div>
|
||||
</div>
|
||||
<div class="config-section" id="backup-section">
|
||||
<div class="config-section-title">配置备份</div>
|
||||
@@ -37,7 +37,7 @@ export async function render() {
|
||||
<div id="backup-actions" style="margin-bottom:var(--space-md)">
|
||||
<button class="btn btn-primary btn-sm" data-action="create-backup">创建备份</button>
|
||||
</div>
|
||||
<div id="backup-list" class="loading-text">加载中...</div>
|
||||
<div id="backup-list"></div>
|
||||
</div>
|
||||
`
|
||||
|
||||
@@ -101,7 +101,6 @@ const REGISTRIES = [
|
||||
|
||||
async function loadRegistry(page) {
|
||||
const bar = page.querySelector('#registry-bar')
|
||||
bar.innerHTML = '<div class="loading-text">加载中...</div>'
|
||||
try {
|
||||
const current = await api.getNpmRegistry()
|
||||
const isPreset = REGISTRIES.some(r => r.value === current)
|
||||
@@ -131,7 +130,6 @@ async function loadRegistry(page) {
|
||||
|
||||
async function loadServices(page) {
|
||||
const container = page.querySelector('#services-list')
|
||||
container.innerHTML = '<div class="loading-text">加载中...</div>'
|
||||
try {
|
||||
const services = await api.getServicesStatus()
|
||||
renderServices(container, services)
|
||||
@@ -200,7 +198,6 @@ function renderServices(container, services) {
|
||||
|
||||
async function loadBackups(page) {
|
||||
const list = page.querySelector('#backup-list')
|
||||
list.innerHTML = '<div class="loading-text">加载中...</div>'
|
||||
try {
|
||||
const backups = await api.listBackups()
|
||||
renderBackups(list, backups)
|
||||
@@ -291,6 +288,7 @@ const ACTION_LABELS = { start: '启动', stop: '停止', restart: '重启' }
|
||||
|
||||
async function handleServiceAction(action, label, page) {
|
||||
const fn = { start: api.startService, stop: api.stopService, restart: api.restartService }[action]
|
||||
toast(`正在${ACTION_LABELS[action]} ${label}...`, 'info')
|
||||
await fn(label)
|
||||
toast(`${ACTION_LABELS[action]} ${label} 成功`, 'success')
|
||||
await loadServices(page)
|
||||
|
||||
@@ -548,3 +548,66 @@
|
||||
.cmd-desc {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 文件上传 */
|
||||
.chat-attach-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.chat-attach-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.chat-attachments-preview {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chat-attachment-item {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-attachment-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.chat-attachment-del {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
background: rgba(0,0,0,0.6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chat-attachment-del:hover {
|
||||
background: rgba(255,0,0,0.8);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user