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:
晴天
2026-03-04 12:16:58 +08:00
parent 05771ffa63
commit dab61ccd24
24 changed files with 882 additions and 209 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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