mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-27 11:20:04 +08:00
2157 lines
78 KiB
Rust
2157 lines
78 KiB
Rust
#[cfg(not(target_os = "macos"))]
|
||
use crate::utils::openclaw_command;
|
||
/// 配置读写命令
|
||
use serde_json::{json, Value};
|
||
use std::fs;
|
||
#[cfg(target_os = "windows")]
|
||
use std::os::windows::process::CommandExt;
|
||
use std::path::PathBuf;
|
||
use std::process::Command;
|
||
|
||
use crate::models::types::VersionInfo;
|
||
|
||
struct GuardianPause {
|
||
reason: &'static str,
|
||
}
|
||
|
||
impl GuardianPause {
|
||
fn new(reason: &'static str) -> Self {
|
||
crate::commands::service::guardian_pause(reason);
|
||
Self { reason }
|
||
}
|
||
}
|
||
|
||
impl Drop for GuardianPause {
|
||
fn drop(&mut self) {
|
||
crate::commands::service::guardian_resume(self.reason);
|
||
}
|
||
}
|
||
|
||
/// 预设 npm 源列表
|
||
const DEFAULT_REGISTRY: &str = "https://registry.npmmirror.com";
|
||
|
||
/// Linux: 检测是否以 root 身份运行(避免 unsafe libc 调用)
|
||
#[cfg(target_os = "linux")]
|
||
fn nix_is_root() -> bool {
|
||
std::env::var("USER")
|
||
.or_else(|_| std::env::var("EUID"))
|
||
.map(|v| v == "root" || v == "0")
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// 读取用户配置的 npm registry,fallback 到淘宝镜像
|
||
fn get_configured_registry() -> String {
|
||
let path = super::openclaw_dir().join("npm-registry.txt");
|
||
fs::read_to_string(&path)
|
||
.ok()
|
||
.map(|s| s.trim().to_string())
|
||
.filter(|s| !s.is_empty())
|
||
.unwrap_or_else(|| DEFAULT_REGISTRY.to_string())
|
||
}
|
||
|
||
/// 创建使用配置源的 npm Command
|
||
/// Windows 上 npm 是 npm.cmd,需要通过 cmd /c 调用,并隐藏窗口
|
||
/// Linux 非 root 用户全局安装需要 sudo
|
||
fn npm_command() -> Command {
|
||
let registry = get_configured_registry();
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||
let mut cmd = Command::new("cmd");
|
||
cmd.args(["/c", "npm", "--registry", ®istry]);
|
||
cmd.env("PATH", super::enhanced_path());
|
||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||
cmd
|
||
}
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
let mut cmd = Command::new("npm");
|
||
cmd.args(["--registry", ®istry]);
|
||
cmd.env("PATH", super::enhanced_path());
|
||
cmd
|
||
}
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
// Linux 非 root 用户全局 npm install 需要 sudo
|
||
let need_sudo = !nix_is_root();
|
||
let mut cmd = if need_sudo {
|
||
let mut c = Command::new("sudo");
|
||
c.args(["npm", "--registry", ®istry]);
|
||
c
|
||
} else {
|
||
let mut c = Command::new("npm");
|
||
c.args(["--registry", ®istry]);
|
||
c
|
||
};
|
||
cmd.env("PATH", super::enhanced_path());
|
||
cmd
|
||
}
|
||
}
|
||
|
||
fn backups_dir() -> PathBuf {
|
||
super::openclaw_dir().join("backups")
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn read_openclaw_config() -> Result<Value, String> {
|
||
let path = super::openclaw_dir().join("openclaw.json");
|
||
let raw = fs::read(&path).map_err(|e| format!("读取配置失败: {e}"))?;
|
||
|
||
// 自愈:自动剥离 UTF-8 BOM(EF BB BF),防止 JSON 解析失败
|
||
let content = if raw.starts_with(&[0xEF, 0xBB, 0xBF]) {
|
||
String::from_utf8_lossy(&raw[3..]).into_owned()
|
||
} else {
|
||
String::from_utf8_lossy(&raw).into_owned()
|
||
};
|
||
|
||
// 解析 JSON,失败时尝试从备份恢复
|
||
let mut config: Value = match serde_json::from_str(&content) {
|
||
Ok(v) => {
|
||
// BOM 被剥离过,静默写回干净文件
|
||
if raw.starts_with(&[0xEF, 0xBB, 0xBF]) {
|
||
let _ = fs::write(&path, &content);
|
||
}
|
||
v
|
||
}
|
||
Err(e) => {
|
||
// JSON 解析失败,尝试从备份恢复
|
||
let bak = super::openclaw_dir().join("openclaw.json.bak");
|
||
if bak.exists() {
|
||
let bak_raw = fs::read(&bak).map_err(|e2| format!("备份也读取失败: {e2}"))?;
|
||
let bak_content = if bak_raw.starts_with(&[0xEF, 0xBB, 0xBF]) {
|
||
String::from_utf8_lossy(&bak_raw[3..]).into_owned()
|
||
} else {
|
||
String::from_utf8_lossy(&bak_raw).into_owned()
|
||
};
|
||
let bak_config: Value = serde_json::from_str(&bak_content)
|
||
.map_err(|e2| format!("配置损坏且备份也无效: 原始={e}, 备份={e2}"))?;
|
||
// 备份有效,恢复主文件
|
||
let _ = fs::write(&path, &bak_content);
|
||
bak_config
|
||
} else {
|
||
return Err(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)
|
||
}
|
||
|
||
/// 供其他模块复用:读取 openclaw.json 为 JSON Value
|
||
pub fn load_openclaw_json() -> Result<Value, String> {
|
||
read_openclaw_config()
|
||
}
|
||
|
||
/// 供其他模块复用:将 JSON Value 写回 openclaw.json(含备份和清理)
|
||
pub fn save_openclaw_json(config: &Value) -> Result<(), String> {
|
||
write_openclaw_config(config.clone())
|
||
}
|
||
|
||
/// 供其他模块复用:触发 Gateway 重载
|
||
pub async fn do_reload_gateway(app: &tauri::AppHandle) -> Result<String, String> {
|
||
let _ = app; // 预留扩展用
|
||
reload_gateway().await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn write_openclaw_config(config: Value) -> Result<(), String> {
|
||
let path = super::openclaw_dir().join("openclaw.json");
|
||
// 备份
|
||
let bak = super::openclaw_dir().join("openclaw.json.bak");
|
||
let _ = fs::copy(&path, &bak);
|
||
// 清理 UI 专属字段,避免 CLI schema 校验失败
|
||
let cleaned = strip_ui_fields(config.clone());
|
||
// 写入
|
||
let json = serde_json::to_string_pretty(&cleaned).map_err(|e| format!("序列化失败: {e}"))?;
|
||
fs::write(&path, &json).map_err(|e| format!("写入失败: {e}"))?;
|
||
|
||
// 同步 provider 配置到所有 agent 的 models.json(运行时注册表)
|
||
sync_providers_to_agent_models(&config);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 将 openclaw.json 的 models.providers 完整同步到每个 agent 的 models.json
|
||
/// 包括:同步 baseUrl/apiKey/api、删除已移除的 provider、删除已移除的 model、
|
||
/// 确保 Gateway 运行时不会引用 openclaw.json 中已不存在的模型
|
||
fn sync_providers_to_agent_models(config: &Value) {
|
||
let src_providers = config
|
||
.pointer("/models/providers")
|
||
.and_then(|p| p.as_object());
|
||
|
||
// 收集 openclaw.json 中所有有效的 provider/model 组合
|
||
let mut valid_models: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||
if let Some(providers) = src_providers {
|
||
for (pk, pv) in providers {
|
||
if let Some(models) = pv.get("models").and_then(|m| m.as_array()) {
|
||
for m in models {
|
||
let id = m.get("id").and_then(|v| v.as_str()).or_else(|| m.as_str());
|
||
if let Some(id) = id {
|
||
valid_models.insert(format!("{}/{}", pk, id));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 收集所有 agent ID
|
||
let mut agent_ids = vec!["main".to_string()];
|
||
if let Some(Value::Array(list)) = config.pointer("/agents/list") {
|
||
for agent in list {
|
||
if let Some(id) = agent.get("id").and_then(|v| v.as_str()) {
|
||
if id != "main" {
|
||
agent_ids.push(id.to_string());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let agents_dir = super::openclaw_dir().join("agents");
|
||
for agent_id in &agent_ids {
|
||
let models_path = agents_dir.join(agent_id).join("agent").join("models.json");
|
||
if !models_path.exists() {
|
||
continue;
|
||
}
|
||
let Ok(content) = fs::read_to_string(&models_path) else {
|
||
continue;
|
||
};
|
||
let Ok(mut models_json) = serde_json::from_str::<Value>(&content) else {
|
||
continue;
|
||
};
|
||
|
||
let mut changed = false;
|
||
|
||
if models_json
|
||
.get("providers")
|
||
.and_then(|p| p.as_object())
|
||
.is_none()
|
||
{
|
||
if let Some(root) = models_json.as_object_mut() {
|
||
root.insert("providers".into(), json!({}));
|
||
changed = true;
|
||
}
|
||
}
|
||
|
||
// 同步 providers
|
||
if let Some(dst_providers) = models_json
|
||
.get_mut("providers")
|
||
.and_then(|p| p.as_object_mut())
|
||
{
|
||
// 1. 删除 openclaw.json 中已不存在的 provider
|
||
if let Some(src) = src_providers {
|
||
let to_remove: Vec<String> = dst_providers
|
||
.keys()
|
||
.filter(|k| !src.contains_key(k.as_str()))
|
||
.cloned()
|
||
.collect();
|
||
for k in to_remove {
|
||
dst_providers.remove(&k);
|
||
changed = true;
|
||
}
|
||
|
||
for (provider_name, src_provider) in src.iter() {
|
||
if !dst_providers.contains_key(provider_name) {
|
||
dst_providers.insert(provider_name.clone(), src_provider.clone());
|
||
changed = true;
|
||
}
|
||
}
|
||
|
||
// 2. 同步存在的 provider 的 baseUrl/apiKey/api + 清理已删除的 models
|
||
for (provider_name, src_provider) in src.iter() {
|
||
if let Some(dst_provider) = dst_providers.get_mut(provider_name) {
|
||
if let Some(dst_obj) = dst_provider.as_object_mut() {
|
||
// 同步连接信息
|
||
for field in ["baseUrl", "apiKey", "api"] {
|
||
if let Some(src_val) =
|
||
src_provider.get(field).and_then(|v| v.as_str())
|
||
{
|
||
if dst_obj.get(field).and_then(|v| v.as_str()) != Some(src_val)
|
||
{
|
||
dst_obj.insert(
|
||
field.to_string(),
|
||
Value::String(src_val.to_string()),
|
||
);
|
||
changed = true;
|
||
}
|
||
}
|
||
}
|
||
// 清理已删除的 models
|
||
if let Some(dst_models) =
|
||
dst_obj.get_mut("models").and_then(|m| m.as_array_mut())
|
||
{
|
||
let src_model_ids: std::collections::HashSet<String> = src_provider
|
||
.get("models")
|
||
.and_then(|m| m.as_array())
|
||
.map(|arr| {
|
||
arr.iter()
|
||
.filter_map(|m| {
|
||
m.get("id")
|
||
.and_then(|v| v.as_str())
|
||
.or_else(|| m.as_str())
|
||
.map(|s| s.to_string())
|
||
})
|
||
.collect()
|
||
})
|
||
.unwrap_or_default();
|
||
let before = dst_models.len();
|
||
dst_models.retain(|m| {
|
||
let id = m
|
||
.get("id")
|
||
.and_then(|v| v.as_str())
|
||
.or_else(|| m.as_str())
|
||
.unwrap_or("");
|
||
src_model_ids.contains(id)
|
||
});
|
||
if dst_models.len() != before {
|
||
changed = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if changed {
|
||
if let Ok(new_json) = serde_json::to_string_pretty(&models_json) {
|
||
let _ = fs::write(&models_path, new_json);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 检测配置中是否包含 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()),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
val
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn read_mcp_config() -> Result<Value, String> {
|
||
let path = super::openclaw_dir().join("mcp.json");
|
||
if !path.exists() {
|
||
return Ok(Value::Object(Default::default()));
|
||
}
|
||
let content = fs::read_to_string(&path).map_err(|e| format!("读取 MCP 配置失败: {e}"))?;
|
||
serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {e}"))
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn write_mcp_config(config: Value) -> Result<(), String> {
|
||
let path = super::openclaw_dir().join("mcp.json");
|
||
let json = serde_json::to_string_pretty(&config).map_err(|e| format!("序列化失败: {e}"))?;
|
||
fs::write(&path, json).map_err(|e| format!("写入失败: {e}"))
|
||
}
|
||
|
||
/// 获取本地安装的 openclaw 版本号(异步版本)
|
||
/// macOS: 优先从 npm 包的 package.json 读取(含完整后缀),fallback 到 CLI
|
||
/// Windows/Linux: 优先读文件系统,fallback 到 CLI
|
||
async fn get_local_version() -> Option<String> {
|
||
// macOS: 通过 symlink 找到包目录,读 package.json 的 version
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
if let Ok(target) = fs::read_link("/opt/homebrew/bin/openclaw") {
|
||
let pkg_json = PathBuf::from("/opt/homebrew/bin")
|
||
.join(&target)
|
||
.parent()?
|
||
.join("package.json");
|
||
if let Ok(content) = fs::read_to_string(&pkg_json) {
|
||
if let Some(ver) = serde_json::from_str::<Value>(&content)
|
||
.ok()
|
||
.and_then(|v| v.get("version")?.as_str().map(String::from))
|
||
{
|
||
return Some(ver);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Windows: 直接读 npm 全局目录下的 package.json,避免 spawn 进程
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
if let Ok(appdata) = std::env::var("APPDATA") {
|
||
// 先查汉化版,再查官方版
|
||
for pkg in &["@qingchencloud/openclaw-zh", "openclaw"] {
|
||
let pkg_json = PathBuf::from(&appdata)
|
||
.join("npm")
|
||
.join("node_modules")
|
||
.join(pkg)
|
||
.join("package.json");
|
||
if let Ok(content) = fs::read_to_string(&pkg_json) {
|
||
if let Some(ver) = serde_json::from_str::<Value>(&content)
|
||
.ok()
|
||
.and_then(|v| v.get("version")?.as_str().map(String::from))
|
||
{
|
||
return Some(ver);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 所有平台通用 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)
|
||
}
|
||
|
||
/// 从 npm registry 获取最新版本号,超时 5 秒
|
||
async fn get_latest_version_for(source: &str) -> Option<String> {
|
||
let client = reqwest::Client::builder()
|
||
.timeout(std::time::Duration::from_secs(2))
|
||
.build()
|
||
.ok()?;
|
||
let pkg = npm_package_name(source)
|
||
.replace('/', "%2F")
|
||
.replace('@', "%40");
|
||
let registry = get_configured_registry();
|
||
let url = format!("{registry}/{pkg}/latest");
|
||
let resp = client.get(&url).send().await.ok()?;
|
||
let json: Value = resp.json().await.ok()?;
|
||
json.get("version")
|
||
.and_then(|v| v.as_str())
|
||
.map(String::from)
|
||
}
|
||
|
||
/// 检测当前安装的是官方版还是汉化版
|
||
/// macOS: 优先检查 homebrew symlink,fallback 到 npm list
|
||
/// Windows: 优先检查 npm 全局目录下的 package.json,避免调用 npm list 阻塞
|
||
/// Linux: 直接用 npm list
|
||
fn detect_installed_source() -> String {
|
||
// macOS: 检查 openclaw bin 的 symlink 指向
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
if let Ok(target) = std::fs::read_link("/opt/homebrew/bin/openclaw") {
|
||
if target.to_string_lossy().contains("openclaw-zh") {
|
||
return "chinese".into();
|
||
}
|
||
return "official".into();
|
||
}
|
||
"official".into()
|
||
}
|
||
// Windows: 优先通过文件系统检测,避免 npm list 阻塞
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
if let Some(appdata) = std::env::var_os("APPDATA") {
|
||
let zh_dir = PathBuf::from(&appdata)
|
||
.join("npm")
|
||
.join("node_modules")
|
||
.join("@qingchencloud")
|
||
.join("openclaw-zh");
|
||
if zh_dir.exists() {
|
||
return "chinese".into();
|
||
}
|
||
}
|
||
"official".into()
|
||
}
|
||
// 所有平台通用: npm list 检测
|
||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||
{
|
||
if let Ok(o) = npm_command()
|
||
.args(["list", "-g", "@qingchencloud/openclaw-zh", "--depth=0"])
|
||
.output()
|
||
{
|
||
if String::from_utf8_lossy(&o.stdout).contains("openclaw-zh@") {
|
||
return "chinese".into();
|
||
}
|
||
}
|
||
"official".into()
|
||
}
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn get_version_info() -> Result<VersionInfo, String> {
|
||
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> {
|
||
v.split(|c: char| !c.is_ascii_digit())
|
||
.filter_map(|s| s.parse().ok())
|
||
.collect()
|
||
};
|
||
let update_available = match (¤t, &latest) {
|
||
(Some(c), Some(l)) => parse_ver(l) > parse_ver(c),
|
||
_ => false,
|
||
};
|
||
Ok(VersionInfo {
|
||
current,
|
||
latest,
|
||
update_available,
|
||
source,
|
||
})
|
||
}
|
||
|
||
/// 获取 OpenClaw 运行时状态摘要(openclaw status --json)
|
||
/// 包含 runtimeVersion、会话列表(含 token 用量、fastMode 等标签)
|
||
#[tauri::command]
|
||
pub async fn get_status_summary() -> Result<Value, String> {
|
||
let output = crate::utils::openclaw_command_async()
|
||
.args(["status", "--json"])
|
||
.output()
|
||
.await;
|
||
|
||
match output {
|
||
Ok(o) if o.status.success() => {
|
||
let stdout = String::from_utf8_lossy(&o.stdout);
|
||
// CLI 输出可能含非 JSON 行,复用 skills 模块的 extract_json
|
||
crate::commands::skills::extract_json_pub(&stdout)
|
||
.ok_or_else(|| "解析失败: 输出中未找到有效 JSON".to_string())
|
||
}
|
||
Ok(o) => {
|
||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||
Err(format!("openclaw status 失败: {}", stderr.trim()))
|
||
}
|
||
Err(e) => Err(format!("执行 openclaw 失败: {e}")),
|
||
}
|
||
}
|
||
|
||
/// npm 包名映射
|
||
fn npm_package_name(source: &str) -> &'static str {
|
||
match source {
|
||
"official" => "openclaw",
|
||
_ => "@qingchencloud/openclaw-zh",
|
||
}
|
||
}
|
||
|
||
/// 获取指定源的所有可用版本列表(从 npm registry 查询)
|
||
#[tauri::command]
|
||
pub async fn list_openclaw_versions(source: String) -> Result<Vec<String>, String> {
|
||
let client = reqwest::Client::builder()
|
||
.timeout(std::time::Duration::from_secs(10))
|
||
.build()
|
||
.map_err(|e| format!("HTTP 初始化失败: {e}"))?;
|
||
let pkg = npm_package_name(&source).replace('/', "%2F");
|
||
let registry = get_configured_registry();
|
||
let url = format!("{registry}/{pkg}");
|
||
let resp = client
|
||
.get(&url)
|
||
.header("Accept", "application/json")
|
||
.send()
|
||
.await
|
||
.map_err(|e| format!("查询版本失败: {e}"))?;
|
||
let json: Value = resp
|
||
.json()
|
||
.await
|
||
.map_err(|e| format!("解析响应失败: {e}"))?;
|
||
let versions = json
|
||
.get("versions")
|
||
.and_then(|v| v.as_object())
|
||
.map(|obj| {
|
||
let mut vers: Vec<String> = obj.keys().cloned().collect();
|
||
// 按版本号排序(新版本在前)
|
||
vers.sort_by(|a, b| {
|
||
let pa: Vec<u32> = a
|
||
.split(|c: char| !c.is_ascii_digit())
|
||
.filter_map(|s| s.parse().ok())
|
||
.collect();
|
||
let pb: Vec<u32> = b
|
||
.split(|c: char| !c.is_ascii_digit())
|
||
.filter_map(|s| s.parse().ok())
|
||
.collect();
|
||
pb.cmp(&pa)
|
||
});
|
||
vers
|
||
})
|
||
.unwrap_or_default();
|
||
Ok(versions)
|
||
}
|
||
|
||
/// 执行 npm 全局安装/升级/降级 openclaw(流式推送日志)
|
||
#[tauri::command]
|
||
pub async fn upgrade_openclaw(
|
||
app: tauri::AppHandle,
|
||
source: String,
|
||
version: Option<String>,
|
||
) -> Result<String, String> {
|
||
use std::io::{BufRead, BufReader};
|
||
use std::process::Stdio;
|
||
use tauri::Emitter;
|
||
let _guardian_pause = GuardianPause::new("upgrade");
|
||
|
||
let current_source = detect_installed_source();
|
||
let pkg_name = npm_package_name(&source);
|
||
let ver = version.as_deref().unwrap_or("latest");
|
||
let pkg = format!("{}@{}", pkg_name, ver);
|
||
|
||
// 切换源时需要卸载旧包,但为避免安装失败导致 CLI 丢失,
|
||
// 先安装新包,成功后再卸载旧包
|
||
let old_pkg = npm_package_name(¤t_source);
|
||
let need_uninstall_old = current_source != source;
|
||
|
||
// 自动配置 git 全面使用 HTTPS 替代 SSH/git 协议,避免用户没配 SSH Key 导致依赖安装失败
|
||
let _ = app.emit("upgrade-log", "配置 Git HTTPS 模式...");
|
||
// 先清除旧的 insteadOf 规则,再逐条添加(git config 不带 --add 会覆盖,只保留最后一条)
|
||
let _ = Command::new("git")
|
||
.args([
|
||
"config",
|
||
"--global",
|
||
"--unset-all",
|
||
"url.https://github.com/.insteadOf",
|
||
])
|
||
.output();
|
||
for from in &[
|
||
"ssh://git@github.com/",
|
||
"git@github.com:",
|
||
"git://github.com/",
|
||
"git+ssh://git@github.com/",
|
||
] {
|
||
let _ = Command::new("git")
|
||
.args([
|
||
"config",
|
||
"--global",
|
||
"--add",
|
||
"url.https://github.com/.insteadOf",
|
||
from,
|
||
])
|
||
.output();
|
||
}
|
||
|
||
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, "--registry", registry, "--verbose"])
|
||
.env("GIT_TERMINAL_PROMPT", "0")
|
||
.env(
|
||
"GIT_SSH_COMMAND",
|
||
"ssh -o BatchMode=yes -o StrictHostKeyChecking=no",
|
||
)
|
||
// Force HTTPS insteadOf via env vars — ensures npm-spawned git subprocesses also use HTTPS
|
||
// even if global git config didn't take effect (e.g. git not in PATH, or Windows permission issues)
|
||
.env("GIT_CONFIG_COUNT", "4")
|
||
.env("GIT_CONFIG_KEY_0", "url.https://github.com/.insteadOf")
|
||
.env("GIT_CONFIG_VALUE_0", "ssh://git@github.com/")
|
||
.env("GIT_CONFIG_KEY_1", "url.https://github.com/.insteadOf")
|
||
.env("GIT_CONFIG_VALUE_1", "git@github.com:")
|
||
.env("GIT_CONFIG_KEY_2", "url.https://github.com/.insteadOf")
|
||
.env("GIT_CONFIG_VALUE_2", "git://github.com/")
|
||
.env("GIT_CONFIG_KEY_3", "url.https://github.com/.insteadOf")
|
||
.env("GIT_CONFIG_VALUE_3", "git+ssh://git@github.com/")
|
||
.stdout(Stdio::piped())
|
||
.stderr(Stdio::piped())
|
||
.spawn()
|
||
.map_err(|e| format!("执行升级命令失败: {e}"))?;
|
||
|
||
let stderr = child.stderr.take();
|
||
let stdout = child.stdout.take();
|
||
|
||
// stderr 每行递增进度(10→80 区间),让用户看到进度在动
|
||
// 同时收集 stderr 用于失败时返回给前端诊断
|
||
let app2 = app.clone();
|
||
let stderr_lines = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
|
||
let stderr_lines2 = stderr_lines.clone();
|
||
let handle = std::thread::spawn(move || {
|
||
let mut progress: u32 = 15;
|
||
if let Some(pipe) = stderr {
|
||
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
|
||
let _ = app2.emit("upgrade-log", &line);
|
||
stderr_lines2.lock().unwrap().push(line);
|
||
if progress < 75 {
|
||
progress += 2;
|
||
let _ = app2.emit("upgrade-progress", progress);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
if let Some(pipe) = stdout {
|
||
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
|
||
let _ = app.emit("upgrade-log", &line);
|
||
}
|
||
}
|
||
|
||
let _ = handle.join();
|
||
let _ = app.emit("upgrade-progress", 80);
|
||
|
||
let status = child.wait().map_err(|e| format!("等待进程失败: {e}"))?;
|
||
let _ = app.emit("upgrade-progress", 100);
|
||
|
||
if !status.success() {
|
||
let code = status
|
||
.code()
|
||
.map(|c| c.to_string())
|
||
.unwrap_or("unknown".into());
|
||
|
||
// 如果使用了镜像源失败,自动降级到官方源重试
|
||
let used_mirror = registry.contains("npmmirror.com") || registry.contains("taobao.org");
|
||
if used_mirror {
|
||
let _ = app.emit("upgrade-log", "");
|
||
let _ = app.emit("upgrade-log", "⚠️ 镜像源安装失败,自动切换到官方源重试...");
|
||
let _ = app.emit("upgrade-progress", 15);
|
||
let fallback = "https://registry.npmjs.org";
|
||
let mut child2 = npm_command()
|
||
.args(["install", "-g", &pkg, "--registry", fallback, "--verbose"])
|
||
.env("GIT_TERMINAL_PROMPT", "0")
|
||
.env(
|
||
"GIT_SSH_COMMAND",
|
||
"ssh -o BatchMode=yes -o StrictHostKeyChecking=no",
|
||
)
|
||
.env("GIT_CONFIG_COUNT", "4")
|
||
.env("GIT_CONFIG_KEY_0", "url.https://github.com/.insteadOf")
|
||
.env("GIT_CONFIG_VALUE_0", "ssh://git@github.com/")
|
||
.env("GIT_CONFIG_KEY_1", "url.https://github.com/.insteadOf")
|
||
.env("GIT_CONFIG_VALUE_1", "git@github.com:")
|
||
.env("GIT_CONFIG_KEY_2", "url.https://github.com/.insteadOf")
|
||
.env("GIT_CONFIG_VALUE_2", "git://github.com/")
|
||
.env("GIT_CONFIG_KEY_3", "url.https://github.com/.insteadOf")
|
||
.env("GIT_CONFIG_VALUE_3", "git+ssh://git@github.com/")
|
||
.stdout(Stdio::piped())
|
||
.stderr(Stdio::piped())
|
||
.spawn()
|
||
.map_err(|e| format!("执行重试命令失败: {e}"))?;
|
||
let stderr2 = child2.stderr.take();
|
||
let stdout2 = child2.stdout.take();
|
||
let app3 = app.clone();
|
||
let stderr_lines3 = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
|
||
let stderr_lines4 = stderr_lines3.clone();
|
||
let handle2 = std::thread::spawn(move || {
|
||
if let Some(pipe) = stderr2 {
|
||
let mut p: u32 = 20;
|
||
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
|
||
let _ = app3.emit("upgrade-log", &line);
|
||
stderr_lines4.lock().unwrap().push(line);
|
||
if p < 75 {
|
||
p += 2;
|
||
let _ = app3.emit("upgrade-progress", p);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
if let Some(pipe) = stdout2 {
|
||
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
|
||
let _ = app.emit("upgrade-log", &line);
|
||
}
|
||
}
|
||
let _ = handle2.join();
|
||
let _ = app.emit("upgrade-progress", 80);
|
||
let status2 = child2
|
||
.wait()
|
||
.map_err(|e| format!("等待重试进程失败: {e}"))?;
|
||
let _ = app.emit("upgrade-progress", 100);
|
||
if !status2.success() {
|
||
let code2 = status2
|
||
.code()
|
||
.map(|c| c.to_string())
|
||
.unwrap_or("unknown".into());
|
||
let tail = stderr_lines3
|
||
.lock()
|
||
.unwrap()
|
||
.iter()
|
||
.rev()
|
||
.take(15)
|
||
.rev()
|
||
.cloned()
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
return Err(format!(
|
||
"升级失败(镜像源和官方源均失败),exit code: {code2}\n{tail}"
|
||
));
|
||
}
|
||
let _ = app.emit("upgrade-log", "✅ 官方源安装成功");
|
||
} else {
|
||
let _ = app.emit("upgrade-log", format!("❌ 升级失败 (exit code: {code})"));
|
||
let tail = stderr_lines
|
||
.lock()
|
||
.unwrap()
|
||
.iter()
|
||
.rev()
|
||
.take(15)
|
||
.rev()
|
||
.cloned()
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
return Err(format!("升级失败,exit code: {code}\n{tail}"));
|
||
}
|
||
}
|
||
|
||
// 安装成功后再卸载旧包(确保 CLI 始终可用)
|
||
if need_uninstall_old {
|
||
let _ = app.emit("upgrade-log", format!("清理旧版本 ({old_pkg})..."));
|
||
let _ = npm_command().args(["uninstall", "-g", old_pkg]).output();
|
||
}
|
||
|
||
// 切换源后重装 Gateway 服务
|
||
if need_uninstall_old {
|
||
let _ = app.emit("upgrade-log", "正在重装 Gateway 服务(更新启动路径)...");
|
||
// 先停掉旧的
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
let uid = get_uid().unwrap_or(501);
|
||
let _ = Command::new("launchctl")
|
||
.args(["bootout", &format!("gui/{uid}/ai.openclaw.gateway")])
|
||
.output();
|
||
}
|
||
#[cfg(not(target_os = "macos"))]
|
||
{
|
||
let _ = openclaw_command().args(["gateway", "stop"]).output();
|
||
}
|
||
// 重新安装
|
||
use crate::utils::openclaw_command_async;
|
||
let gw_out = openclaw_command_async()
|
||
.args(["gateway", "install"])
|
||
.output()
|
||
.await;
|
||
match gw_out {
|
||
Ok(o) if o.status.success() => {
|
||
let _ = app.emit("upgrade-log", "Gateway 服务已重装");
|
||
}
|
||
_ => {
|
||
let _ = app.emit(
|
||
"upgrade-log",
|
||
"⚠️ Gateway 重装失败,请手动执行 openclaw gateway install",
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
let new_ver = get_local_version().await.unwrap_or_else(|| "未知".into());
|
||
let action = if ver == "latest" { "升级" } else { "安装" };
|
||
let msg = format!("✅ {action}成功,当前版本: {new_ver}");
|
||
let _ = app.emit("upgrade-log", &msg);
|
||
Ok(msg)
|
||
}
|
||
|
||
/// 卸载 OpenClaw(npm uninstall + 可选清理配置)
|
||
#[tauri::command]
|
||
pub async fn uninstall_openclaw(
|
||
app: tauri::AppHandle,
|
||
clean_config: bool,
|
||
) -> Result<String, String> {
|
||
use std::io::{BufRead, BufReader};
|
||
use std::process::Stdio;
|
||
use tauri::Emitter;
|
||
let _guardian_pause = GuardianPause::new("uninstall openclaw");
|
||
crate::commands::service::guardian_mark_manual_stop();
|
||
|
||
let source = detect_installed_source();
|
||
let pkg = npm_package_name(&source);
|
||
|
||
// 1. 先停止 Gateway
|
||
let _ = app.emit("upgrade-log", "正在停止 Gateway...");
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
let uid = get_uid().unwrap_or(501);
|
||
let _ = Command::new("launchctl")
|
||
.args(["bootout", &format!("gui/{uid}/ai.openclaw.gateway")])
|
||
.output();
|
||
}
|
||
#[cfg(not(target_os = "macos"))]
|
||
{
|
||
let _ = openclaw_command().args(["gateway", "stop"]).output();
|
||
}
|
||
|
||
// 2. 卸载 Gateway 服务
|
||
let _ = app.emit("upgrade-log", "正在卸载 Gateway 服务...");
|
||
#[cfg(not(target_os = "macos"))]
|
||
{
|
||
let _ = openclaw_command().args(["gateway", "uninstall"]).output();
|
||
}
|
||
|
||
// 3. npm uninstall
|
||
let _ = app.emit("upgrade-log", format!("$ npm uninstall -g {pkg}"));
|
||
let _ = app.emit("upgrade-progress", 20);
|
||
|
||
let mut child = npm_command()
|
||
.args(["uninstall", "-g", pkg])
|
||
.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("upgrade-log", &line);
|
||
}
|
||
}
|
||
});
|
||
|
||
if let Some(pipe) = stdout {
|
||
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
|
||
let _ = app.emit("upgrade-log", &line);
|
||
}
|
||
}
|
||
|
||
let _ = handle.join();
|
||
let _ = app.emit("upgrade-progress", 60);
|
||
|
||
let status = child.wait().map_err(|e| format!("等待进程失败: {e}"))?;
|
||
if !status.success() {
|
||
let code = status
|
||
.code()
|
||
.map(|c| c.to_string())
|
||
.unwrap_or("unknown".into());
|
||
return Err(format!("卸载失败,exit code: {code}"));
|
||
}
|
||
|
||
// 4. 两个包都尝试卸载(确保干净)
|
||
let other_pkg = if source == "official" {
|
||
"@qingchencloud/openclaw-zh"
|
||
} else {
|
||
"openclaw"
|
||
};
|
||
let _ = app.emit("upgrade-log", format!("清理 {other_pkg}..."));
|
||
let _ = npm_command().args(["uninstall", "-g", other_pkg]).output();
|
||
let _ = app.emit("upgrade-progress", 80);
|
||
|
||
// 5. 可选:清理配置目录
|
||
if clean_config {
|
||
let config_dir = super::openclaw_dir();
|
||
if config_dir.exists() {
|
||
let _ = app.emit(
|
||
"upgrade-log",
|
||
format!("清理配置目录: {}", config_dir.display()),
|
||
);
|
||
if let Err(e) = std::fs::remove_dir_all(&config_dir) {
|
||
let _ = app.emit(
|
||
"upgrade-log",
|
||
format!("⚠️ 清理配置目录失败: {e}(可能有文件被占用)"),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
let _ = app.emit("upgrade-progress", 100);
|
||
let msg = if clean_config {
|
||
"✅ OpenClaw 已完全卸载(包括配置文件)"
|
||
} else {
|
||
"✅ OpenClaw 已卸载(配置文件保留在 ~/.openclaw/)"
|
||
};
|
||
let _ = app.emit("upgrade-log", msg);
|
||
Ok(msg.into())
|
||
}
|
||
|
||
/// 自动初始化配置文件(CLI 已装但 openclaw.json 不存在时)
|
||
#[tauri::command]
|
||
pub fn init_openclaw_config() -> Result<Value, String> {
|
||
let dir = super::openclaw_dir();
|
||
let config_path = dir.join("openclaw.json");
|
||
let mut result = serde_json::Map::new();
|
||
|
||
if config_path.exists() {
|
||
result.insert("created".into(), Value::Bool(false));
|
||
result.insert("message".into(), Value::String("配置文件已存在".into()));
|
||
return Ok(Value::Object(result));
|
||
}
|
||
|
||
// 确保目录存在
|
||
if !dir.exists() {
|
||
std::fs::create_dir_all(&dir).map_err(|e| format!("创建目录失败: {e}"))?;
|
||
}
|
||
|
||
let default_config = serde_json::json!({
|
||
"$schema": "https://openclaw.ai/schema/config.json",
|
||
"meta": { "lastTouchedVersion": "2026.1.1" },
|
||
"models": { "providers": {} },
|
||
"gateway": {
|
||
"mode": "local",
|
||
"port": 18789,
|
||
"auth": { "mode": "none" },
|
||
"controlUi": { "allowedOrigins": ["*"], "allowInsecureAuth": true }
|
||
},
|
||
"tools": { "profile": "full", "sessions": { "visibility": "all" } }
|
||
});
|
||
|
||
let content =
|
||
serde_json::to_string_pretty(&default_config).map_err(|e| format!("序列化失败: {e}"))?;
|
||
std::fs::write(&config_path, content).map_err(|e| format!("写入失败: {e}"))?;
|
||
|
||
result.insert("created".into(), Value::Bool(true));
|
||
result.insert("message".into(), Value::String("配置文件已创建".into()));
|
||
Ok(Value::Object(result))
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn check_installation() -> Result<Value, String> {
|
||
let dir = super::openclaw_dir();
|
||
let installed = dir.join("openclaw.json").exists();
|
||
let mut result = serde_json::Map::new();
|
||
result.insert("installed".into(), Value::Bool(installed));
|
||
result.insert(
|
||
"path".into(),
|
||
Value::String(dir.to_string_lossy().to_string()),
|
||
);
|
||
Ok(Value::Object(result))
|
||
}
|
||
|
||
/// 检测 Node.js 是否已安装,返回版本号
|
||
#[tauri::command]
|
||
pub fn check_node() -> Result<Value, String> {
|
||
let mut result = serde_json::Map::new();
|
||
let mut cmd = Command::new("node");
|
||
cmd.arg("--version");
|
||
cmd.env("PATH", super::enhanced_path());
|
||
#[cfg(target_os = "windows")]
|
||
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||
match cmd.output() {
|
||
Ok(o) if o.status.success() => {
|
||
let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||
result.insert("installed".into(), Value::Bool(true));
|
||
result.insert("version".into(), Value::String(ver));
|
||
}
|
||
_ => {
|
||
result.insert("installed".into(), Value::Bool(false));
|
||
result.insert("version".into(), Value::Null);
|
||
}
|
||
}
|
||
Ok(Value::Object(result))
|
||
}
|
||
|
||
/// 在指定路径下检测 node 是否存在
|
||
#[tauri::command]
|
||
pub fn check_node_at_path(node_dir: String) -> Result<Value, String> {
|
||
let dir = std::path::PathBuf::from(&node_dir);
|
||
#[cfg(target_os = "windows")]
|
||
let node_bin = dir.join("node.exe");
|
||
#[cfg(not(target_os = "windows"))]
|
||
let node_bin = dir.join("node");
|
||
|
||
let mut result = serde_json::Map::new();
|
||
if !node_bin.exists() {
|
||
result.insert("installed".into(), Value::Bool(false));
|
||
result.insert("version".into(), Value::Null);
|
||
return Ok(Value::Object(result));
|
||
}
|
||
|
||
let mut cmd = Command::new(&node_bin);
|
||
cmd.arg("--version");
|
||
#[cfg(target_os = "windows")]
|
||
cmd.creation_flags(0x08000000);
|
||
match cmd.output() {
|
||
Ok(o) if o.status.success() => {
|
||
let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||
result.insert("installed".into(), Value::Bool(true));
|
||
result.insert("version".into(), Value::String(ver));
|
||
result.insert("path".into(), Value::String(node_dir));
|
||
}
|
||
_ => {
|
||
result.insert("installed".into(), Value::Bool(false));
|
||
result.insert("version".into(), Value::Null);
|
||
}
|
||
}
|
||
Ok(Value::Object(result))
|
||
}
|
||
|
||
/// 扫描常见路径,返回所有找到的 Node.js 安装
|
||
#[tauri::command]
|
||
pub fn scan_node_paths() -> Result<Value, String> {
|
||
let mut found: Vec<Value> = vec![];
|
||
let home = dirs::home_dir().unwrap_or_default();
|
||
|
||
let mut candidates: Vec<String> = vec![];
|
||
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
let pf = std::env::var("ProgramFiles").unwrap_or_else(|_| r"C:\Program Files".into());
|
||
let pf86 =
|
||
std::env::var("ProgramFiles(x86)").unwrap_or_else(|_| r"C:\Program Files (x86)".into());
|
||
let localappdata = std::env::var("LOCALAPPDATA").unwrap_or_default();
|
||
let appdata = std::env::var("APPDATA").unwrap_or_default();
|
||
|
||
candidates.push(format!(r"{}\nodejs", pf));
|
||
candidates.push(format!(r"{}\nodejs", pf86));
|
||
if !localappdata.is_empty() {
|
||
candidates.push(format!(r"{}\Programs\nodejs", localappdata));
|
||
}
|
||
if !appdata.is_empty() {
|
||
candidates.push(format!(r"{}\npm", appdata));
|
||
}
|
||
candidates.push(format!(r"{}\.volta\bin", home.display()));
|
||
candidates.push(format!(r"{}\.nvm", home.display()));
|
||
|
||
for drive in &["C", "D", "E", "F", "G"] {
|
||
candidates.push(format!(r"{}:\nodejs", drive));
|
||
candidates.push(format!(r"{}:\Node", drive));
|
||
candidates.push(format!(r"{}:\Node.js", drive));
|
||
candidates.push(format!(r"{}:\Program Files\nodejs", drive));
|
||
// 扫描常见 AI 工具目录
|
||
candidates.push(format!(r"{}:\AI\Node", drive));
|
||
candidates.push(format!(r"{}:\AI\nodejs", drive));
|
||
candidates.push(format!(r"{}:\Dev\nodejs", drive));
|
||
candidates.push(format!(r"{}:\Tools\nodejs", drive));
|
||
}
|
||
}
|
||
|
||
#[cfg(not(target_os = "windows"))]
|
||
{
|
||
candidates.push("/usr/local/bin".into());
|
||
candidates.push("/opt/homebrew/bin".into());
|
||
candidates.push(format!("{}/.nvm/current/bin", home.display()));
|
||
candidates.push(format!("{}/.volta/bin", home.display()));
|
||
candidates.push(format!("{}/.nodenv/shims", home.display()));
|
||
candidates.push(format!("{}/.fnm/current/bin", home.display()));
|
||
candidates.push(format!("{}/n/bin", home.display()));
|
||
}
|
||
|
||
for dir in &candidates {
|
||
let path = std::path::Path::new(dir);
|
||
#[cfg(target_os = "windows")]
|
||
let node_bin = path.join("node.exe");
|
||
#[cfg(not(target_os = "windows"))]
|
||
let node_bin = path.join("node");
|
||
|
||
if node_bin.exists() {
|
||
let mut cmd = Command::new(&node_bin);
|
||
cmd.arg("--version");
|
||
#[cfg(target_os = "windows")]
|
||
cmd.creation_flags(0x08000000);
|
||
if let Ok(o) = cmd.output() {
|
||
if o.status.success() {
|
||
let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||
let mut entry = serde_json::Map::new();
|
||
entry.insert("path".into(), Value::String(dir.clone()));
|
||
entry.insert("version".into(), Value::String(ver));
|
||
found.push(Value::Object(entry));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(Value::Array(found))
|
||
}
|
||
|
||
/// 保存用户自定义的 Node.js 路径到 ~/.openclaw/clawpanel.json
|
||
#[tauri::command]
|
||
pub fn save_custom_node_path(node_dir: String) -> Result<(), String> {
|
||
let config_path = super::openclaw_dir().join("clawpanel.json");
|
||
let mut config: serde_json::Map<String, Value> = if config_path.exists() {
|
||
let content =
|
||
std::fs::read_to_string(&config_path).map_err(|e| format!("读取配置失败: {e}"))?;
|
||
serde_json::from_str(&content).unwrap_or_default()
|
||
} else {
|
||
serde_json::Map::new()
|
||
};
|
||
config.insert("nodePath".into(), Value::String(node_dir));
|
||
let json = serde_json::to_string_pretty(&Value::Object(config))
|
||
.map_err(|e| format!("序列化失败: {e}"))?;
|
||
std::fs::write(&config_path, json).map_err(|e| format!("写入配置失败: {e}"))?;
|
||
// 立即刷新 PATH 缓存,使新路径生效(无需重启应用)
|
||
super::refresh_enhanced_path();
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn write_env_file(path: String, config: String) -> Result<(), String> {
|
||
let expanded = if let Some(stripped) = path.strip_prefix("~/") {
|
||
dirs::home_dir().unwrap_or_default().join(stripped)
|
||
} else {
|
||
PathBuf::from(&path)
|
||
};
|
||
|
||
// 安全限制:只允许写入 ~/.openclaw/ 目录下的文件
|
||
let openclaw_base = super::openclaw_dir();
|
||
if !expanded.starts_with(&openclaw_base) {
|
||
return Err("只允许写入 ~/.openclaw/ 目录下的文件".to_string());
|
||
}
|
||
|
||
if let Some(parent) = expanded.parent() {
|
||
let _ = fs::create_dir_all(parent);
|
||
}
|
||
fs::write(&expanded, &config).map_err(|e| format!("写入 .env 失败: {e}"))
|
||
}
|
||
|
||
// ===== 备份管理 =====
|
||
|
||
#[tauri::command]
|
||
pub fn list_backups() -> Result<Value, String> {
|
||
let dir = backups_dir();
|
||
if !dir.exists() {
|
||
return Ok(Value::Array(vec![]));
|
||
}
|
||
let mut backups: Vec<Value> = vec![];
|
||
let entries = fs::read_dir(&dir).map_err(|e| format!("读取备份目录失败: {e}"))?;
|
||
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||
continue;
|
||
}
|
||
let name = path
|
||
.file_name()
|
||
.unwrap_or_default()
|
||
.to_string_lossy()
|
||
.to_string();
|
||
let meta = fs::metadata(&path).ok();
|
||
let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
|
||
// macOS 支持 created(),fallback 到 modified()
|
||
let created = meta
|
||
.and_then(|m| m.created().ok().or_else(|| m.modified().ok()))
|
||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||
.map(|d| d.as_secs())
|
||
.unwrap_or(0);
|
||
|
||
let mut obj = serde_json::Map::new();
|
||
obj.insert("name".into(), Value::String(name));
|
||
obj.insert("size".into(), Value::Number(size.into()));
|
||
obj.insert("created_at".into(), Value::Number(created.into()));
|
||
backups.push(Value::Object(obj));
|
||
}
|
||
// 按时间倒序
|
||
backups.sort_by(|a, b| {
|
||
let ta = a.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0);
|
||
let tb = b.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0);
|
||
tb.cmp(&ta)
|
||
});
|
||
Ok(Value::Array(backups))
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn create_backup() -> Result<Value, String> {
|
||
let dir = backups_dir();
|
||
fs::create_dir_all(&dir).map_err(|e| format!("创建备份目录失败: {e}"))?;
|
||
|
||
let src = super::openclaw_dir().join("openclaw.json");
|
||
if !src.exists() {
|
||
return Err("openclaw.json 不存在".into());
|
||
}
|
||
|
||
let now = chrono::Local::now();
|
||
let name = format!("openclaw-{}.json", now.format("%Y%m%d-%H%M%S"));
|
||
let dest = dir.join(&name);
|
||
fs::copy(&src, &dest).map_err(|e| format!("备份失败: {e}"))?;
|
||
|
||
let size = fs::metadata(&dest).map(|m| m.len()).unwrap_or(0);
|
||
let mut obj = serde_json::Map::new();
|
||
obj.insert("name".into(), Value::String(name));
|
||
obj.insert("size".into(), Value::Number(size.into()));
|
||
Ok(Value::Object(obj))
|
||
}
|
||
|
||
/// 检查备份文件名是否安全
|
||
fn is_unsafe_backup_name(name: &str) -> bool {
|
||
name.contains("..") || name.contains('/') || name.contains('\\')
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn restore_backup(name: String) -> Result<(), String> {
|
||
if is_unsafe_backup_name(&name) {
|
||
return Err("非法文件名".into());
|
||
}
|
||
let backup_path = backups_dir().join(&name);
|
||
if !backup_path.exists() {
|
||
return Err(format!("备份文件不存在: {name}"));
|
||
}
|
||
let target = super::openclaw_dir().join("openclaw.json");
|
||
|
||
// 恢复前先自动备份当前配置
|
||
if target.exists() {
|
||
let _ = create_backup();
|
||
}
|
||
|
||
fs::copy(&backup_path, &target).map_err(|e| format!("恢复失败: {e}"))?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn delete_backup(name: String) -> Result<(), String> {
|
||
if is_unsafe_backup_name(&name) {
|
||
return Err("非法文件名".into());
|
||
}
|
||
let path = backups_dir().join(&name);
|
||
if !path.exists() {
|
||
return Err(format!("备份文件不存在: {name}"));
|
||
}
|
||
fs::remove_file(&path).map_err(|e| format!("删除失败: {e}"))
|
||
}
|
||
|
||
/// 获取当前用户 UID(macOS/Linux 用 id -u,Windows 返回 0)
|
||
#[allow(dead_code)]
|
||
fn get_uid() -> Result<u32, String> {
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
Ok(0)
|
||
}
|
||
#[cfg(not(target_os = "windows"))]
|
||
{
|
||
let output = Command::new("id")
|
||
.arg("-u")
|
||
.output()
|
||
.map_err(|e| format!("获取 UID 失败: {e}"))?;
|
||
String::from_utf8_lossy(&output.stdout)
|
||
.trim()
|
||
.parse::<u32>()
|
||
.map_err(|e| format!("解析 UID 失败: {e}"))
|
||
}
|
||
}
|
||
|
||
/// 重载 Gateway 服务
|
||
/// macOS: launchctl kickstart -k
|
||
/// Windows/Linux: 直接通过进程管理重启(不走慢 CLI)
|
||
#[tauri::command]
|
||
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 = tokio::process::Command::new("launchctl")
|
||
.args(["kickstart", "-k", &target])
|
||
.output()
|
||
.await
|
||
.map_err(|e| format!("重载失败: {e}"))?;
|
||
if output.status.success() {
|
||
Ok("Gateway 已重载".to_string())
|
||
} else {
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
Err(format!("重载失败: {stderr}"))
|
||
}
|
||
}
|
||
#[cfg(not(target_os = "macos"))]
|
||
{
|
||
// 直接调用服务管理(进程级别),避免慢 CLI 调用
|
||
crate::commands::service::restart_service("ai.openclaw.gateway".into())
|
||
.await
|
||
.map(|_| "Gateway 已重载".to_string())
|
||
}
|
||
}
|
||
|
||
/// 重启 Gateway 服务(与 reload_gateway 相同实现)
|
||
#[tauri::command]
|
||
pub async fn restart_gateway() -> Result<String, String> {
|
||
reload_gateway().await
|
||
}
|
||
|
||
/// 清理 base URL:去掉尾部斜杠和已知端点路径,防止用户粘贴完整端点 URL 导致路径重复
|
||
fn normalize_base_url(raw: &str) -> String {
|
||
let mut base = raw.trim_end_matches('/').to_string();
|
||
for suffix in &[
|
||
"/api/chat",
|
||
"/api/generate",
|
||
"/api/tags",
|
||
"/api",
|
||
"/chat/completions",
|
||
"/completions",
|
||
"/responses",
|
||
"/messages",
|
||
"/models",
|
||
] {
|
||
if base.ends_with(suffix) {
|
||
base.truncate(base.len() - suffix.len());
|
||
break;
|
||
}
|
||
}
|
||
base = base.trim_end_matches('/').to_string();
|
||
if base.ends_with(":11434") {
|
||
return format!("{base}/v1");
|
||
}
|
||
base
|
||
}
|
||
|
||
fn normalize_model_api_type(raw: &str) -> &'static str {
|
||
match raw.trim() {
|
||
"anthropic" | "anthropic-messages" => "anthropic-messages",
|
||
"google-gemini" => "google-gemini",
|
||
"openai" | "openai-completions" | "openai-responses" | "" => "openai-completions",
|
||
_ => "openai-completions",
|
||
}
|
||
}
|
||
|
||
fn normalize_base_url_for_api(raw: &str, api_type: &str) -> String {
|
||
let mut base = normalize_base_url(raw);
|
||
match normalize_model_api_type(api_type) {
|
||
"anthropic-messages" => {
|
||
if !base.ends_with("/v1") {
|
||
base.push_str("/v1");
|
||
}
|
||
base
|
||
}
|
||
"google-gemini" => base,
|
||
_ => {
|
||
// 不再强制追加 /v1,尊重用户填写的 URL(火山引擎等第三方用 /v3 等路径)
|
||
// 仅 Ollama (端口 11434) 自动补 /v1
|
||
base
|
||
}
|
||
}
|
||
}
|
||
|
||
fn extract_error_message(text: &str, status: reqwest::StatusCode) -> String {
|
||
serde_json::from_str::<serde_json::Value>(text)
|
||
.ok()
|
||
.and_then(|v| {
|
||
v.get("error")
|
||
.and_then(|e| e.get("message"))
|
||
.and_then(|m| m.as_str())
|
||
.map(String::from)
|
||
.or_else(|| v.get("message").and_then(|m| m.as_str()).map(String::from))
|
||
})
|
||
.unwrap_or_else(|| format!("HTTP {status}"))
|
||
}
|
||
|
||
/// 测试模型连通性:向 provider 发送一个简单的 chat completion 请求
|
||
#[tauri::command]
|
||
pub async fn test_model(
|
||
base_url: String,
|
||
api_key: String,
|
||
model_id: String,
|
||
api_type: Option<String>,
|
||
) -> Result<String, String> {
|
||
let api_type = normalize_model_api_type(api_type.as_deref().unwrap_or("openai-completions"));
|
||
let base = normalize_base_url_for_api(&base_url, api_type);
|
||
|
||
let client = reqwest::Client::builder()
|
||
.timeout(std::time::Duration::from_secs(30))
|
||
.build()
|
||
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?;
|
||
|
||
let resp = match api_type {
|
||
"anthropic-messages" => {
|
||
let url = format!("{}/messages", base);
|
||
let body = json!({
|
||
"model": model_id,
|
||
"messages": [{"role": "user", "content": "Hi"}],
|
||
"max_tokens": 16,
|
||
});
|
||
let mut req = client
|
||
.post(&url)
|
||
.header("anthropic-version", "2023-06-01")
|
||
.json(&body);
|
||
if !api_key.is_empty() {
|
||
req = req.header("x-api-key", api_key.clone());
|
||
}
|
||
req.send()
|
||
}
|
||
"google-gemini" => {
|
||
let url = format!(
|
||
"{}/models/{}:generateContent?key={}",
|
||
base, model_id, api_key
|
||
);
|
||
let body = json!({
|
||
"contents": [{"role": "user", "parts": [{"text": "Hi"}]}]
|
||
});
|
||
client.post(&url).json(&body).send()
|
||
}
|
||
_ => {
|
||
let url = format!("{}/chat/completions", base);
|
||
let body = json!({
|
||
"model": model_id,
|
||
"messages": [{"role": "user", "content": "Hi"}],
|
||
"max_tokens": 16,
|
||
"stream": false
|
||
});
|
||
let mut req = client.post(&url).json(&body);
|
||
if !api_key.is_empty() {
|
||
req = req.header("Authorization", format!("Bearer {api_key}"));
|
||
}
|
||
req.send()
|
||
}
|
||
}
|
||
.await
|
||
.map_err(|e| {
|
||
if e.is_timeout() {
|
||
"请求超时 (30s)".to_string()
|
||
} else if e.is_connect() {
|
||
format!("连接失败: {e}")
|
||
} else {
|
||
format!("请求失败: {e}")
|
||
}
|
||
})?;
|
||
|
||
let status = resp.status();
|
||
let text = resp.text().await.unwrap_or_default();
|
||
|
||
if !status.is_success() {
|
||
let msg = extract_error_message(&text, status);
|
||
// 401/403 是认证错误,一定要报错
|
||
if status.as_u16() == 401 || status.as_u16() == 403 {
|
||
return Err(msg);
|
||
}
|
||
// 其他错误(400/422 等):服务器可达、认证通过,仅模型对简单测试不兼容
|
||
// 返回成功但带提示,避免误导用户认为模型不可用
|
||
return Ok(format!(
|
||
"⚠ 连接正常(API 返回 {status},部分模型对简单测试不兼容,不影响实际使用)"
|
||
));
|
||
}
|
||
|
||
// 提取回复内容(兼容多种响应格式)
|
||
let reply = serde_json::from_str::<serde_json::Value>(&text)
|
||
.ok()
|
||
.and_then(|v| {
|
||
if let Some(arr) = v.get("content").and_then(|c| c.as_array()) {
|
||
let text = arr
|
||
.iter()
|
||
.filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text"))
|
||
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
|
||
.collect::<Vec<_>>()
|
||
.join("");
|
||
if !text.is_empty() {
|
||
return Some(text);
|
||
}
|
||
}
|
||
if let Some(t) = v
|
||
.get("candidates")
|
||
.and_then(|c| c.get(0))
|
||
.and_then(|c| c.get("content"))
|
||
.and_then(|c| c.get("parts"))
|
||
.and_then(|p| p.get(0))
|
||
.and_then(|p| p.get("text"))
|
||
.and_then(|t| t.as_str())
|
||
.filter(|s| !s.is_empty())
|
||
{
|
||
return Some(t.to_string());
|
||
}
|
||
// 标准 OpenAI 格式: choices[0].message.content
|
||
if let Some(msg) = v
|
||
.get("choices")
|
||
.and_then(|c| c.get(0))
|
||
.and_then(|c| c.get("message"))
|
||
{
|
||
let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
|
||
if !content.is_empty() {
|
||
return Some(content.to_string());
|
||
}
|
||
// reasoning 模型
|
||
if let Some(rc) = msg
|
||
.get("reasoning_content")
|
||
.and_then(|c| c.as_str())
|
||
.filter(|s| !s.is_empty())
|
||
{
|
||
return Some(format!("[reasoning] {rc}"));
|
||
}
|
||
}
|
||
// DashScope 格式: output.text
|
||
if let Some(t) = v
|
||
.get("output")
|
||
.and_then(|o| o.get("text"))
|
||
.and_then(|t| t.as_str())
|
||
.filter(|s| !s.is_empty())
|
||
{
|
||
return Some(t.to_string());
|
||
}
|
||
None
|
||
})
|
||
.unwrap_or_else(|| "(模型已响应)".into());
|
||
|
||
Ok(reply)
|
||
}
|
||
|
||
/// 获取服务商的远程模型列表(调用 /models 接口)
|
||
#[tauri::command]
|
||
pub async fn list_remote_models(
|
||
base_url: String,
|
||
api_key: String,
|
||
api_type: Option<String>,
|
||
) -> Result<Vec<String>, String> {
|
||
let api_type = normalize_model_api_type(api_type.as_deref().unwrap_or("openai-completions"));
|
||
let base = normalize_base_url_for_api(&base_url, api_type);
|
||
|
||
let client = reqwest::Client::builder()
|
||
.timeout(std::time::Duration::from_secs(15))
|
||
.build()
|
||
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?;
|
||
|
||
let resp = match api_type {
|
||
"anthropic-messages" => {
|
||
let url = format!("{}/models", base);
|
||
let mut req = client.get(&url).header("anthropic-version", "2023-06-01");
|
||
if !api_key.is_empty() {
|
||
req = req.header("x-api-key", api_key.clone());
|
||
}
|
||
req.send()
|
||
}
|
||
"google-gemini" => {
|
||
let url = format!("{}/models?key={}", base, api_key);
|
||
client.get(&url).send()
|
||
}
|
||
_ => {
|
||
let url = format!("{}/models", base);
|
||
let mut req = client.get(&url);
|
||
if !api_key.is_empty() {
|
||
req = req.header("Authorization", format!("Bearer {api_key}"));
|
||
}
|
||
req.send()
|
||
}
|
||
}
|
||
.await
|
||
.map_err(|e| {
|
||
if e.is_timeout() {
|
||
"请求超时 (15s),该服务商可能不支持模型列表接口".to_string()
|
||
} else if e.is_connect() {
|
||
format!("连接失败,请检查接口地址是否正确: {e}")
|
||
} else {
|
||
format!("请求失败: {e}")
|
||
}
|
||
})?;
|
||
|
||
let status = resp.status();
|
||
let text = resp.text().await.unwrap_or_default();
|
||
|
||
if !status.is_success() {
|
||
let msg = extract_error_message(&text, status);
|
||
return Err(format!("获取模型列表失败: {msg}"));
|
||
}
|
||
|
||
// 解析 OpenAI / Anthropic / Gemini 格式的 /models 响应
|
||
let ids = serde_json::from_str::<serde_json::Value>(&text)
|
||
.ok()
|
||
.map(|v| {
|
||
let mut ids: Vec<String> = if let Some(data) = v.get("data").and_then(|d| d.as_array())
|
||
{
|
||
data.iter()
|
||
.filter_map(|m| m.get("id").and_then(|id| id.as_str()).map(String::from))
|
||
.collect()
|
||
} else if let Some(data) = v.get("models").and_then(|d| d.as_array()) {
|
||
data.iter()
|
||
.filter_map(|m| {
|
||
m.get("name")
|
||
.and_then(|id| id.as_str())
|
||
.map(|s| s.trim_start_matches("models/").to_string())
|
||
})
|
||
.collect()
|
||
} else {
|
||
vec![]
|
||
};
|
||
ids.sort();
|
||
ids
|
||
})
|
||
.unwrap_or_default();
|
||
|
||
if ids.is_empty() {
|
||
return Err("该服务商返回了空的模型列表,可能不支持 /models 接口".to_string());
|
||
}
|
||
|
||
Ok(ids)
|
||
}
|
||
|
||
/// 安装 Gateway 服务(执行 openclaw gateway install)
|
||
#[tauri::command]
|
||
pub async fn install_gateway() -> Result<String, String> {
|
||
use crate::utils::openclaw_command_async;
|
||
let _guardian_pause = GuardianPause::new("install gateway");
|
||
// 先检测 openclaw CLI 是否可用
|
||
let cli_check = openclaw_command_async().arg("--version").output().await;
|
||
match cli_check {
|
||
Ok(o) if o.status.success() => {}
|
||
_ => {
|
||
return Err("openclaw CLI 未安装。请先执行以下命令安装:\n\n\
|
||
npm install -g @qingchencloud/openclaw-zh\n\n\
|
||
安装完成后再点击此按钮安装 Gateway 服务。"
|
||
.into());
|
||
}
|
||
}
|
||
|
||
let output = openclaw_command_async()
|
||
.args(["gateway", "install"])
|
||
.output()
|
||
.await
|
||
.map_err(|e| format!("安装失败: {e}"))?;
|
||
|
||
if output.status.success() {
|
||
Ok("Gateway 服务已安装".to_string())
|
||
} else {
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
Err(format!("安装失败: {stderr}"))
|
||
}
|
||
}
|
||
|
||
/// 卸载 Gateway 服务
|
||
/// macOS: launchctl bootout + 删除 plist
|
||
/// Windows: 直接 taskkill
|
||
/// Linux: pkill
|
||
#[tauri::command]
|
||
pub fn uninstall_gateway() -> Result<String, String> {
|
||
let _guardian_pause = GuardianPause::new("uninstall gateway");
|
||
crate::commands::service::guardian_mark_manual_stop();
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
let uid = get_uid()?;
|
||
let target = format!("gui/{uid}/ai.openclaw.gateway");
|
||
|
||
// 先停止服务
|
||
let _ = Command::new("launchctl")
|
||
.args(["bootout", &target])
|
||
.output();
|
||
|
||
// 删除 plist 文件
|
||
let home = dirs::home_dir().unwrap_or_default();
|
||
let plist = home.join("Library/LaunchAgents/ai.openclaw.gateway.plist");
|
||
if plist.exists() {
|
||
fs::remove_file(&plist).map_err(|e| format!("删除 plist 失败: {e}"))?;
|
||
}
|
||
}
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
// 直接杀死 gateway 相关的 node.exe 进程,不走慢 CLI
|
||
let _ = Command::new("taskkill")
|
||
.args(["/f", "/im", "node.exe", "/fi", "WINDOWTITLE eq openclaw*"])
|
||
.creation_flags(0x08000000)
|
||
.output();
|
||
}
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
let _ = Command::new("pkill")
|
||
.args(["-f", "openclaw.*gateway"])
|
||
.output();
|
||
}
|
||
Ok("Gateway 服务已卸载".to_string())
|
||
}
|
||
|
||
/// 为 openclaw.json 中所有模型添加 input: ["text", "image"],使 Gateway 识别模型支持图片输入
|
||
#[tauri::command]
|
||
pub fn patch_model_vision() -> Result<bool, 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 vision_input = Value::Array(vec![
|
||
Value::String("text".into()),
|
||
Value::String("image".into()),
|
||
]);
|
||
|
||
let mut changed = false;
|
||
|
||
if let Some(obj) = config.as_object_mut() {
|
||
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() {
|
||
if !mobj.contains_key("input") {
|
||
mobj.insert("input".into(), vision_input.clone());
|
||
changed = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if changed {
|
||
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}"))?;
|
||
fs::write(&path, json).map_err(|e| format!("写入失败: {e}"))?;
|
||
}
|
||
|
||
Ok(changed)
|
||
}
|
||
|
||
/// 检查 ClawPanel 自身是否有新版本(GitHub → Gitee 自动降级)
|
||
#[tauri::command]
|
||
pub async fn check_panel_update() -> Result<Value, String> {
|
||
let client = reqwest::Client::builder()
|
||
.timeout(std::time::Duration::from_secs(8))
|
||
.user_agent("ClawPanel")
|
||
.build()
|
||
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?;
|
||
|
||
// 先尝试 GitHub,失败后降级 Gitee
|
||
let sources = [
|
||
(
|
||
"https://api.github.com/repos/qingchencloud/clawpanel/releases/latest",
|
||
"https://github.com/qingchencloud/clawpanel/releases",
|
||
"github",
|
||
),
|
||
(
|
||
"https://gitee.com/api/v5/repos/QtCodeCreators/clawpanel/releases/latest",
|
||
"https://gitee.com/QtCodeCreators/clawpanel/releases",
|
||
"gitee",
|
||
),
|
||
];
|
||
|
||
let mut last_err = String::new();
|
||
for (api_url, releases_url, source) in &sources {
|
||
match client.get(*api_url).send().await {
|
||
Ok(resp) if resp.status().is_success() => {
|
||
let json: Value = resp
|
||
.json()
|
||
.await
|
||
.map_err(|e| format!("解析响应失败: {e}"))?;
|
||
|
||
let tag = json
|
||
.get("tag_name")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("")
|
||
.trim_start_matches('v')
|
||
.to_string();
|
||
|
||
if tag.is_empty() {
|
||
last_err = format!("{source}: 未找到版本号");
|
||
continue;
|
||
}
|
||
|
||
let mut result = serde_json::Map::new();
|
||
result.insert("latest".into(), Value::String(tag));
|
||
result.insert(
|
||
"url".into(),
|
||
json.get("html_url")
|
||
.cloned()
|
||
.unwrap_or(Value::String(releases_url.to_string())),
|
||
);
|
||
result.insert("source".into(), Value::String(source.to_string()));
|
||
result.insert(
|
||
"downloadUrl".into(),
|
||
Value::String("https://claw.qt.cool".into()),
|
||
);
|
||
return Ok(Value::Object(result));
|
||
}
|
||
Ok(resp) => {
|
||
last_err = format!("{source}: HTTP {}", resp.status());
|
||
}
|
||
Err(e) => {
|
||
last_err = format!("{source}: {e}");
|
||
}
|
||
}
|
||
}
|
||
|
||
Err(last_err)
|
||
}
|
||
|
||
// === 面板配置 (clawpanel.json) ===
|
||
|
||
#[tauri::command]
|
||
pub fn read_panel_config() -> Result<Value, String> {
|
||
let path = super::openclaw_dir().join("clawpanel.json");
|
||
if !path.exists() {
|
||
return Ok(serde_json::json!({}));
|
||
}
|
||
let content = fs::read_to_string(&path).map_err(|e| format!("读取失败: {e}"))?;
|
||
serde_json::from_str(&content).map_err(|e| format!("解析失败: {e}"))
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn write_panel_config(config: Value) -> Result<(), String> {
|
||
let dir = super::openclaw_dir();
|
||
if !dir.exists() {
|
||
fs::create_dir_all(&dir).map_err(|e| format!("创建目录失败: {e}"))?;
|
||
}
|
||
let path = dir.join("clawpanel.json");
|
||
let json = serde_json::to_string_pretty(&config).map_err(|e| format!("序列化失败: {e}"))?;
|
||
fs::write(&path, json).map_err(|e| format!("写入失败: {e}"))
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn get_npm_registry() -> Result<String, String> {
|
||
Ok(get_configured_registry())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn set_npm_registry(registry: String) -> Result<(), String> {
|
||
let path = super::openclaw_dir().join("npm-registry.txt");
|
||
fs::write(&path, registry.trim()).map_err(|e| format!("保存失败: {e}"))
|
||
}
|
||
|
||
/// 检测 Git 是否已安装
|
||
#[tauri::command]
|
||
pub fn check_git() -> Result<Value, String> {
|
||
let mut result = serde_json::Map::new();
|
||
let mut cmd = Command::new("git");
|
||
cmd.arg("--version");
|
||
#[cfg(target_os = "windows")]
|
||
cmd.creation_flags(0x08000000);
|
||
match cmd.output() {
|
||
Ok(o) if o.status.success() => {
|
||
let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||
result.insert("installed".into(), Value::Bool(true));
|
||
result.insert("version".into(), Value::String(ver));
|
||
}
|
||
_ => {
|
||
result.insert("installed".into(), Value::Bool(false));
|
||
result.insert("version".into(), Value::Null);
|
||
}
|
||
}
|
||
Ok(Value::Object(result))
|
||
}
|
||
|
||
/// 尝试自动安装 Git(Windows: winget; macOS: xcode-select; Linux: apt/yum)
|
||
#[tauri::command]
|
||
pub async fn auto_install_git(app: tauri::AppHandle) -> Result<String, String> {
|
||
use std::process::Stdio;
|
||
use tauri::Emitter;
|
||
|
||
let _ = app.emit("upgrade-log", "正在尝试自动安装 Git...");
|
||
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
use std::io::{BufRead, BufReader};
|
||
// 尝试 winget
|
||
let _ = app.emit("upgrade-log", "尝试使用 winget 安装 Git...");
|
||
let mut child = Command::new("winget")
|
||
.args([
|
||
"install",
|
||
"--id",
|
||
"Git.Git",
|
||
"-e",
|
||
"--source",
|
||
"winget",
|
||
"--accept-package-agreements",
|
||
"--accept-source-agreements",
|
||
])
|
||
.creation_flags(0x08000000)
|
||
.stdout(Stdio::piped())
|
||
.stderr(Stdio::piped())
|
||
.spawn()
|
||
.map_err(|e| format!("winget 不可用,请手动安装 Git: {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("upgrade-log", &line);
|
||
}
|
||
}
|
||
});
|
||
if let Some(pipe) = stdout {
|
||
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
|
||
let _ = app.emit("upgrade-log", &line);
|
||
}
|
||
}
|
||
let _ = handle.join();
|
||
let status = child
|
||
.wait()
|
||
.map_err(|e| format!("等待 winget 完成失败: {e}"))?;
|
||
if status.success() {
|
||
let _ = app.emit("upgrade-log", "Git 安装成功!");
|
||
return Ok("Git 已通过 winget 安装".to_string());
|
||
}
|
||
Err("winget 安装 Git 失败,请手动下载安装: https://git-scm.com/downloads".to_string())
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
let _ = app.emit("upgrade-log", "尝试通过 xcode-select 安装 Git...");
|
||
let mut child = Command::new("xcode-select")
|
||
.arg("--install")
|
||
.stdout(Stdio::piped())
|
||
.stderr(Stdio::piped())
|
||
.spawn()
|
||
.map_err(|e| format!("xcode-select 不可用: {e}"))?;
|
||
let status = child.wait().map_err(|e| format!("等待安装完成失败: {e}"))?;
|
||
if status.success() {
|
||
let _ = app.emit("upgrade-log", "Git 安装已触发,请在弹出的窗口中确认安装。");
|
||
return Ok("已触发 xcode-select 安装,请在弹窗中确认".to_string());
|
||
}
|
||
Err(
|
||
"xcode-select 安装失败,请手动安装 Xcode Command Line Tools 或 brew install git"
|
||
.to_string(),
|
||
)
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
use std::io::{BufRead, BufReader};
|
||
// 检测包管理器
|
||
let pkg_mgr = if Command::new("apt-get")
|
||
.arg("--version")
|
||
.output()
|
||
.map(|o| o.status.success())
|
||
.unwrap_or(false)
|
||
{
|
||
"apt"
|
||
} else if Command::new("yum")
|
||
.arg("--version")
|
||
.output()
|
||
.map(|o| o.status.success())
|
||
.unwrap_or(false)
|
||
{
|
||
"yum"
|
||
} else if Command::new("dnf")
|
||
.arg("--version")
|
||
.output()
|
||
.map(|o| o.status.success())
|
||
.unwrap_or(false)
|
||
{
|
||
"dnf"
|
||
} else if Command::new("pacman")
|
||
.arg("--version")
|
||
.output()
|
||
.map(|o| o.status.success())
|
||
.unwrap_or(false)
|
||
{
|
||
"pacman"
|
||
} else {
|
||
return Err(
|
||
"未找到包管理器,请手动安装 Git: sudo apt install git 或 sudo yum install git"
|
||
.to_string(),
|
||
);
|
||
};
|
||
|
||
let (cmd_name, args): (&str, Vec<&str>) = match pkg_mgr {
|
||
"apt" => ("sudo", vec!["apt-get", "install", "-y", "git"]),
|
||
"yum" => ("sudo", vec!["yum", "install", "-y", "git"]),
|
||
"dnf" => ("sudo", vec!["dnf", "install", "-y", "git"]),
|
||
"pacman" => ("sudo", vec!["pacman", "-S", "--noconfirm", "git"]),
|
||
_ => return Err("不支持的包管理器".to_string()),
|
||
};
|
||
|
||
let _ = app.emit(
|
||
"upgrade-log",
|
||
format!("执行: {} {}", cmd_name, args.join(" ")),
|
||
);
|
||
let mut child = Command::new(cmd_name)
|
||
.args(&args)
|
||
.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("upgrade-log", &line);
|
||
}
|
||
}
|
||
});
|
||
if let Some(pipe) = stdout {
|
||
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
|
||
let _ = app.emit("upgrade-log", &line);
|
||
}
|
||
}
|
||
let _ = handle.join();
|
||
let status = child.wait().map_err(|e| format!("等待安装完成失败: {e}"))?;
|
||
if status.success() {
|
||
let _ = app.emit("upgrade-log", "Git 安装成功!");
|
||
return Ok("Git 已安装".to_string());
|
||
}
|
||
Err("Git 安装失败,请手动执行: sudo apt install git".to_string())
|
||
}
|
||
}
|
||
|
||
/// 配置 Git 使用 HTTPS 替代 SSH,解决国内用户 SSH 不通的问题
|
||
#[tauri::command]
|
||
pub fn configure_git_https() -> Result<String, String> {
|
||
let mut success = 0;
|
||
let configs = [
|
||
("url.https://github.com/.insteadOf", "ssh://git@github.com/"),
|
||
("url.https://github.com/.insteadOf", "git@github.com:"),
|
||
("url.https://github.com/.insteadOf", "git://github.com/"),
|
||
];
|
||
for (key, value) in &configs {
|
||
let mut cmd = Command::new("git");
|
||
cmd.args(["config", "--global", key, value]);
|
||
#[cfg(target_os = "windows")]
|
||
cmd.creation_flags(0x08000000);
|
||
if cmd.output().map(|o| o.status.success()).unwrap_or(false) {
|
||
success += 1;
|
||
}
|
||
}
|
||
if success > 0 {
|
||
Ok(format!("已配置 Git 使用 HTTPS({success} 条规则)"))
|
||
} else {
|
||
Err("Git 未安装或配置失败".to_string())
|
||
}
|
||
}
|
||
|
||
/// 刷新 enhanced_path 缓存,使新设置的 Node.js 路径立即生效
|
||
#[tauri::command]
|
||
pub fn invalidate_path_cache() -> Result<(), String> {
|
||
super::refresh_enhanced_path();
|
||
Ok(())
|
||
}
|