v0.8.0: Ollama兼容、Git自动安装、Gitee镜像、会话重命名、消息渠道Agent绑定、仪表盘重设计、环境检测实时生效、#44修复

This commit is contained in:
晴天
2026-03-12 02:17:47 +08:00
parent 62c8fc9355
commit 3e24ceaa4d
32 changed files with 2972 additions and 487 deletions

View File

@@ -1,7 +1,7 @@
#[cfg(not(target_os = "macos"))]
use crate::utils::openclaw_command;
/// 配置读写命令
use serde_json::Value;
use serde_json::{json, Value};
use std::fs;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
@@ -205,6 +205,13 @@ fn sync_providers_to_agent_models(config: &Value) {
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")
@@ -222,6 +229,13 @@ fn sync_providers_to_agent_models(config: &Value) {
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) {
@@ -1058,6 +1072,8 @@ pub fn save_custom_node_path(node_dir: String) -> Result<(), String> {
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(())
}
@@ -1245,6 +1261,10 @@ pub async fn restart_gateway() -> Result<String, String> {
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",
@@ -1256,7 +1276,56 @@ fn normalize_base_url(raw: &str) -> String {
break;
}
}
base.trim_end_matches('/').to_string()
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,
_ => {
if !base.ends_with("/v1") {
if let Some(idx) = base.find("/v1/") {
base.truncate(idx + 3);
} else {
base.push_str("/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 请求
@@ -1265,27 +1334,57 @@ pub async fn test_model(
base_url: String,
api_key: String,
model_id: String,
api_type: Option<String>,
) -> Result<String, String> {
let url = format!("{}/chat/completions", normalize_base_url(&base_url));
let body = serde_json::json!({
"model": model_id,
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 16,
"stream": false
});
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 mut req = client.post(&url).json(&body);
if !api_key.is_empty() {
req = req.header("Authorization", format!("Bearer {api_key}"));
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()
}
}
let resp = req.send().await.map_err(|e| {
.await
.map_err(|e| {
if e.is_timeout() {
"请求超时 (30s)".to_string()
} else if e.is_connect() {
@@ -1299,16 +1398,7 @@ pub async fn test_model(
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
// 尝试提取错误信息
let msg = 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)
})
.unwrap_or_else(|| format!("HTTP {status}"));
let msg = extract_error_message(&text, status);
// 401/403 是认证错误,一定要报错
if status.as_u16() == 401 || status.as_u16() == 403 {
return Err(msg);
@@ -1324,6 +1414,29 @@ pub async fn test_model(
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")
@@ -1361,20 +1474,43 @@ pub async fn test_model(
/// 获取服务商的远程模型列表(调用 /models 接口)
#[tauri::command]
pub async fn list_remote_models(base_url: String, api_key: String) -> Result<Vec<String>, String> {
let url = format!("{}/models", normalize_base_url(&base_url));
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 mut req = client.get(&url);
if !api_key.is_empty() {
req = req.header("Authorization", format!("Bearer {api_key}"));
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()
}
}
let resp = req.send().await.map_err(|e| {
.await
.map_err(|e| {
if e.is_timeout() {
"请求超时 (15s),该服务商可能不支持模型列表接口".to_string()
} else if e.is_connect() {
@@ -1388,27 +1524,29 @@ pub async fn list_remote_models(base_url: String, api_key: String) -> Result<Vec
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
let msg = 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)
})
.unwrap_or_else(|| format!("HTTP {status}"));
let msg = extract_error_message(&text, status);
return Err(format!("获取模型列表失败: {msg}"));
}
// 解析 OpenAI 格式的 /models 响应
// 解析 OpenAI / Anthropic / Gemini 格式的 /models 响应
let ids = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|v| {
let data = v.get("data")?.as_array()?;
let mut ids: Vec<String> = data
.iter()
.filter_map(|m| m.get("id").and_then(|id| id.as_str()).map(String::from))
.collect();
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();
Some(ids)
})
@@ -1544,47 +1682,75 @@ pub fn patch_model_vision() -> Result<bool, String> {
Ok(changed)
}
/// 检查 ClawPanel 自身是否有新版本(通过 GitHub releases API
/// 检查 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(5))
.timeout(std::time::Duration::from_secs(8))
.user_agent("ClawPanel")
.build()
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?;
let url = "https://api.github.com/repos/qingchencloud/clawpanel/releases/latest";
let resp = client
.get(url)
.send()
.await
.map_err(|e| format!("请求失败: {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",
),
];
if !resp.status().is_success() {
return Err(format!("GitHub API 返回 {}", resp.status()));
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}");
}
}
}
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();
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(
"https://github.com/qingchencloud/clawpanel/releases".into(),
)),
);
Ok(Value::Object(result))
Err(last_err)
}
// === 面板配置 (clawpanel.json) ===
@@ -1620,3 +1786,175 @@ 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::io::{BufRead, BufReader};
use std::process::Stdio;
use tauri::Emitter;
let _ = app.emit("upgrade-log", "正在尝试自动安装 Git...");
#[cfg(target_os = "windows")]
{
// 尝试 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());
}
return 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());
}
return Err("xcode-select 安装失败,请手动安装 Xcode Command Line Tools 或 brew install git".to_string());
}
#[cfg(target_os = "linux")]
{
// 检测包管理器
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());
}
return 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(())
}

View File

@@ -2,27 +2,83 @@
/// 负责 Telegram / Discord / QQ Bot 等消息渠道的配置持久化与凭证校验
/// 配置写入 openclaw.json 的 channels / plugins 节点
use serde_json::{json, Map, Value};
use std::fs;
use std::path::{Path, PathBuf};
fn platform_storage_key(platform: &str) -> &str {
match platform {
"dingtalk" | "dingtalk-connector" => "dingtalk-connector",
_ => platform,
}
}
fn platform_list_id(platform: &str) -> &str {
match platform {
"dingtalk-connector" => "dingtalk",
_ => platform,
}
}
fn ensure_chat_completions_enabled(cfg: &mut Value) -> Result<(), String> {
let root = cfg.as_object_mut().ok_or("配置格式错误")?;
let gateway = root.entry("gateway").or_insert_with(|| json!({}));
let gateway_obj = gateway.as_object_mut().ok_or("gateway 节点格式错误")?;
let http = gateway_obj.entry("http").or_insert_with(|| json!({}));
let http_obj = http.as_object_mut().ok_or("gateway.http 节点格式错误")?;
let endpoints = http_obj.entry("endpoints").or_insert_with(|| json!({}));
let endpoints_obj = endpoints
.as_object_mut()
.ok_or("gateway.http.endpoints 节点格式错误")?;
let chat = endpoints_obj
.entry("chatCompletions")
.or_insert_with(|| json!({}));
let chat_obj = chat
.as_object_mut()
.ok_or("gateway.http.endpoints.chatCompletions 节点格式错误")?;
chat_obj.insert("enabled".into(), Value::Bool(true));
Ok(())
}
fn gateway_auth_mode(cfg: &Value) -> Option<&str> {
cfg.get("gateway")
.and_then(|g| g.get("auth"))
.and_then(|a| a.get("mode"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
}
fn gateway_auth_value(cfg: &Value, key: &str) -> Option<String> {
cfg.get("gateway")
.and_then(|g| g.get("auth"))
.and_then(|a| a.get(key))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(|v| v.to_string())
}
/// 读取指定平台的当前配置(从 openclaw.json 中提取表单可用的值)
#[tauri::command]
pub async fn read_platform_config(platform: String) -> Result<Value, String> {
let cfg = super::config::load_openclaw_json()?;
let storage_key = platform_storage_key(&platform);
// 从已有配置中提取用户可编辑字段
let saved = cfg
.get("channels")
.and_then(|c| c.get(&platform))
.and_then(|c| c.get(storage_key))
.cloned()
.unwrap_or(Value::Null);
if saved.is_null() {
return Ok(json!({ "exists": false }));
}
let mut form = Map::new();
let exists = !saved.is_null();
match platform.as_str() {
"discord" => {
if saved.is_null() {
return Ok(json!({ "exists": false }));
}
// Discord 配置在 openclaw.json 中是展开的 guilds 结构
// 需要反向提取成表单字段token, guildId, channelId
if let Some(t) = saved.get("token").and_then(|v| v.as_str()) {
@@ -43,6 +99,9 @@ pub async fn read_platform_config(platform: String) -> Result<Value, String> {
}
}
"telegram" => {
if saved.is_null() {
return Ok(json!({ "exists": false }));
}
// Telegram: botToken 直接保存, allowFrom 数组需要拼回逗号字符串
if let Some(t) = saved.get("botToken").and_then(|v| v.as_str()) {
form.insert("botToken".into(), Value::String(t.into()));
@@ -53,6 +112,9 @@ pub async fn read_platform_config(platform: String) -> Result<Value, String> {
}
}
"qqbot" => {
if saved.is_null() {
return Ok(json!({ "exists": false }));
}
// QQ Bot: token 格式为 "AppID:AppSecret",拆分回表单字段
if let Some(t) = saved.get("token").and_then(|v| v.as_str()) {
if let Some((app_id, app_secret)) = t.split_once(':') {
@@ -62,6 +124,9 @@ pub async fn read_platform_config(platform: String) -> Result<Value, String> {
}
}
"feishu" => {
if saved.is_null() {
return Ok(json!({ "exists": false }));
}
// 飞书: appId, appSecret, domain 直接保存
if let Some(v) = saved.get("appId").and_then(|v| v.as_str()) {
form.insert("appId".into(), Value::String(v.into()));
@@ -73,7 +138,39 @@ pub async fn read_platform_config(platform: String) -> Result<Value, String> {
form.insert("domain".into(), Value::String(v.into()));
}
}
"dingtalk" | "dingtalk-connector" => {
if let Some(v) = saved.get("clientId").and_then(|v| v.as_str()) {
form.insert("clientId".into(), Value::String(v.into()));
}
if let Some(v) = saved.get("clientSecret").and_then(|v| v.as_str()) {
form.insert("clientSecret".into(), Value::String(v.into()));
}
if let Some(v) = saved.get("gatewayToken").and_then(|v| v.as_str()) {
form.insert("gatewayToken".into(), Value::String(v.into()));
}
if let Some(v) = saved.get("gatewayPassword").and_then(|v| v.as_str()) {
form.insert("gatewayPassword".into(), Value::String(v.into()));
}
match gateway_auth_mode(&cfg) {
Some("token") => {
if let Some(v) = gateway_auth_value(&cfg, "token") {
form.insert("gatewayToken".into(), Value::String(v));
}
form.remove("gatewayPassword");
}
Some("password") => {
if let Some(v) = gateway_auth_value(&cfg, "password") {
form.insert("gatewayPassword".into(), Value::String(v));
}
form.remove("gatewayToken");
}
_ => {}
}
}
_ => {
if saved.is_null() {
return Ok(json!({ "exists": false }));
}
// 通用:原样返回字符串类型字段
if let Some(obj) = saved.as_object() {
for (k, v) in obj {
@@ -88,7 +185,7 @@ pub async fn read_platform_config(platform: String) -> Result<Value, String> {
}
}
Ok(json!({ "exists": true, "values": Value::Object(form) }))
Ok(json!({ "exists": exists, "values": Value::Object(form) }))
}
/// 保存平台配置到 openclaw.json
@@ -100,6 +197,7 @@ pub async fn save_messaging_platform(
app: tauri::AppHandle,
) -> Result<Value, String> {
let mut cfg = super::config::load_openclaw_json()?;
let storage_key = platform_storage_key(&platform).to_string();
let channels = cfg
.as_object_mut()
@@ -213,6 +311,8 @@ pub async fn save_messaging_platform(
entry.insert("enabled".into(), Value::Bool(true));
channels_map.insert("qqbot".into(), Value::Object(entry));
ensure_plugin_allowed(&mut cfg, "qqbot")?;
let _ = cleanup_legacy_plugin_backup_dir("qqbot");
}
"feishu" => {
let app_id = form_obj
@@ -250,6 +350,54 @@ pub async fn save_messaging_platform(
}
channels_map.insert("feishu".into(), Value::Object(entry));
ensure_plugin_allowed(&mut cfg, "feishu")?;
let _ = cleanup_legacy_plugin_backup_dir("feishu");
}
"dingtalk" | "dingtalk-connector" => {
let client_id = form_obj
.get("clientId")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
let client_secret = form_obj
.get("clientSecret")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
if client_id.is_empty() || client_secret.is_empty() {
return Err("Client ID 和 Client Secret 不能为空".into());
}
let mut entry = Map::new();
entry.insert("clientId".into(), Value::String(client_id));
entry.insert("clientSecret".into(), Value::String(client_secret));
entry.insert("enabled".into(), Value::Bool(true));
let gateway_token = form_obj
.get("gatewayToken")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if !gateway_token.is_empty() {
entry.insert("gatewayToken".into(), Value::String(gateway_token.into()));
}
let gateway_password = form_obj
.get("gatewayPassword")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if !gateway_password.is_empty() {
entry.insert("gatewayPassword".into(), Value::String(gateway_password.into()));
}
channels_map.insert(storage_key, Value::Object(entry));
ensure_plugin_allowed(&mut cfg, "dingtalk-connector")?;
ensure_chat_completions_enabled(&mut cfg)?;
let _ = cleanup_legacy_plugin_backup_dir("dingtalk-connector");
}
_ => {
// 通用平台:直接保存表单字段
@@ -258,7 +406,7 @@ pub async fn save_messaging_platform(
entry.insert(k.clone(), v.clone());
}
entry.insert("enabled".into(), Value::Bool(true));
channels_map.insert(platform.clone(), Value::Object(entry));
channels_map.insert(storage_key, Value::Object(entry));
}
}
@@ -278,9 +426,10 @@ pub async fn remove_messaging_platform(
app: tauri::AppHandle,
) -> Result<Value, String> {
let mut cfg = super::config::load_openclaw_json()?;
let storage_key = platform_storage_key(&platform);
if let Some(channels) = cfg.get_mut("channels").and_then(|c| c.as_object_mut()) {
channels.remove(&platform);
channels.remove(storage_key);
}
super::config::save_openclaw_json(&cfg)?;
@@ -297,10 +446,11 @@ pub async fn toggle_messaging_platform(
app: tauri::AppHandle,
) -> Result<Value, String> {
let mut cfg = super::config::load_openclaw_json()?;
let storage_key = platform_storage_key(&platform);
if let Some(entry) = cfg
.get_mut("channels")
.and_then(|c| c.get_mut(&platform))
.and_then(|c| c.get_mut(storage_key))
.and_then(|v| v.as_object_mut())
{
entry.insert("enabled".into(), Value::Bool(enabled));
@@ -328,6 +478,7 @@ pub async fn verify_bot_token(platform: String, form: Value) -> Result<Value, St
"telegram" => verify_telegram(&client, form_obj).await,
"qqbot" => verify_qqbot(&client, form_obj).await,
"feishu" => verify_feishu(&client, form_obj).await,
"dingtalk" | "dingtalk-connector" => verify_dingtalk(&client, form_obj).await,
_ => Ok(json!({
"valid": true,
"warnings": ["该平台暂不支持在线校验"]
@@ -345,7 +496,7 @@ pub async fn list_configured_platforms() -> Result<Value, String> {
for (name, val) in channels {
let enabled = val.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
result.push(json!({
"id": name,
"id": platform_list_id(name),
"enabled": enabled
}));
}
@@ -354,6 +505,41 @@ pub async fn list_configured_platforms() -> Result<Value, String> {
Ok(json!(result))
}
#[tauri::command]
pub async fn get_channel_plugin_status(plugin_id: String) -> Result<Value, String> {
let plugin_id = plugin_id.trim();
if plugin_id.is_empty() {
return Err("plugin_id 不能为空".into());
}
let plugin_dir = generic_plugin_dir(plugin_id);
let installed = plugin_dir.is_dir() && plugin_install_marker_exists(&plugin_dir);
let legacy_backup_detected = legacy_plugin_backup_dir(plugin_id).exists();
let cfg = super::config::load_openclaw_json().unwrap_or_else(|_| json!({}));
let allowed = cfg
.get("plugins")
.and_then(|p| p.get("allow"))
.and_then(|v| v.as_array())
.map(|arr| arr.iter().any(|v| v.as_str() == Some(plugin_id)))
.unwrap_or(false);
let enabled = cfg
.get("plugins")
.and_then(|p| p.get("entries"))
.and_then(|e| e.get(plugin_id))
.and_then(|entry| entry.get("enabled"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
Ok(json!({
"installed": installed,
"path": plugin_dir.to_string_lossy(),
"allowed": allowed,
"enabled": enabled,
"legacyBackupDetected": legacy_backup_detected
}))
}
// ── Discord 凭证校验 ──────────────────────────────────────
async fn verify_discord(
@@ -500,23 +686,412 @@ async fn verify_qqbot(
}
}
fn ensure_plugin_allowed(cfg: &mut Value, plugin_id: &str) -> Result<(), String> {
let root = cfg.as_object_mut().ok_or("配置格式错误")?;
let plugins = root.entry("plugins").or_insert_with(|| json!({}));
let plugins_map = plugins.as_object_mut().ok_or("plugins 节点格式错误")?;
let allow = plugins_map.entry("allow").or_insert_with(|| json!([]));
let allow_arr = allow.as_array_mut().ok_or("plugins.allow 节点格式错误")?;
if !allow_arr.iter().any(|v| v.as_str() == Some(plugin_id)) {
allow_arr.push(Value::String(plugin_id.to_string()));
}
let entries = plugins_map.entry("entries").or_insert_with(|| json!({}));
let entries_map = entries
.as_object_mut()
.ok_or("plugins.entries 节点格式错误")?;
let entry = entries_map
.entry(plugin_id.to_string())
.or_insert_with(|| json!({}));
let entry_obj = entry
.as_object_mut()
.ok_or("plugins.entries 条目格式错误")?;
entry_obj.insert("enabled".into(), Value::Bool(true));
Ok(())
}
fn plugin_backup_root() -> PathBuf {
super::openclaw_dir().join("backups").join("plugin-installs")
}
fn qqbot_plugin_dir() -> PathBuf {
super::openclaw_dir().join("extensions").join("qqbot")
}
fn qqbot_backup_dir() -> PathBuf {
plugin_backup_root().join("qqbot.__clawpanel_backup")
}
fn qqbot_config_backup_path() -> PathBuf {
plugin_backup_root().join("openclaw.qqbot-install.bak")
}
fn legacy_plugin_backup_dir(plugin_id: &str) -> PathBuf {
super::openclaw_dir()
.join("extensions")
.join(format!("{plugin_id}.__clawpanel_backup"))
}
fn cleanup_legacy_plugin_backup_dir(plugin_id: &str) -> Result<bool, String> {
let legacy_backup = legacy_plugin_backup_dir(plugin_id);
if !legacy_backup.exists() {
return Ok(false);
}
if legacy_backup.is_dir() {
fs::remove_dir_all(&legacy_backup).map_err(|e| format!("清理旧版插件备份失败: {e}"))?;
} else {
fs::remove_file(&legacy_backup).map_err(|e| format!("清理旧版插件备份失败: {e}"))?;
}
Ok(true)
}
fn plugin_install_marker_exists(plugin_dir: &Path) -> bool {
plugin_dir.join("package.json").is_file()
|| plugin_dir.join("plugin.ts").is_file()
|| plugin_dir.join("index.js").is_file()
|| plugin_dir.join("dist").join("index.js").is_file()
}
fn path_to_plugin_entry(path: &Path) -> String {
let mut normalized = path.to_string_lossy().replace('\\', "/");
while normalized.starts_with("./") {
normalized = normalized[2..].to_string();
}
format!("./{}", normalized.trim_start_matches('/'))
}
fn plugin_entry_exists(plugin_dir: &Path, entry: &str) -> bool {
plugin_dir.join(entry.trim_start_matches("./")).is_file()
}
fn synthesize_qqbot_runtime_entry(plugin_dir: &Path) -> Result<String, String> {
let channel = plugin_dir.join("src").join("channel.js");
let runtime = plugin_dir.join("src").join("runtime.js");
if !channel.is_file() || !runtime.is_file() {
return Err("QQBot 插件缺少运行时文件,无法自动修复".into());
}
let dist_dir = plugin_dir.join("dist");
fs::create_dir_all(&dist_dir).map_err(|e| format!("创建 dist 目录失败: {e}"))?;
let dist_entry = dist_dir.join("index.js");
let code = r#"import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
import { qqbotPlugin } from "../src/channel.js";
import { setQQBotRuntime } from "../src/runtime.js";
const plugin = {
id: "qqbot",
name: "QQ Bot",
description: "QQ Bot channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api) {
setQQBotRuntime(api.runtime);
api.registerChannel({ plugin: qqbotPlugin });
},
};
export default plugin;
"#;
fs::write(&dist_entry, code).map_err(|e| format!("写入 dist/index.js 失败: {e}"))?;
Ok("./dist/index.js".into())
}
fn repair_qqbot_package_manifest(plugin_dir: &Path) -> Result<String, String> {
let package_path = plugin_dir.join("package.json");
if !package_path.is_file() {
return Err("QQBot 插件缺少 package.json".into());
}
let raw = fs::read_to_string(&package_path).map_err(|e| format!("读取 package.json 失败: {e}"))?;
let mut pkg: Value =
serde_json::from_str(&raw).map_err(|e| format!("解析 package.json 失败: {e}"))?;
let desired_entry = if let Some(main) = pkg.get("main").and_then(|v| v.as_str()) {
let candidate = path_to_plugin_entry(Path::new(main));
if plugin_entry_exists(plugin_dir, &candidate) {
candidate
} else if main.replace('\\', "/") == "dist/index.js" {
synthesize_qqbot_runtime_entry(plugin_dir)?
} else {
return Err(format!("插件入口文件不存在: {main}"));
}
} else if plugin_entry_exists(plugin_dir, "./index.js") {
"./index.js".into()
} else if plugin_dir.join("index.ts").is_file() {
synthesize_qqbot_runtime_entry(plugin_dir)?
} else {
return Err("未找到可用的 QQBot 插件入口".into());
};
for field in ["openclaw", "clawdbot", "moltbot"] {
if let Some(obj) = pkg.get_mut(field).and_then(|v| v.as_object_mut()) {
obj.insert("extensions".into(), json!([desired_entry.clone()]));
}
}
let serialized =
serde_json::to_string_pretty(&pkg).map_err(|e| format!("序列化 package.json 失败: {e}"))?;
fs::write(&package_path, serialized).map_err(|e| format!("写入 package.json 失败: {e}"))?;
Ok(desired_entry)
}
fn restore_path(backup: &Path, target: &Path) -> Result<(), String> {
if target.exists() {
if target.is_dir() {
fs::remove_dir_all(target).map_err(|e| format!("清理目录失败: {e}"))?;
} else {
fs::remove_file(target).map_err(|e| format!("清理文件失败: {e}"))?;
}
}
if backup.exists() {
fs::rename(backup, target).map_err(|e| format!("恢复备份失败: {e}"))?;
}
Ok(())
}
fn cleanup_failed_qqbot_install(
had_plugin_backup: bool,
had_config_backup: bool,
) -> Result<(), String> {
let plugin_dir = qqbot_plugin_dir();
let plugin_backup = qqbot_backup_dir();
let config_path = super::openclaw_dir().join("openclaw.json");
let config_backup = qqbot_config_backup_path();
if plugin_dir.exists() {
fs::remove_dir_all(&plugin_dir).map_err(|e| format!("清理坏插件目录失败: {e}"))?;
}
if had_plugin_backup {
restore_path(&plugin_backup, &plugin_dir)?;
} else if plugin_backup.exists() {
fs::remove_dir_all(&plugin_backup).map_err(|e| format!("清理插件备份失败: {e}"))?;
}
if had_config_backup {
restore_path(&config_backup, &config_path)?;
} else if config_backup.exists() {
fs::remove_file(&config_backup).map_err(|e| format!("清理配置备份失败: {e}"))?;
}
Ok(())
}
fn generic_plugin_dir(plugin_id: &str) -> PathBuf {
super::openclaw_dir().join("extensions").join(plugin_id)
}
fn generic_plugin_backup_dir(plugin_id: &str) -> PathBuf {
plugin_backup_root().join(format!("{plugin_id}.__clawpanel_backup"))
}
fn generic_plugin_config_backup_path(plugin_id: &str) -> PathBuf {
plugin_backup_root().join(format!("openclaw.{plugin_id}-install.bak"))
}
fn cleanup_failed_plugin_install(
plugin_id: &str,
had_plugin_backup: bool,
had_config_backup: bool,
) -> Result<(), String> {
let plugin_dir = generic_plugin_dir(plugin_id);
let plugin_backup = generic_plugin_backup_dir(plugin_id);
let config_path = super::openclaw_dir().join("openclaw.json");
let config_backup = generic_plugin_config_backup_path(plugin_id);
if plugin_dir.exists() {
fs::remove_dir_all(&plugin_dir).map_err(|e| format!("清理坏插件目录失败: {e}"))?;
}
if had_plugin_backup {
restore_path(&plugin_backup, &plugin_dir)?;
} else if plugin_backup.exists() {
fs::remove_dir_all(&plugin_backup).map_err(|e| format!("清理插件备份失败: {e}"))?;
}
if had_config_backup {
restore_path(&config_backup, &config_path)?;
} else if config_backup.exists() {
fs::remove_file(&config_backup).map_err(|e| format!("清理配置备份失败: {e}"))?;
}
Ok(())
}
// ── QQ Bot 插件安装(带日志流) ──────────────────────────
#[tauri::command]
pub async fn install_channel_plugin(
app: tauri::AppHandle,
package_name: String,
plugin_id: String,
) -> Result<String, String> {
use std::io::{BufRead, BufReader};
use std::process::Stdio;
use tauri::Emitter;
let package_name = package_name.trim();
let plugin_id = plugin_id.trim();
if package_name.is_empty() || plugin_id.is_empty() {
return Err("package_name 和 plugin_id 不能为空".into());
}
let plugin_dir = generic_plugin_dir(plugin_id);
let plugin_backup = generic_plugin_backup_dir(plugin_id);
let config_path = super::openclaw_dir().join("openclaw.json");
let config_backup = generic_plugin_config_backup_path(plugin_id);
let had_existing_plugin = plugin_dir.exists();
let had_existing_config = config_path.exists();
let _ = app.emit("plugin-log", format!("正在安装插件 {} ...", package_name));
let _ = app.emit("plugin-progress", 10);
fs::create_dir_all(plugin_backup_root()).map_err(|e| format!("创建插件备份目录失败: {e}"))?;
if cleanup_legacy_plugin_backup_dir(plugin_id)? {
let _ = app.emit("plugin-log", "已清理旧版插件备份目录");
}
if plugin_backup.exists() {
let _ = fs::remove_dir_all(&plugin_backup);
}
if had_existing_plugin {
fs::rename(&plugin_dir, &plugin_backup).map_err(|e| format!("备份旧插件失败: {e}"))?;
let _ = app.emit("plugin-log", format!("检测到旧插件目录,已备份 {}", plugin_dir.display()));
}
if config_backup.exists() {
let _ = fs::remove_file(&config_backup);
}
if had_existing_config {
fs::copy(&config_path, &config_backup).map_err(|e| format!("备份配置失败: {e}"))?;
}
let spawn_result = crate::utils::openclaw_command()
.args(["plugins", "install", package_name])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
let mut child = match spawn_result {
Ok(child) => child,
Err(e) => {
let _ = cleanup_failed_plugin_install(plugin_id, had_existing_plugin, had_existing_config);
return Err(format!("启动 openclaw 失败: {}", e));
}
};
let stderr = child.stderr.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("plugin-log", &line);
}
}
});
let _ = app.emit("plugin-progress", 30);
let mut progress = 30;
if let Some(pipe) = child.stdout.take() {
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
let _ = app.emit("plugin-log", &line);
if progress < 90 {
progress += 10;
let _ = app.emit("plugin-progress", progress);
}
}
}
let _ = handle.join();
let _ = app.emit("plugin-progress", 95);
let status = child
.wait()
.map_err(|e| format!("等待安装进程失败: {}", e))?;
if !status.success() {
let rollback_err = cleanup_failed_plugin_install(plugin_id, had_existing_plugin, had_existing_config)
.err()
.unwrap_or_default();
let _ = app.emit("plugin-log", format!("插件 {} 安装失败,已回退", package_name));
return if rollback_err.is_empty() {
Err(format!("插件安装失败:{}", package_name))
} else {
Err(format!("插件安装失败:{};回退失败:{}", package_name, rollback_err))
};
}
let finalize = (|| -> Result<(), String> {
let mut cfg = super::config::load_openclaw_json()?;
ensure_plugin_allowed(&mut cfg, plugin_id)?;
super::config::save_openclaw_json(&cfg)?;
Ok(())
})();
if let Err(err) = finalize {
let rollback_err = cleanup_failed_plugin_install(plugin_id, had_existing_plugin, had_existing_config)
.err()
.unwrap_or_default();
let _ = app.emit("plugin-log", format!("插件 {} 安装后收尾失败,已回退: {}", package_name, err));
return if rollback_err.is_empty() {
Err(format!("插件安装失败:{err}"))
} else {
Err(format!("插件安装失败:{err};回退失败:{rollback_err}"))
};
}
if plugin_backup.exists() {
let _ = fs::remove_dir_all(&plugin_backup);
}
if config_backup.exists() {
let _ = fs::remove_file(&config_backup);
}
let _ = app.emit("plugin-progress", 100);
let _ = app.emit("plugin-log", format!("插件 {} 安装完成", package_name));
Ok("安装成功".into())
}
#[tauri::command]
pub async fn install_qqbot_plugin(app: tauri::AppHandle) -> Result<String, String> {
use std::io::{BufRead, BufReader};
use std::process::Stdio;
use tauri::Emitter;
let plugin_dir = qqbot_plugin_dir();
let plugin_backup = qqbot_backup_dir();
let config_path = super::openclaw_dir().join("openclaw.json");
let config_backup = qqbot_config_backup_path();
let had_existing_plugin = plugin_dir.exists();
let had_existing_config = config_path.exists();
let _ = app.emit("plugin-log", "正在安装 QQBot 社区插件 @sliverp/qqbot ...");
let _ = app.emit("plugin-progress", 10);
let mut child = crate::utils::openclaw_command()
fs::create_dir_all(plugin_backup_root()).map_err(|e| format!("创建插件备份目录失败: {e}"))?;
if cleanup_legacy_plugin_backup_dir("qqbot")? {
let _ = app.emit("plugin-log", "已清理旧版 QQBot 插件备份目录");
}
if plugin_backup.exists() {
let _ = fs::remove_dir_all(&plugin_backup);
}
if had_existing_plugin {
fs::rename(&plugin_dir, &plugin_backup).map_err(|e| format!("备份旧 QQBot 插件失败: {e}"))?;
}
if config_backup.exists() {
let _ = fs::remove_file(&config_backup);
}
if had_existing_config {
fs::copy(&config_path, &config_backup).map_err(|e| format!("备份配置失败: {e}"))?;
}
let spawn_result = crate::utils::openclaw_command()
.args(["plugins", "install", "@sliverp/qqbot@latest"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("启动 openclaw 失败: {}", e))?;
.spawn();
let mut child = match spawn_result {
Ok(child) => child,
Err(e) => {
let _ = cleanup_failed_qqbot_install(had_existing_plugin, had_existing_config);
return Err(format!("启动 openclaw 失败: {}", e));
}
};
let stderr = child.stderr.take();
let app2 = app.clone();
@@ -547,15 +1122,48 @@ pub async fn install_qqbot_plugin(app: tauri::AppHandle) -> Result<String, Strin
let status = child
.wait()
.map_err(|e| format!("等待安装进程失败: {}", e))?;
let _ = app.emit("plugin-progress", 100);
if !status.success() {
let _ = app.emit("plugin-log", "QQBot 插件安装失败");
return Err("插件安装失败,请查看日志".into());
let finalize = (|| -> Result<(), String> {
if !status.success() {
let _ = app.emit("plugin-log", "安装器返回失败,正在尝试自动修复 QQBot 插件...");
}
let entry = repair_qqbot_package_manifest(&plugin_dir)?;
let _ = app.emit("plugin-log", format!("已修正 QQBot 插件入口: {entry}"));
let mut cfg = super::config::load_openclaw_json()?;
ensure_plugin_allowed(&mut cfg, "qqbot")?;
super::config::save_openclaw_json(&cfg)?;
let _ = app.emit("plugin-log", "已补齐 plugins.allow 与 entries.qqbot.enabled");
Ok(())
})();
match finalize {
Ok(()) => {
let _ = app.emit("plugin-progress", 100);
if plugin_backup.exists() {
let _ = fs::remove_dir_all(&plugin_backup);
}
if config_backup.exists() {
let _ = fs::remove_file(&config_backup);
}
let _ = app.emit("plugin-log", "QQBot 插件安装完成");
Ok("安装成功".into())
}
Err(err) => {
let _ = app.emit("plugin-log", format!("自动修复失败,正在回退: {err}"));
let rollback_err = cleanup_failed_qqbot_install(had_existing_plugin, had_existing_config)
.err()
.unwrap_or_default();
let _ = app.emit("plugin-progress", 100);
let _ = app.emit("plugin-log", "QQBot 插件安装失败,已自动回退到安装前状态");
if rollback_err.is_empty() {
Err(format!("插件安装失败:{err}"))
} else {
Err(format!("插件安装失败:{err};回退失败:{rollback_err}"))
}
}
}
let _ = app.emit("plugin-log", "QQBot 插件安装完成");
Ok("安装成功".into())
}
// ── Telegram 凭证校验 ─────────────────────────────────────
@@ -689,3 +1297,75 @@ async fn verify_feishu(
}))
}
}
// ── 钉钉凭证校验 ──────────────────────────────────────
async fn verify_dingtalk(
client: &reqwest::Client,
form: &Map<String, Value>,
) -> Result<Value, String> {
let client_id = form
.get("clientId")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let client_secret = form
.get("clientSecret")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if client_id.is_empty() {
return Ok(json!({ "valid": false, "errors": ["Client ID 不能为空"] }));
}
if client_secret.is_empty() {
return Ok(json!({ "valid": false, "errors": ["Client Secret 不能为空"] }));
}
let resp = client
.post("https://api.dingtalk.com/v1.0/oauth2/accessToken")
.json(&json!({
"appKey": client_id,
"appSecret": client_secret
}))
.send()
.await
.map_err(|e| format!("钉钉 API 连接失败: {}", e))?;
let body: Value = resp
.json()
.await
.map_err(|e| format!("解析响应失败: {}", e))?;
if body
.get("accessToken")
.and_then(|v| v.as_str())
.filter(|v| !v.is_empty())
.is_some()
|| body
.get("access_token")
.and_then(|v| v.as_str())
.filter(|v| !v.is_empty())
.is_some()
{
Ok(json!({
"valid": true,
"errors": [],
"details": [
format!("AppKey: {}", client_id),
"已通过 accessToken 接口校验".to_string()
]
}))
} else {
let msg = body
.get("message")
.or_else(|| body.get("msg"))
.or_else(|| body.get("errmsg"))
.and_then(|v| v.as_str())
.unwrap_or("凭证无效,请检查 Client ID 和 Client Secret");
Ok(json!({
"valid": false,
"errors": [msg]
}))
}
}

View File

@@ -1,5 +1,5 @@
use std::path::PathBuf;
use std::sync::OnceLock;
use std::sync::RwLock;
pub mod agent;
pub mod assistant;
@@ -20,7 +20,8 @@ pub fn openclaw_dir() -> PathBuf {
}
/// 缓存 enhanced_path 结果,避免每次调用都扫描文件系统
static ENHANCED_PATH_CACHE: OnceLock<String> = OnceLock::new();
/// 使用 RwLock 替代 OnceLock支持运行时刷新缓存
static ENHANCED_PATH_CACHE: RwLock<Option<String>> = RwLock::new(None);
/// Tauri 应用启动时 PATH 可能不完整:
/// - macOS 从 Finder 启动时 PATH 只有 /usr/bin:/bin:/usr/sbin:/sbin
@@ -28,7 +29,26 @@ static ENHANCED_PATH_CACHE: OnceLock<String> = OnceLock::new();
///
/// 补充 Node.js / npm 常见安装路径
pub fn enhanced_path() -> String {
ENHANCED_PATH_CACHE.get_or_init(build_enhanced_path).clone()
// 先尝试读缓存
if let Ok(guard) = ENHANCED_PATH_CACHE.read() {
if let Some(ref cached) = *guard {
return cached.clone();
}
}
// 缓存为空,重新构建
let path = build_enhanced_path();
if let Ok(mut guard) = ENHANCED_PATH_CACHE.write() {
*guard = Some(path.clone());
}
path
}
/// 刷新 enhanced_path 缓存,使新设置的 Node.js 路径立即生效(无需重启应用)
pub fn refresh_enhanced_path() {
let new_path = build_enhanced_path();
if let Ok(mut guard) = ENHANCED_PATH_CACHE.write() {
*guard = Some(new_path);
}
}
fn build_enhanced_path() -> String {
@@ -242,17 +262,20 @@ fn build_enhanced_path() -> String {
}
let mut parts: Vec<&str> = vec![];
if !current.is_empty() {
parts.push(&current);
}
// 用户自定义路径优先级最高
if let Some(ref cp) = custom_path {
parts.push(cp.as_str());
}
// 然后是默认扫描到的路径
for p in &extra {
if std::path::Path::new(p).exists() {
parts.push(p.as_str());
}
}
// 最后是系统 PATH
if !current.is_empty() {
parts.push(&current);
}
parts.join(";")
}
}

View File

@@ -191,3 +191,70 @@ pub fn check_pairing_status() -> Result<bool, String> {
Ok(paired.get(device_id).is_some())
}
async fn run_pairing_command(args: Vec<String>) -> Result<String, String> {
let mut cmd = crate::utils::openclaw_command_async();
cmd.args(args);
let output = cmd
.output()
.await
.map_err(|e| format!("执行 openclaw 失败: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let message = match (stdout.is_empty(), stderr.is_empty()) {
(false, false) => format!("{stdout}\n{stderr}"),
(false, true) => stdout,
(true, false) => stderr,
(true, true) => String::new(),
};
if output.status.success() {
Ok(if message.is_empty() {
"操作完成".into()
} else {
message
})
} else {
Err(if message.is_empty() {
format!("命令执行失败: {}", output.status)
} else {
message
})
}
}
#[tauri::command]
pub async fn pairing_list_channel(channel: String) -> Result<String, String> {
let channel = channel.trim();
if channel.is_empty() {
return Err("channel 不能为空".into());
}
run_pairing_command(vec!["pairing".into(), "list".into(), channel.into()]).await
}
#[tauri::command]
pub async fn pairing_approve_channel(
channel: String,
code: String,
notify: bool,
) -> Result<String, String> {
let channel = channel.trim();
let code = code.trim();
if channel.is_empty() {
return Err("channel 不能为空".into());
}
if code.is_empty() {
return Err("配对码不能为空".into());
}
let mut args = vec![
"pairing".into(),
"approve".into(),
channel.into(),
code.into(),
];
if notify {
args.push("--notify".into());
}
run_pairing_command(args).await
}

View File

@@ -93,11 +93,17 @@ pub fn run() {
config::write_panel_config,
config::get_npm_registry,
config::set_npm_registry,
config::check_git,
config::auto_install_git,
config::configure_git_https,
config::invalidate_path_cache,
// 设备密钥 + Gateway 握手
device::create_connect_frame,
// 设备配对
pairing::auto_pair_device,
pairing::check_pairing_status,
pairing::pairing_list_channel,
pairing::pairing_approve_channel,
// 服务
service::get_services_status,
service::start_service,
@@ -149,6 +155,8 @@ pub fn run() {
messaging::toggle_messaging_platform,
messaging::verify_bot_token,
messaging::list_configured_platforms,
messaging::get_channel_plugin_status,
messaging::install_channel_plugin,
messaging::install_qqbot_plugin,
// Skills 管理openclaw skills CLI
skills::skills_list,

View File

@@ -1,16 +1,40 @@
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
/// Windows: 在 PATH 中查找 openclaw.cmd 的完整路径
/// 避免通过 `cmd /c openclaw` 调用时 npm .cmd shim 中的引号导致
/// "\"node\"" is not recognized 错误
#[cfg(target_os = "windows")]
fn find_openclaw_cmd() -> Option<std::path::PathBuf> {
let path = crate::commands::enhanced_path();
for dir in path.split(';') {
let candidate = std::path::Path::new(dir).join("openclaw.cmd");
if candidate.exists() {
return Some(candidate);
}
}
None
}
/// 跨平台获取 openclaw 命令的方法(同步版本)
/// 在 Windows 上使用 `cmd /c openclaw` 以兼容全局 npm 路径下的 `.cmd` 脚本
#[allow(dead_code)]
pub fn openclaw_command() -> std::process::Command {
#[cfg(target_os = "windows")]
{
const CREATE_NO_WINDOW: u32 = 0x08000000;
let enhanced = crate::commands::enhanced_path();
// 优先:找到 openclaw.cmd 完整路径,用 cmd /c "完整路径" 避免引号问题
if let Some(cmd_path) = find_openclaw_cmd() {
let mut cmd = std::process::Command::new("cmd");
cmd.arg("/c").arg(cmd_path);
cmd.env("PATH", &enhanced);
cmd.creation_flags(CREATE_NO_WINDOW);
return cmd;
}
// 兜底:直接用 cmd /c openclaw
let mut cmd = std::process::Command::new("cmd");
cmd.arg("/c").arg("openclaw");
cmd.env("PATH", crate::commands::enhanced_path());
cmd.env("PATH", &enhanced);
cmd.creation_flags(CREATE_NO_WINDOW);
cmd
}
@@ -27,9 +51,19 @@ pub fn openclaw_command_async() -> tokio::process::Command {
#[cfg(target_os = "windows")]
{
const CREATE_NO_WINDOW: u32 = 0x08000000;
let enhanced = crate::commands::enhanced_path();
// 优先:找到 openclaw.cmd 完整路径
if let Some(cmd_path) = find_openclaw_cmd() {
let mut cmd = tokio::process::Command::new("cmd");
cmd.arg("/c").arg(cmd_path);
cmd.env("PATH", &enhanced);
cmd.creation_flags(CREATE_NO_WINDOW);
return cmd;
}
// 兜底
let mut cmd = tokio::process::Command::new("cmd");
cmd.arg("/c").arg("openclaw");
cmd.env("PATH", crate::commands::enhanced_path());
cmd.env("PATH", &enhanced);
cmd.creation_flags(CREATE_NO_WINDOW);
cmd
}