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

2157 lines
78 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_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 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());
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());
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", &registry]);
c
} else {
let mut c = Command::new("npm");
c.args(["--registry", &registry]);
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 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 = 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 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 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 (&current, &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(&current_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)
}
/// 卸载 OpenClawnpm 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}"))
}
/// 获取当前用户 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 = 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))
}
/// 尝试自动安装 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 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(())
}