mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-26 19:00:00 +08:00
feat: v0.6.0 — 公益AI接口 + Agent灵魂借尸还魂 + 知识库 + 全局AI诊断 + 官网改版
This commit is contained in:
10
src-tauri/Cargo.lock
generated
10
src-tauri/Cargo.lock
generated
@@ -328,13 +328,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clawpanel"
|
||||
version = "0.5.7"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"ed25519-dalek",
|
||||
"rand 0.8.5",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -343,6 +344,7 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-shell",
|
||||
"tokio",
|
||||
"urlencoding",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -4361,6 +4363,12 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "urlpattern"
|
||||
version = "0.3.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "clawpanel"
|
||||
version = "0.5.7"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
description = "ClawPanel - OpenClaw 可视化管理面板"
|
||||
authors = ["qingchencloud"]
|
||||
@@ -28,4 +28,6 @@ ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
sha2 = "0.10"
|
||||
rand = "0.8"
|
||||
base64 = "0.22"
|
||||
urlencoding = "2"
|
||||
regex = "1"
|
||||
tokio = { version = "1", features = ["process", "time"] }
|
||||
|
||||
@@ -357,6 +357,124 @@ async fn get_port_process(port: u16) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// 联网搜索(DuckDuckGo HTML)
|
||||
#[tauri::command]
|
||||
pub async fn assistant_web_search(
|
||||
query: String,
|
||||
max_results: Option<usize>,
|
||||
) -> Result<String, String> {
|
||||
let max = max_results.unwrap_or(5);
|
||||
let url = format!(
|
||||
"https://html.duckduckgo.com/html/?q={}",
|
||||
urlencoding::encode(&query)
|
||||
);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?;
|
||||
|
||||
let html = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("搜索请求失败: {e}"))?
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("读取搜索结果失败: {e}"))?;
|
||||
|
||||
// 解析搜索结果
|
||||
let mut results = Vec::new();
|
||||
let re_result = regex::Regex::new(
|
||||
r#"class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)</a>[\s\S]*?class="result__snippet"[^>]*>([\s\S]*?)</a>"#
|
||||
).unwrap();
|
||||
|
||||
for cap in re_result.captures_iter(&html) {
|
||||
if results.len() >= max {
|
||||
break;
|
||||
}
|
||||
let raw_url = &cap[1];
|
||||
let title = regex::Regex::new(r"<[^>]+>")
|
||||
.unwrap()
|
||||
.replace_all(&cap[2], "")
|
||||
.trim()
|
||||
.to_string();
|
||||
let snippet = regex::Regex::new(r"<[^>]+>")
|
||||
.unwrap()
|
||||
.replace_all(&cap[3], "")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
// 解码 DuckDuckGo 的重定向 URL
|
||||
let final_url = if let Some(pos) = raw_url.find("uddg=") {
|
||||
let encoded = &raw_url[pos + 5..];
|
||||
let end = encoded.find('&').unwrap_or(encoded.len());
|
||||
urlencoding::decode(&encoded[..end])
|
||||
.unwrap_or_else(|_| encoded[..end].into())
|
||||
.to_string()
|
||||
} else {
|
||||
raw_url.to_string()
|
||||
};
|
||||
|
||||
if !title.is_empty() && !final_url.is_empty() {
|
||||
results.push((title, final_url, snippet));
|
||||
}
|
||||
}
|
||||
|
||||
if results.is_empty() {
|
||||
return Ok(format!("搜索「{}」未找到相关结果。", query));
|
||||
}
|
||||
|
||||
let mut output = format!("搜索「{}」找到 {} 条结果:\n\n", query, results.len());
|
||||
for (i, (title, url, snippet)) in results.iter().enumerate() {
|
||||
output.push_str(&format!(
|
||||
"{}. **{}**\n {}\n {}\n\n",
|
||||
i + 1,
|
||||
title,
|
||||
url,
|
||||
snippet
|
||||
));
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// 抓取 URL 内容(通过 Jina Reader API)
|
||||
#[tauri::command]
|
||||
pub async fn assistant_fetch_url(url: String) -> Result<String, String> {
|
||||
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||
return Err("URL 必须以 http:// 或 https:// 开头".into());
|
||||
}
|
||||
|
||||
let jina_url = format!("https://r.jina.ai/{}", url);
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("Mozilla/5.0")
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?;
|
||||
|
||||
let content = client
|
||||
.get(&jina_url)
|
||||
.header("Accept", "text/plain")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("抓取失败: {e}"))?
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("读取内容失败: {e}"))?;
|
||||
|
||||
if content.len() > 100_000 {
|
||||
Ok(format!(
|
||||
"{}\n\n[内容已截断,超过 100KB 限制]",
|
||||
&content[..100_000]
|
||||
))
|
||||
} else if content.is_empty() {
|
||||
Ok("(页面内容为空)".into())
|
||||
} else {
|
||||
Ok(content)
|
||||
}
|
||||
}
|
||||
|
||||
/// 列出目录内容
|
||||
#[tauri::command]
|
||||
pub async fn assistant_list_dir(path: String) -> Result<String, String> {
|
||||
|
||||
@@ -560,12 +560,16 @@ pub async fn upgrade_openclaw(app: tauri::AppHandle, source: String) -> Result<S
|
||||
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);
|
||||
@@ -587,8 +591,13 @@ pub async fn upgrade_openclaw(app: tauri::AppHandle, source: String) -> Result<S
|
||||
let _ = app.emit("upgrade-progress", 100);
|
||||
|
||||
if !status.success() {
|
||||
let _ = app.emit("upgrade-log", "❌ 升级失败");
|
||||
return Err("升级失败,请查看日志".into());
|
||||
let code = status.code().map(|c| c.to_string()).unwrap_or("unknown".into());
|
||||
let _ = app.emit("upgrade-log", format!("❌ 升级失败 (exit code: {code})"));
|
||||
// 把 stderr 最后 15 行带进错误消息,确保前端诊断函数能匹配到
|
||||
// npm 内部错误码(如 -4058 ENOENT、EPERM 等)
|
||||
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 始终可用)
|
||||
@@ -1024,6 +1033,24 @@ 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 &[
|
||||
"/chat/completions",
|
||||
"/completions",
|
||||
"/responses",
|
||||
"/messages",
|
||||
"/models",
|
||||
] {
|
||||
if base.ends_with(suffix) {
|
||||
base.truncate(base.len() - suffix.len());
|
||||
break;
|
||||
}
|
||||
}
|
||||
base.trim_end_matches('/').to_string()
|
||||
}
|
||||
|
||||
/// 测试模型连通性:向 provider 发送一个简单的 chat completion 请求
|
||||
#[tauri::command]
|
||||
pub async fn test_model(
|
||||
@@ -1031,7 +1058,7 @@ pub async fn test_model(
|
||||
api_key: String,
|
||||
model_id: String,
|
||||
) -> Result<String, String> {
|
||||
let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
|
||||
let url = format!("{}/chat/completions", normalize_base_url(&base_url));
|
||||
|
||||
let body = serde_json::json!({
|
||||
"model": model_id,
|
||||
@@ -1100,7 +1127,7 @@ 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", base_url.trim_end_matches('/'));
|
||||
let url = format!("{}/models", normalize_base_url(&base_url));
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
|
||||
@@ -364,13 +364,13 @@ echo "安装完成"
|
||||
let mut child = {
|
||||
let install_script = r#"
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$binDir = Join-Path $env:USERPROFILE 'bin'
|
||||
if (-not (Test-Path $binDir)) { New-Item -ItemType Directory -Path $binDir -Force | Out-Null }
|
||||
Write-Output '下载 cftunnel...'
|
||||
$url = 'https://github.com/qingchencloud/cftunnel/releases/latest/download/cftunnel-windows-amd64.exe'
|
||||
$dest = Join-Path $binDir 'cftunnel.exe'
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
Invoke-WebRequest -Uri $url -OutFile $dest -UseBasicParsing
|
||||
Write-Output '通过官方安装脚本安装 cftunnel...'
|
||||
$tmp = Join-Path $env:TEMP 'install-cftunnel.ps1'
|
||||
Write-Output '下载安装脚本到临时文件...'
|
||||
Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/qingchencloud/cftunnel/main/install.ps1' -OutFile $tmp -UseBasicParsing
|
||||
Write-Output '执行安装脚本...'
|
||||
& $tmp
|
||||
Remove-Item $tmp -ErrorAction SilentlyContinue
|
||||
Write-Output '安装完成'
|
||||
"#;
|
||||
// 使用完整路径调用 PowerShell,避免 MSYS2/Git Bash 环境下找不到
|
||||
@@ -434,7 +434,7 @@ Write-Output '安装完成'
|
||||
Ok("安装成功".into())
|
||||
}
|
||||
|
||||
/// 一键安装 ClawApp(通过 npm)
|
||||
/// 一键安装 ClawApp(通过官方安装脚本)
|
||||
#[tauri::command]
|
||||
pub async fn install_clawapp(app: tauri::AppHandle) -> Result<String, String> {
|
||||
use std::io::{BufRead, BufReader};
|
||||
@@ -444,25 +444,58 @@ pub async fn install_clawapp(app: tauri::AppHandle) -> Result<String, String> {
|
||||
let _ = app.emit("install-log", "开始安装 ClawApp...");
|
||||
let _ = app.emit("install-progress", 10);
|
||||
|
||||
let _ = app.emit("install-log", "通过 npm 安装 clawapp...");
|
||||
let _ = app.emit("install-log", "下载安装脚本...");
|
||||
let _ = app.emit("install-progress", 30);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let mut child = {
|
||||
let mut cmd = Command::new("cmd");
|
||||
cmd.args(["/c", "npm", "install", "-g", "clawapp"]);
|
||||
cmd.creation_flags(0x08000000);
|
||||
cmd.stdout(Stdio::piped())
|
||||
let install_script = r#"
|
||||
#!/bin/bash
|
||||
set -e
|
||||
echo "通过官方安装脚本安装 ClawApp..."
|
||||
curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawapp/main/install.sh | bash
|
||||
echo "安装完成"
|
||||
"#;
|
||||
Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg(install_script)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动安装进程失败: {e}"))?
|
||||
};
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[cfg(target_os = "windows")]
|
||||
let mut child = {
|
||||
Command::new("npm")
|
||||
.args(["install", "-g", "clawapp"])
|
||||
.stdout(Stdio::piped())
|
||||
let install_script = r#"
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Write-Output '通过官方安装脚本安装 ClawApp...'
|
||||
$tmp = Join-Path $env:TEMP 'install-clawapp.ps1'
|
||||
Write-Output '下载安装脚本到临时文件...'
|
||||
Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/qingchencloud/clawapp/main/install.ps1' -OutFile $tmp -UseBasicParsing
|
||||
Write-Output '执行安装脚本...'
|
||||
& $tmp -Auto
|
||||
Remove-Item $tmp -ErrorAction SilentlyContinue
|
||||
Write-Output '安装完成'
|
||||
"#;
|
||||
let ps_path = std::env::var("SystemRoot")
|
||||
.map(|root| {
|
||||
format!(
|
||||
"{}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
|
||||
root
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|_| "powershell.exe".to_string());
|
||||
let mut cmd = Command::new(&ps_path);
|
||||
cmd.args([
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
install_script,
|
||||
]);
|
||||
cmd.creation_flags(0x08000000);
|
||||
cmd.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动安装进程失败: {e}"))?
|
||||
|
||||
@@ -53,7 +53,12 @@ fn build_enhanced_path() -> String {
|
||||
format!("{}/.volta/bin", home.display()),
|
||||
format!("{}/.nodenv/shims", home.display()),
|
||||
format!("{}/n/bin", home.display()),
|
||||
format!("{}/.npm-global/bin", home.display()),
|
||||
];
|
||||
// NPM_CONFIG_PREFIX: 用户通过 npm config set prefix 自定义的全局安装路径
|
||||
if let Ok(prefix) = std::env::var("NPM_CONFIG_PREFIX") {
|
||||
extra.push(format!("{}/bin", prefix));
|
||||
}
|
||||
// 扫描 nvm 实际安装的版本目录(兼容无 current 符号链接的情况)
|
||||
let nvm_versions = home.join(".nvm/versions/node");
|
||||
if nvm_versions.is_dir() {
|
||||
@@ -104,7 +109,12 @@ fn build_enhanced_path() -> String {
|
||||
format!("{}/.volta/bin", home.display()),
|
||||
format!("{}/.nodenv/shims", home.display()),
|
||||
format!("{}/n/bin", home.display()),
|
||||
format!("{}/.npm-global/bin", home.display()),
|
||||
];
|
||||
// NPM_CONFIG_PREFIX: 用户通过 npm config set prefix 自定义的全局安装路径
|
||||
if let Ok(prefix) = std::env::var("NPM_CONFIG_PREFIX") {
|
||||
extra.push(format!("{}/bin", prefix));
|
||||
}
|
||||
// NVM_DIR 环境变量(用户可能自定义了 nvm 安装目录)
|
||||
let nvm_dir = std::env::var("NVM_DIR")
|
||||
.ok()
|
||||
|
||||
@@ -265,16 +265,37 @@ mod platform {
|
||||
#[cfg(target_os = "windows")]
|
||||
mod platform {
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::sync::Mutex;
|
||||
use tokio::process::Command as TokioCommand;
|
||||
|
||||
/// 缓存 is_cli_installed 结果,避免每 15 秒 polling 都 spawn cmd.exe
|
||||
static CLI_CACHE: Mutex<Option<(bool, std::time::Instant)>> = Mutex::new(None);
|
||||
const CLI_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
/// Windows 不需要 UID
|
||||
pub fn current_uid() -> Result<u32, String> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// 检测 openclaw CLI 是否已安装
|
||||
/// 检测 openclaw CLI 是否已安装(带 60s 缓存,避免频繁 spawn 进程)
|
||||
pub fn is_cli_installed() -> bool {
|
||||
// 方式1: 检查常见文件路径
|
||||
// 检查缓存
|
||||
if let Ok(guard) = CLI_CACHE.lock() {
|
||||
if let Some((val, ts)) = *guard {
|
||||
if ts.elapsed() < CLI_CACHE_TTL {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
}
|
||||
let result = check_cli_installed_inner();
|
||||
if let Ok(mut guard) = CLI_CACHE.lock() {
|
||||
*guard = Some((result, std::time::Instant::now()));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn check_cli_installed_inner() -> bool {
|
||||
// 方式1: 检查常见文件路径(零进程,最快)
|
||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||
let cmd_path = std::path::Path::new(&appdata)
|
||||
.join("npm")
|
||||
|
||||
@@ -84,6 +84,8 @@ pub fn run() {
|
||||
assistant::assistant_system_info,
|
||||
assistant::assistant_list_processes,
|
||||
assistant::assistant_check_port,
|
||||
assistant::assistant_web_search,
|
||||
assistant::assistant_fetch_url,
|
||||
// 数据目录 & 图片存储
|
||||
assistant::assistant_ensure_data_dir,
|
||||
assistant::assistant_save_image,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
|
||||
"productName": "ClawPanel",
|
||||
"version": "0.5.7",
|
||||
"version": "0.6.0",
|
||||
"identifier": "ai.openclaw.clawpanel",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
Reference in New Issue
Block a user