Files
clawpanel/src-tauri/src/commands/config.rs

2851 lines
103 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#[cfg(not(target_os = "macos"))]
use crate::utils::openclaw_command;
/// 配置读写命令
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::HashMap;
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";
/// (target_https_prefix, from_pattern) pairs for Git HTTPS rewriting.
/// Each entry maps a non-HTTPS Git URL pattern to the corresponding HTTPS URL.
const GIT_HTTPS_REWRITES: &[(&str, &str)] = &[
// github.com
("https://github.com/", "ssh://git@github.com/"),
("https://github.com/", "ssh://git@github.com"),
("https://github.com/", "ssh://git@://github.com/"),
("https://github.com/", "git@github.com:"),
("https://github.com/", "git://github.com/"),
("https://github.com/", "git+ssh://git@github.com/"),
// gitlab.com
("https://gitlab.com/", "ssh://git@gitlab.com/"),
("https://gitlab.com/", "git@gitlab.com:"),
("https://gitlab.com/", "git://gitlab.com/"),
("https://gitlab.com/", "git+ssh://git@gitlab.com/"),
// bitbucket.org
("https://bitbucket.org/", "ssh://git@bitbucket.org/"),
("https://bitbucket.org/", "git@bitbucket.org:"),
("https://bitbucket.org/", "git://bitbucket.org/"),
("https://bitbucket.org/", "git+ssh://git@bitbucket.org/"),
];
#[derive(Debug, Deserialize, Default)]
struct VersionPolicySource {
recommended: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
struct VersionPolicyEntry {
#[serde(default)]
official: VersionPolicySource,
#[serde(default)]
chinese: VersionPolicySource,
}
#[derive(Debug, Deserialize, Default)]
struct R2Config {
#[serde(default)]
#[serde(rename = "baseUrl")]
base_url: Option<String>,
#[serde(default)]
enabled: bool,
}
#[derive(Debug, Deserialize, Default)]
struct VersionPolicy {
#[serde(default)]
r2: R2Config,
#[serde(default)]
default: VersionPolicyEntry,
#[serde(default)]
panels: HashMap<String, VersionPolicyEntry>,
}
fn panel_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
fn parse_version(value: &str) -> Vec<u32> {
value
.split(|c: char| !c.is_ascii_digit())
.filter_map(|s| s.parse().ok())
.collect()
}
/// 提取基础版本号(去掉 -zh.x / -nightly.xxx 等后缀,只保留主版本数字部分)
/// "2026.3.13-zh.1" → "2026.3.13", "2026.3.13" → "2026.3.13"
fn base_version(v: &str) -> String {
// 在第一个 '-' 处截断
let base = v.split('-').next().unwrap_or(v);
base.to_string()
}
/// 判断 CLI 报告的版本是否与推荐版匹配(考虑汉化版 -zh.x 后缀差异)
fn versions_match(cli_version: &str, recommended: &str) -> bool {
if cli_version == recommended {
return true;
}
// CLI 报告 "2026.3.13",推荐版 "2026.3.13-zh.1" → 基础版本相同即视为匹配
base_version(cli_version) == base_version(recommended)
}
/// 判断推荐版是否真的比当前版本更新(忽略 -zh.x 后缀)
fn recommended_is_newer(recommended: &str, current: &str) -> bool {
let r = parse_version(&base_version(recommended));
let c = parse_version(&base_version(current));
r > c
}
fn load_version_policy() -> VersionPolicy {
serde_json::from_str(include_str!("../../../openclaw-version-policy.json")).unwrap_or_default()
}
fn r2_config() -> R2Config {
load_version_policy().r2
}
fn recommended_version_for(source: &str) -> Option<String> {
let policy = load_version_policy();
let panel_entry = policy.panels.get(panel_version());
match source {
"official" => panel_entry
.and_then(|entry| entry.official.recommended.clone())
.or(policy.default.official.recommended),
_ => panel_entry
.and_then(|entry| entry.chinese.recommended.clone())
.or(policy.default.chinese.recommended),
}
}
fn configure_git_https_rules() -> usize {
// Collect unique target prefixes to unset old rules
let targets: std::collections::HashSet<&str> =
GIT_HTTPS_REWRITES.iter().map(|(t, _)| *t).collect();
for target in &targets {
let key = format!("url.{target}.insteadOf");
let mut unset = Command::new("git");
unset.args(["config", "--global", "--unset-all", &key]);
#[cfg(target_os = "windows")]
unset.creation_flags(0x08000000);
let _ = unset.output();
}
let mut success = 0;
for (target, from) in GIT_HTTPS_REWRITES {
let key = format!("url.{target}.insteadOf");
let mut cmd = Command::new("git");
cmd.args(["config", "--global", "--add", &key, from]);
#[cfg(target_os = "windows")]
cmd.creation_flags(0x08000000);
if cmd.output().map(|o| o.status.success()).unwrap_or(false) {
success += 1;
}
}
success
}
fn apply_git_install_env(cmd: &mut Command) {
crate::commands::apply_proxy_env(cmd);
cmd.env("GIT_TERMINAL_PROMPT", "0")
.env(
"GIT_SSH_COMMAND",
"ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o IdentitiesOnly=yes",
)
.env("GIT_ALLOW_PROTOCOL", "https:http:file");
cmd.env("GIT_CONFIG_COUNT", GIT_HTTPS_REWRITES.len().to_string());
for (idx, (target, from)) in GIT_HTTPS_REWRITES.iter().enumerate() {
cmd.env(
format!("GIT_CONFIG_KEY_{idx}"),
format!("url.{target}.insteadOf"),
)
.env(format!("GIT_CONFIG_VALUE_{idx}"), *from);
}
}
/// 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 registryfallback 到淘宝镜像
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", &registry]);
cmd.env("PATH", super::enhanced_path());
crate::commands::apply_proxy_env(&mut cmd);
cmd.creation_flags(CREATE_NO_WINDOW);
cmd
}
#[cfg(target_os = "macos")]
{
let mut cmd = Command::new("npm");
cmd.args(["--registry", &registry]);
cmd.env("PATH", super::enhanced_path());
crate::commands::apply_proxy_env(&mut cmd);
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(["-E", "npm", "--registry", &registry]);
c
} else {
let mut c = Command::new("npm");
c.args(["--registry", &registry]);
c
};
cmd.env("PATH", super::enhanced_path());
crate::commands::apply_proxy_env(&mut cmd);
cmd
}
}
/// 安装/升级前的清理工作:停止 Gateway、清理 npm 全局 bin 下的 openclaw 残留文件
/// 解决 Windows 上 EEXIST文件已存在和文件被占用的问题
fn pre_install_cleanup() {
// 1. 停止 Gateway 进程,释放 openclaw 相关文件锁
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
// 杀死所有 openclaw gateway 相关的 node 进程
let _ = Command::new("taskkill")
.args(["/f", "/im", "node.exe", "/fi", "WINDOWTITLE eq OpenClaw*"])
.creation_flags(0x08000000)
.output();
// 等文件锁释放
std::thread::sleep(std::time::Duration::from_millis(500));
}
#[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(target_os = "linux")]
{
let _ = Command::new("pkill")
.args(["-f", "openclaw.*gateway"])
.output();
}
// 2. 清理 npm 全局 bin 目录下的 openclaw 残留文件Windows EEXIST 根因)
#[cfg(target_os = "windows")]
{
if let Ok(appdata) = std::env::var("APPDATA") {
let npm_bin = std::path::Path::new(&appdata).join("npm");
for name in &["openclaw", "openclaw.cmd", "openclaw.ps1"] {
let p = npm_bin.join(name);
if p.exists() {
let _ = fs::remove_file(&p);
}
}
}
}
}
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 BOMEF 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 =
crate::commands::build_http_client(std::time::Duration::from_secs(2), None).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 symlinkfallback 到 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 recommended = recommended_version_for(&source);
let update_available = match (&current, &recommended) {
(Some(c), Some(r)) => recommended_is_newer(r, c),
(None, Some(_)) => true,
_ => false,
};
let latest_update_available = match (&current, &latest) {
(Some(c), Some(l)) => recommended_is_newer(l, c),
(None, Some(_)) => true,
_ => false,
};
let is_recommended = match (&current, &recommended) {
(Some(c), Some(r)) => versions_match(c, r),
_ => false,
};
let ahead_of_recommended = match (&current, &recommended) {
(Some(c), Some(r)) => recommended_is_newer(c, r),
_ => false,
};
Ok(VersionInfo {
current,
latest,
recommended,
update_available,
latest_update_available,
is_recommended,
ahead_of_recommended,
panel_version: panel_version().to_string(),
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 = crate::commands::build_http_client(std::time::Duration::from_secs(10), None)
.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 mut 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 = parse_version(a);
let pb = parse_version(b);
pb.cmp(&pa)
});
vers
})
.unwrap_or_default();
if let Some(recommended) = recommended_version_for(&source) {
if let Some(pos) = versions.iter().position(|v| v == &recommended) {
let version = versions.remove(pos);
versions.insert(0, version);
} else {
versions.insert(0, recommended);
}
}
Ok(versions)
}
/// 执行 npm 全局安装/升级/降级 openclaw后台执行通过 event 推送进度)
/// 立即返回,不阻塞前端。完成后 emit "upgrade-done" 或 "upgrade-error"。
#[tauri::command]
pub async fn upgrade_openclaw(
app: tauri::AppHandle,
source: String,
version: Option<String>,
) -> Result<String, String> {
let app2 = app.clone();
tauri::async_runtime::spawn(async move {
use tauri::Emitter;
let result = upgrade_openclaw_inner(app2.clone(), source, version).await;
match result {
Ok(msg) => {
let _ = app2.emit("upgrade-done", &msg);
}
Err(err) => {
let _ = app2.emit("upgrade-error", &err);
}
}
});
Ok("任务已启动".into())
}
/// 检测当前平台标识(用于 R2 归档文件名)
fn r2_platform_key() -> &'static str {
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
{
"win-x64"
}
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
"darwin-arm64"
}
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
{
"darwin-x64"
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
{
"linux-x64"
}
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
{
"linux-arm64"
}
#[cfg(not(any(
all(target_os = "windows", target_arch = "x86_64"),
all(target_os = "macos", target_arch = "aarch64"),
all(target_os = "macos", target_arch = "x86_64"),
all(target_os = "linux", target_arch = "x86_64"),
all(target_os = "linux", target_arch = "aarch64"),
)))]
{
"unknown"
}
}
/// npm 全局 node_modules 目录
fn npm_global_modules_dir() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
std::env::var("APPDATA")
.ok()
.map(|a| PathBuf::from(a).join("npm").join("node_modules"))
}
#[cfg(target_os = "macos")]
{
// homebrew 或系统 node
let brew = PathBuf::from("/opt/homebrew/lib/node_modules");
if brew.exists() {
return Some(brew);
}
let sys = PathBuf::from("/usr/local/lib/node_modules");
if sys.exists() {
return Some(sys);
}
Some(brew) // fallback to homebrew path
}
#[cfg(target_os = "linux")]
{
// 尝试 npm config get prefix
if let Ok(output) = Command::new("npm")
.args(["config", "get", "prefix"])
.output()
{
let prefix = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !prefix.is_empty() {
return Some(PathBuf::from(prefix).join("lib").join("node_modules"));
}
}
Some(PathBuf::from("/usr/local/lib/node_modules"))
}
}
/// npm 全局 bin 目录
fn npm_global_bin_dir() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
std::env::var("APPDATA")
.ok()
.map(|a| PathBuf::from(a).join("npm"))
}
#[cfg(target_os = "macos")]
{
let brew = PathBuf::from("/opt/homebrew/bin");
if brew.exists() {
return Some(brew);
}
Some(PathBuf::from("/usr/local/bin"))
}
#[cfg(target_os = "linux")]
{
if let Ok(output) = Command::new("npm")
.args(["config", "get", "prefix"])
.output()
{
let prefix = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !prefix.is_empty() {
return Some(PathBuf::from(prefix).join("bin"));
}
}
Some(PathBuf::from("/usr/local/bin"))
}
}
/// 尝试从 R2 CDN 下载预装归档安装 OpenClaw跳过 npm 依赖解析)
/// 成功返回 Ok(版本号),失败返回 Err(原因) 供 caller 降级到 npm install
async fn try_r2_install(
app: &tauri::AppHandle,
version: &str,
source: &str,
) -> Result<String, String> {
use sha2::{Digest, Sha256};
use tauri::Emitter;
let r2 = r2_config();
if !r2.enabled {
return Err("R2 加速未启用".into());
}
let base_url = r2.base_url.as_deref().ok_or("R2 baseUrl 未配置")?;
let platform = r2_platform_key();
if platform == "unknown" {
return Err("当前平台不支持 R2 预装归档".into());
}
// 1. 获取 latest.json
let _ = app.emit("upgrade-log", "尝试从 CDN 加速下载...");
let manifest_url = format!("{}/latest.json", base_url);
let client = crate::commands::build_http_client(std::time::Duration::from_secs(10), None)
.map_err(|e| format!("HTTP 客户端创建失败: {e}"))?;
let manifest_resp = client
.get(&manifest_url)
.send()
.await
.map_err(|e| format!("获取 CDN 清单失败: {e}"))?;
if !manifest_resp.status().is_success() {
return Err(format!("CDN 清单不可用 (HTTP {})", manifest_resp.status()));
}
let manifest: Value = manifest_resp
.json()
.await
.map_err(|e| format!("CDN 清单解析失败: {e}"))?;
// 2. 查找归档:优先通用 tarball全平台其次平台特定 assets
let source_key = if source == "official" {
"official"
} else {
"chinese"
};
let source_obj = manifest.get(source_key);
let cdn_version = source_obj
.and_then(|s| s.get("version"))
.and_then(|v| v.as_str())
.unwrap_or(version);
// 优先通用 tarballnpm pack 产物,~50MB全平台通用
let tarball = source_obj.and_then(|s| s.get("tarball"));
// 其次平台特定 assets预装 node_modules~200MB
let asset = source_obj
.and_then(|s| s.get("assets"))
.and_then(|a| a.get(platform));
let use_tarball = tarball
.and_then(|t| t.get("url"))
.and_then(|v| v.as_str())
.is_some();
let (archive_url, expected_sha, expected_size) = if use_tarball {
let t = tarball.unwrap();
(
t.get("url")
.and_then(|v| v.as_str())
.ok_or("tarball URL 缺失")?,
t.get("sha256").and_then(|v| v.as_str()).unwrap_or(""),
t.get("size").and_then(|v| v.as_u64()).unwrap_or(0),
)
} else if let Some(a) = asset {
(
a.get("url")
.and_then(|v| v.as_str())
.ok_or("归档 URL 缺失")?,
a.get("sha256").and_then(|v| v.as_str()).unwrap_or(""),
a.get("size").and_then(|v| v.as_u64()).unwrap_or(0),
)
} else {
return Err(format!("CDN 无 {source_key} 可用归档"));
};
// 版本匹配检查如果用户指定了版本CDN 版本必须匹配)
if version != "latest" && !versions_match(cdn_version, version) {
return Err(format!(
"CDN 版本 {cdn_version} 与请求版本 {version} 不匹配"
));
}
let size_mb = if expected_size > 0 {
format!("{:.0}MB", expected_size as f64 / 1_048_576.0)
} else {
"未知大小".into()
};
let _ = app.emit(
"upgrade-log",
format!("CDN 下载: {cdn_version} ({platform}, {size_mb})"),
);
let _ = app.emit("upgrade-progress", 15);
// 3. 流式下载到临时文件
let tmp_dir = std::env::temp_dir();
let archive_path = tmp_dir.join(format!("openclaw-{platform}.tgz"));
let dl_client = crate::commands::build_http_client(std::time::Duration::from_secs(300), None)
.map_err(|e| format!("下载客户端创建失败: {e}"))?;
let dl_resp = dl_client
.get(archive_url)
.send()
.await
.map_err(|e| format!("CDN 下载失败: {e}"))?;
if !dl_resp.status().is_success() {
return Err(format!("CDN 下载失败 (HTTP {})", dl_resp.status()));
}
let total_bytes = dl_resp.content_length().unwrap_or(expected_size);
{
use tokio::io::AsyncWriteExt;
let mut file = tokio::fs::File::create(&archive_path)
.await
.map_err(|e| format!("创建临时文件失败: {e}"))?;
let mut stream = dl_resp.bytes_stream();
let mut downloaded: u64 = 0;
let mut last_progress: u32 = 15;
use futures_util::StreamExt;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("下载中断: {e}"))?;
file.write_all(&chunk)
.await
.map_err(|e| format!("写入失败: {e}"))?;
downloaded += chunk.len() as u64;
if total_bytes > 0 {
let pct = 15 + ((downloaded as f64 / total_bytes as f64) * 50.0) as u32;
if pct > last_progress {
last_progress = pct;
let _ = app.emit("upgrade-progress", pct.min(65));
}
}
}
file.flush()
.await
.map_err(|e| format!("刷新文件失败: {e}"))?;
}
let _ = app.emit("upgrade-log", "下载完成,校验中...");
let _ = app.emit("upgrade-progress", 68);
// 4. SHA256 校验
if !expected_sha.is_empty() {
let file_bytes = std::fs::read(&archive_path).map_err(|e| format!("读取归档失败: {e}"))?;
let mut hasher = Sha256::new();
hasher.update(&file_bytes);
let actual_sha = format!("{:x}", hasher.finalize());
if actual_sha != expected_sha {
let _ = std::fs::remove_file(&archive_path);
return Err(format!(
"SHA256 校验失败: 期望 {expected_sha}, 实际 {actual_sha}"
));
}
let _ = app.emit("upgrade-log", "SHA256 校验通过 ✓");
}
let _ = app.emit("upgrade-progress", 72);
// 5. 安装:通用 tarball 用 npm install -g平台归档用 tar 解压
if use_tarball {
// 通用 tarball 模式npm install -g ./file.tgz全平台通用npm 自动处理原生模块)
let _ = app.emit("upgrade-log", "通用 tarball 模式,执行 npm install...");
let mut install_cmd = npm_command();
install_cmd.args(["install", "-g", &archive_path.to_string_lossy(), "--force"]);
apply_git_install_env(&mut install_cmd);
let install_output = install_cmd
.output()
.map_err(|e| format!("npm install 执行失败: {e}"))?;
if !install_output.status.success() {
let stderr = String::from_utf8_lossy(&install_output.stderr);
let _ = std::fs::remove_file(&archive_path);
return Err(format!(
"npm install -g tarball 失败: {}",
&stderr[stderr.len().saturating_sub(300)..]
));
}
let _ = app.emit("upgrade-log", "npm install 完成 ✓");
} else {
// 平台特定归档模式:直接解压到 npm 全局 node_modules
let modules_dir = npm_global_modules_dir().ok_or("无法确定 npm 全局 node_modules 目录")?;
if !modules_dir.exists() {
std::fs::create_dir_all(&modules_dir)
.map_err(|e| format!("创建 node_modules 目录失败: {e}"))?;
}
let _ = app.emit("upgrade-log", format!("解压到 {}", modules_dir.display()));
let qc_dir = modules_dir.join("@qingchencloud");
if qc_dir.exists() {
let _ = std::fs::remove_dir_all(&qc_dir);
}
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
let status = Command::new("tar")
.args([
"-xzf",
&archive_path.to_string_lossy(),
"-C",
&modules_dir.to_string_lossy(),
])
.creation_flags(0x08000000)
.status()
.map_err(|e| format!("解压失败: {e}"))?;
if !status.success() {
return Err("tar 解压失败".into());
}
}
#[cfg(not(target_os = "windows"))]
{
let status = Command::new("tar")
.args([
"-xzf",
&archive_path.to_string_lossy(),
"-C",
&modules_dir.to_string_lossy(),
])
.status()
.map_err(|e| format!("解压失败: {e}"))?;
if !status.success() {
return Err("tar 解压失败".into());
}
}
// 归档内目录可能是 qingchencloud/Windows tar 不支持 @ 前缀),需要重命名
let no_at_dir = modules_dir.join("qingchencloud");
if no_at_dir.exists() && !qc_dir.exists() {
std::fs::rename(&no_at_dir, &qc_dir)
.map_err(|e| format!("重命名 qingchencloud → @qingchencloud 失败: {e}"))?;
let _ = app.emit("upgrade-log", "目录已修正: qingchencloud → @qingchencloud");
}
let _ = app.emit("upgrade-log", "解压完成,创建 bin 链接...");
// 创建 bin 链接
let bin_dir = npm_global_bin_dir().ok_or("无法确定 npm bin 目录")?;
let openclaw_js = modules_dir
.join("@qingchencloud")
.join("openclaw-zh")
.join("bin")
.join("openclaw.js");
if openclaw_js.exists() {
#[cfg(target_os = "windows")]
{
let cmd_path = bin_dir.join("openclaw.cmd");
let cmd_content = format!(
"@ECHO off\r\nGOTO start\r\n:find_dp0\r\nSET dp0=%~dp0\r\nEXIT /b\r\n:start\r\nSETLOCAL\r\nCALL :find_dp0\r\n\r\nIF EXIST \"%dp0%\\node.exe\" (\r\n SET \"_prog=%dp0%\\node.exe\"\r\n) ELSE (\r\n SET \"_prog=node\"\r\n SET PATHEXT=%PATHEXT:;.JS;=;%\r\n)\r\n\r\nendLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & \"%_prog%\" \"{}\" %*\r\n",
openclaw_js.display()
);
std::fs::write(&cmd_path, cmd_content)
.map_err(|e| format!("创建 openclaw.cmd 失败: {e}"))?;
let ps1_path = bin_dir.join("openclaw.ps1");
let ps1_content = format!(
"#!/usr/bin/env pwsh\r\n$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent\r\n\r\n$exe=\"\"\r\nif ($PSVersionTable.PSVersion -lt \"6.0\" -or $IsWindows) {{\r\n $exe=\".exe\"\r\n}}\r\n$ret=0\r\nif (Test-Path \"$basedir/node$exe\") {{\r\n if ($MyInvocation.ExpectingInput) {{\r\n $input | & \"$basedir/node$exe\" \"{}\" $args\r\n }} else {{\r\n & \"$basedir/node$exe\" \"{}\" $args\r\n }}\r\n $ret=$LASTEXITCODE\r\n}} else {{\r\n if ($MyInvocation.ExpectingInput) {{\r\n $input | & \"node$exe\" \"{}\" $args\r\n }} else {{\r\n & \"node$exe\" \"{}\" $args\r\n }}\r\n $ret=$LASTEXITCODE\r\n}}\r\nexit $ret\r\n",
openclaw_js.display(), openclaw_js.display(), openclaw_js.display(), openclaw_js.display()
);
let _ = std::fs::write(&ps1_path, ps1_content);
}
#[cfg(not(target_os = "windows"))]
{
let link_path = bin_dir.join("openclaw");
let _ = std::fs::remove_file(&link_path);
#[cfg(unix)]
{
std::os::unix::fs::symlink(&openclaw_js, &link_path)
.map_err(|e| format!("创建 symlink 失败: {e}"))?;
let _ = Command::new("chmod")
.args(["+x", &openclaw_js.to_string_lossy()])
.status();
let _ = Command::new("chmod")
.args(["+x", &link_path.to_string_lossy()])
.status();
}
}
let _ = app.emit("upgrade-log", "bin 链接已创建 ✓");
} else {
let _ = app.emit("upgrade-log", "⚠️ openclaw.js 未找到bin 链接跳过");
}
}
// 清理临时文件
let _ = std::fs::remove_file(&archive_path);
let _ = app.emit("upgrade-progress", 95);
Ok(cdn_version.to_string())
}
async fn upgrade_openclaw_inner(
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 requested_version = version.clone();
let recommended_version = recommended_version_for(&source);
let ver = requested_version
.as_deref()
.or(recommended_version.as_deref())
.unwrap_or("latest");
let pkg = format!("{}@{}", pkg_name, ver);
// ── R2 CDN 加速:优先尝试从 CDN 下载预装归档 ──
if source != "official" {
// 目前仅汉化版支持 R2 加速
match try_r2_install(&app, ver, &source).await {
Ok(installed_ver) => {
let _ = app.emit("upgrade-progress", 100);
// 刷新缓存
super::refresh_enhanced_path();
crate::commands::service::invalidate_cli_detection_cache();
let msg = format!("✅ CDN 加速安装完成,当前版本: {installed_ver}");
let _ = app.emit("upgrade-log", &msg);
return Ok(msg);
}
Err(reason) => {
let _ = app.emit(
"upgrade-log",
format!("CDN 加速不可用({reason}),降级到 npm 安装..."),
);
let _ = app.emit("upgrade-progress", 5);
}
}
}
// 切换源时需要卸载旧包,但为避免安装失败导致 CLI 丢失,
// 先安装新包,成功后再卸载旧包
let old_pkg = npm_package_name(&current_source);
let need_uninstall_old = current_source != source;
if requested_version.is_none() {
if let Some(recommended) = &recommended_version {
let _ = app.emit(
"upgrade-log",
format!(
"ClawPanel {} 默认绑定 OpenClaw 稳定版: {}",
panel_version(),
recommended
),
);
} else {
let _ = app.emit("upgrade-log", "未找到绑定稳定版,将回退到 latest");
}
}
let configured_rules = configure_git_https_rules();
let _ = app.emit(
"upgrade-log",
format!(
"Git HTTPS 规则已就绪 ({}/{})",
configured_rules,
GIT_HTTPS_REWRITES.len()
),
);
// 安装前:停止 Gateway 并清理可能冲突的 bin 文件
let _ = app.emit("upgrade-log", "正在停止 Gateway 并清理旧文件...");
pre_install_cleanup();
let _ = app.emit("upgrade-log", format!("$ npm install -g {pkg} --force"));
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 install_cmd = npm_command();
install_cmd.args([
"install",
"-g",
&pkg,
"--force",
"--registry",
registry,
"--verbose",
]);
apply_git_install_env(&mut install_cmd);
let mut child = install_cmd
.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 install_cmd2 = npm_command();
install_cmd2.args([
"install",
"-g",
&pkg,
"--force",
"--registry",
fallback,
"--verbose",
]);
apply_git_install_env(&mut install_cmd2);
let mut child2 = install_cmd2
.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 服务(更新启动路径)...");
// 刷新 PATH 缓存和 CLI 检测缓存,确保找到新安装的二进制
super::refresh_enhanced_path();
crate::commands::service::invalidate_cli_detection_cache();
// 先停掉旧的
#[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();
}
// 重新安装(刷新后的 PATH 会找到新二进制)
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 msg = format!("✅ 安装完成,当前版本: {new_ver}");
let _ = app.emit("upgrade-log", &msg);
Ok(msg)
}
/// 卸载 OpenClaw后台执行通过 event 推送进度)
/// 立即返回,不阻塞前端。完成后 emit "upgrade-done" 或 "upgrade-error"。
#[tauri::command]
pub async fn uninstall_openclaw(
app: tauri::AppHandle,
clean_config: bool,
) -> Result<String, String> {
let app2 = app.clone();
tauri::async_runtime::spawn(async move {
use tauri::Emitter;
let result = uninstall_openclaw_inner(app2.clone(), clean_config).await;
match result {
Ok(msg) => {
let _ = app2.emit("upgrade-done", &msg);
}
Err(err) => {
let _ = app2.emit("upgrade-error", &err);
}
}
});
Ok("任务已启动".into())
}
async fn uninstall_openclaw_inner(
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 last_touched_version =
recommended_version_for("chinese").unwrap_or_else(|| "2026.1.1".to_string());
let default_config = serde_json::json!({
"$schema": "https://openclaw.ai/schema/config.json",
"meta": { "lastTouchedVersion": last_touched_version },
"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();
crate::commands::service::invalidate_cli_detection_cache();
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}"))
}
/// 获取当前用户 UIDmacOS/Linux 用 id -uWindows 返回 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 =
crate::commands::build_http_client_no_proxy(std::time::Duration::from_secs(30), None)
.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 =
crate::commands::build_http_client_no_proxy(std::time::Duration::from_secs(15), None)
.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 =
crate::commands::build_http_client(std::time::Duration::from_secs(8), Some("ClawPanel"))
.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}"))
}
/// 测试代理连通性:通过配置的代理访问指定 URL返回状态码和耗时
#[tauri::command]
pub async fn test_proxy(url: Option<String>) -> Result<Value, String> {
let proxy_url = crate::commands::configured_proxy_url()
.ok_or("未配置代理地址,请先在面板设置中保存代理地址")?;
let target = url.unwrap_or_else(|| "https://registry.npmjs.org/-/ping".to_string());
let client =
crate::commands::build_http_client(std::time::Duration::from_secs(10), Some("ClawPanel"))
.map_err(|e| format!("创建代理客户端失败: {e}"))?;
let start = std::time::Instant::now();
let resp = client.get(&target).send().await.map_err(|e| {
let elapsed = start.elapsed().as_millis();
format!("代理连接失败 ({elapsed}ms): {e}")
})?;
let elapsed = start.elapsed().as_millis();
let status = resp.status().as_u16();
Ok(json!({
"ok": status < 500,
"status": status,
"elapsed_ms": elapsed,
"proxy": proxy_url,
"target": target,
}))
}
#[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))
}
/// 尝试自动安装 GitWindows: 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 success = configure_git_https_rules();
if success > 0 {
Ok(format!(
"已配置 Git 使用 HTTPS{success}/{} 条规则)",
GIT_HTTPS_REWRITES.len()
))
} else {
Err("Git 未安装或配置失败".to_string())
}
}
/// 刷新 enhanced_path 缓存,使新设置的 Node.js 路径立即生效
#[tauri::command]
pub fn invalidate_path_cache() -> Result<(), String> {
super::refresh_enhanced_path();
crate::commands::service::invalidate_cli_detection_cache();
Ok(())
}