Files
clawpanel/src-tauri/src/commands/assistant.rs
晴天 394813a96c feat: v0.9.1 — 面板设置页、网络代理、后台安装、模型服务商扩展、多项修复
新功能:
- 新增独立面板设置页面(网络代理 + 代理测试 + 模型代理开关 + npm源)
- 网络代理支持:下载类操作走代理,自动绕过内网地址
- 安装/升级/卸载改为后台执行,不再阻塞界面
- 全局任务状态栏:关闭弹窗后顶部显示进度,可重新查看日志
- 安装/卸载完成后自动刷新界面状态
- 新增多个模型服务商快捷配置(硅基流动、火山引擎、阿里云百炼、智谱AI、MiniMax、NVIDIA NIM、胜算云)
- AI助手浮动按钮恢复,首次提示可拖动,实时聊天页隐藏

修复:
- 修复版本更新误判(本地版本高于远端不再误弹更新)
- 修复Windows下nvm/自定义Node路径CLI检测
- 修复npm EEXIST文件冲突(--force + 安装前自动清理)
- 修复汉化版-zh.x后缀版本比较错误
- 修复模型URL自动拼接/v1问题
- 修复切换版本后Gateway重装失败(PATH缓存刷新)
- 修复切换助手服务商时旧模型名残留

优化:
- macOS图标改用docs/logo.png统一生成
- 内置推荐版本号更新到OpenClaw 2026.3.13
- 错误诊断增强(EEXIST识别)
- 弹窗标题根据操作类型显示
- 新增版本维护文档
2026-03-14 19:57:22 +08:00

511 lines
16 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.
use base64::{engine::general_purpose, Engine as _};
/// AI 助手工具命令
/// 提供终端执行、文件读写、目录列表等能力
/// 仅在用户主动开启工具后由 AI 调用
#[cfg(target_os = "windows")]
#[allow(unused_imports)]
use std::os::windows::process::CommandExt;
use std::path::PathBuf;
/// 审计日志:记录 AI 助手的敏感操作exec / read / write
fn audit_log(action: &str, detail: &str) {
let log_dir = super::openclaw_dir().join("logs");
let _ = std::fs::create_dir_all(&log_dir);
let log_path = log_dir.join("assistant-audit.log");
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
let line = format!("[{ts}] [{action}] {detail}\n");
let _ = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.and_then(|mut f| std::io::Write::write_all(&mut f, line.as_bytes()));
}
/// ClawPanel 数据目录(~/.openclaw/clawpanel/
fn data_dir() -> PathBuf {
super::openclaw_dir().join("clawpanel")
}
/// 确保数据目录及子目录存在,返回目录路径
#[tauri::command]
pub async fn assistant_ensure_data_dir() -> Result<String, String> {
let base = data_dir();
let subdirs = ["images", "sessions", "cache"];
for sub in &subdirs {
let dir = base.join(sub);
tokio::fs::create_dir_all(&dir)
.await
.map_err(|e| format!("创建目录 {} 失败: {e}", dir.display()))?;
}
Ok(base.to_string_lossy().to_string())
}
/// 保存图片base64 → 文件),返回文件路径
#[tauri::command]
pub async fn assistant_save_image(id: String, data: String) -> Result<String, String> {
let dir = data_dir().join("images");
tokio::fs::create_dir_all(&dir)
.await
.map_err(|e| format!("创建目录失败: {e}"))?;
// data 可能包含 data:image/xxx;base64, 前缀
let pure_b64 = if let Some(pos) = data.find(",") {
&data[pos + 1..]
} else {
&data
};
// 从 data URI 提取扩展名
let ext = if data.starts_with("data:image/png") {
"png"
} else if data.starts_with("data:image/gif") {
"gif"
} else if data.starts_with("data:image/webp") {
"webp"
} else {
"jpg"
};
let filename = format!("{}.{}", id, ext);
let filepath = dir.join(&filename);
let bytes = general_purpose::STANDARD
.decode(pure_b64)
.map_err(|e| format!("base64 解码失败: {e}"))?;
tokio::fs::write(&filepath, &bytes)
.await
.map_err(|e| format!("写入图片失败: {e}"))?;
Ok(filepath.to_string_lossy().to_string())
}
/// 加载图片(文件 → base64 data URI
#[tauri::command]
pub async fn assistant_load_image(id: String) -> Result<String, String> {
let dir = data_dir().join("images");
// 尝试各种扩展名
let mut found: Option<PathBuf> = None;
for ext in &["jpg", "png", "gif", "webp", "jpeg"] {
let path = dir.join(format!("{}.{}", id, ext));
if path.exists() {
found = Some(path);
break;
}
}
let filepath = found.ok_or_else(|| format!("图片 {} 不存在", id))?;
let bytes = tokio::fs::read(&filepath)
.await
.map_err(|e| format!("读取图片失败: {e}"))?;
let ext = filepath
.extension()
.and_then(|e| e.to_str())
.unwrap_or("jpg");
let mime = match ext {
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
_ => "image/jpeg",
};
let b64 = general_purpose::STANDARD.encode(&bytes);
Ok(format!("data:{};base64,{}", mime, b64))
}
/// 删除图片文件
#[tauri::command]
pub async fn assistant_delete_image(id: String) -> Result<(), String> {
let dir = data_dir().join("images");
for ext in &["jpg", "png", "gif", "webp", "jpeg"] {
let path = dir.join(format!("{}.{}", id, ext));
if path.exists() {
tokio::fs::remove_file(&path)
.await
.map_err(|e| format!("删除图片失败: {e}"))?;
}
}
Ok(())
}
// ── AI 助手工具 ──
/// 执行 shell 命令,返回 stdout + stderr
#[tauri::command]
pub async fn assistant_exec(command: String, cwd: Option<String>) -> Result<String, String> {
let work_dir = cwd.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_default()
.to_string_lossy()
.to_string()
});
audit_log("EXEC", &format!("cmd={command} cwd={work_dir}"));
let output;
#[cfg(target_os = "windows")]
{
const CREATE_NO_WINDOW: u32 = 0x08000000;
output = tokio::process::Command::new("cmd")
.args(["/c", &command])
.current_dir(&work_dir)
.env("PATH", super::enhanced_path())
.creation_flags(CREATE_NO_WINDOW)
.output()
.await
.map_err(|e| format!("执行失败: {e}"))?;
}
#[cfg(not(target_os = "windows"))]
{
output = tokio::process::Command::new("sh")
.args(["-c", &command])
.current_dir(&work_dir)
.env("PATH", super::enhanced_path())
.output()
.await
.map_err(|e| format!("执行失败: {e}"))?;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let code = output.status.code().unwrap_or(-1);
let mut result = String::new();
if !stdout.is_empty() {
result.push_str(&stdout);
}
if !stderr.is_empty() {
if !result.is_empty() {
result.push('\n');
}
result.push_str("[stderr] ");
result.push_str(&stderr);
}
if result.is_empty() {
result = format!("(命令已执行,退出码: {code})");
} else if code != 0 {
result.push_str(&format!("\n(退出码: {code})"));
}
// 限制输出长度
if result.len() > 10000 {
result.truncate(10000);
result.push_str("\n...(输出已截断)");
}
Ok(result)
}
/// 读取文件内容
#[tauri::command]
pub async fn assistant_read_file(path: String) -> Result<String, String> {
audit_log("READ", &path);
let content = tokio::fs::read_to_string(&path)
.await
.map_err(|e| format!("读取文件失败 {path}: {e}"))?;
if content.len() > 50000 {
Ok(format!(
"{}...\n(文件内容已截断,共 {} 字节)",
&content[..50000],
content.len()
))
} else {
Ok(content)
}
}
/// 写入文件
#[tauri::command]
pub async fn assistant_write_file(path: String, content: String) -> Result<String, String> {
audit_log("WRITE", &format!("{path} ({} bytes)", content.len()));
if let Some(parent) = PathBuf::from(&path).parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| format!("创建目录失败: {e}"))?;
}
tokio::fs::write(&path, &content)
.await
.map_err(|e| format!("写入文件失败 {path}: {e}"))?;
Ok(format!("已写入 {} ({} 字节)", path, content.len()))
}
/// 获取系统信息OS、架构、主目录、主机名
#[tauri::command]
pub async fn assistant_system_info() -> Result<String, String> {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
let home = dirs::home_dir()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let hostname = std::env::var("COMPUTERNAME")
.or_else(|_| std::env::var("HOSTNAME"))
.unwrap_or_else(|_| "unknown".into());
let shell = if cfg!(target_os = "windows") {
"powershell / cmd"
} else if cfg!(target_os = "macos") {
"zsh (macOS default)"
} else {
"bash / sh"
};
Ok(format!(
"OS: {}\nArch: {}\nHome: {}\nHostname: {}\nShell: {}\nPath separator: {}",
os,
arch,
home,
hostname,
shell,
std::path::MAIN_SEPARATOR
))
}
/// 列出运行中的进程(按名称过滤)
#[tauri::command]
pub async fn assistant_list_processes(filter: Option<String>) -> Result<String, String> {
let output;
#[cfg(target_os = "windows")]
{
output = tokio::process::Command::new("powershell")
.args(["-NoProfile", "-Command",
"Get-Process | Select-Object Id, ProcessName, CPU, WorkingSet64 | Sort-Object ProcessName | Format-Table -AutoSize | Out-String -Width 200"])
.creation_flags(0x08000000)
.output()
.await;
}
#[cfg(not(target_os = "windows"))]
{
output = tokio::process::Command::new("ps")
.args(["aux", "--sort=-%mem"])
.output()
.await;
}
let output = output.map_err(|e| format!("获取进程列表失败: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
if let Some(f) = filter {
let f_lower = f.to_lowercase();
let lines: Vec<&str> = stdout
.lines()
.filter(|line| {
let lower = line.to_lowercase();
lower.contains(&f_lower)
|| lower.starts_with("id")
|| lower.starts_with("user")
|| lower.contains("---")
})
.collect();
if lines.len() <= 2 {
return Ok(format!("未找到匹配 '{}' 的进程", f));
}
Ok(lines.join("\n"))
} else {
// 无过滤时限制输出行数
let lines: Vec<&str> = stdout.lines().take(80).collect();
Ok(lines.join("\n"))
}
}
/// 检测端口是否在监听
#[tauri::command]
pub async fn assistant_check_port(port: u16) -> Result<String, String> {
use std::time::Duration;
let addr = format!("127.0.0.1:{}", port);
let result = std::net::TcpStream::connect_timeout(
&addr.parse().map_err(|e| format!("地址解析失败: {e}"))?,
Duration::from_secs(2),
);
match result {
Ok(_stream) => {
// 尝试获取占用进程信息
let process_info = get_port_process(port).await;
Ok(format!(
"端口 {} 已被占用(正在监听){}",
port, process_info
))
}
Err(_) => Ok(format!("端口 {} 未被占用(空闲)", port)),
}
}
async fn get_port_process(port: u16) -> String {
let output;
#[cfg(target_os = "windows")]
{
output = tokio::process::Command::new("powershell")
.args(["-NoProfile", "-Command",
&format!("Get-NetTCPConnection -LocalPort {} -ErrorAction SilentlyContinue | Select-Object OwningProcess | ForEach-Object {{ (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName }}", port)])
.creation_flags(0x08000000)
.output()
.await;
}
#[cfg(not(target_os = "windows"))]
{
output = tokio::process::Command::new("lsof")
.args(["-i", &format!(":{}", port), "-t"])
.output()
.await;
}
match output {
Ok(o) => {
let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
if s.is_empty() {
String::new()
} else {
format!("\n占用进程: {}", s)
}
}
Err(_) => String::new(),
}
}
/// 联网搜索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 = super::build_http_client(
std::time::Duration::from_secs(10),
Some("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"),
)
.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();
let re_strip_tags = regex::Regex::new(r"<[^>]+>").unwrap();
for cap in re_result.captures_iter(&html) {
if results.len() >= max {
break;
}
let raw_url = &cap[1];
let title = re_strip_tags.replace_all(&cap[2], "").trim().to_string();
let snippet = re_strip_tags.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 = super::build_http_client(std::time::Duration::from_secs(15), Some("Mozilla/5.0"))
.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> {
let mut entries = tokio::fs::read_dir(&path)
.await
.map_err(|e| format!("读取目录失败 {path}: {e}"))?;
let mut items = Vec::new();
while let Some(entry) = entries.next_entry().await.map_err(|e| format!("{e}"))? {
let meta = entry.metadata().await.ok();
let name = entry.file_name().to_string_lossy().to_string();
let is_dir = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false);
let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
if is_dir {
items.push(format!("[DIR] {}/", name));
} else {
items.push(format!("[FILE] {} ({} bytes)", name, size));
}
if items.len() >= 200 {
items.push("...(已截断)".into());
break;
}
}
items.sort();
Ok(items.join("\n"))
}