mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-07-12 16:01:56 +08:00
feat: 新增 AI 助手页面 + 图片识别功能 + 更新宣传素材
- 新增 AI 助手页面 (assistant.js/assistant.css/assistant.rs) - 新增图片识别截图 (13.png) 并加入官网和 README - 更新宣传视频 (promo-web.mp4) 含 AI 助手+工具调用+图片识别场景 - 更新视频封面 (video-cover.png/video-cover-light.png) 突出 AI 助手 - 更新 AI 助手演示 GIF (ai-assistant-demo.gif) 作为 README 首图 - 更新功能矩阵 GIF (feature-showcase.gif) AI 助手为 star 卡片 - 官网新增图片识别 showcase 区块 - README 新增图片识别特性和截图 - 视频封面改用专业设计版本 - 演示视频时长 badge 更新为 50 秒
This commit is contained in:
355
src-tauri/src/commands/assistant.rs
Normal file
355
src-tauri/src/commands/assistant.rs
Normal file
@@ -0,0 +1,355 @@
|
||||
/// AI 助手工具命令
|
||||
/// 提供终端执行、文件读写、目录列表等能力
|
||||
/// 仅在用户主动开启工具后由 AI 调用
|
||||
use std::path::PathBuf;
|
||||
use base64::{Engine as _, engine::general_purpose};
|
||||
|
||||
/// 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()
|
||||
});
|
||||
|
||||
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> {
|
||||
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> {
|
||||
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 = if cfg!(target_os = "windows") {
|
||||
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"])
|
||||
.output()
|
||||
.await
|
||||
} else {
|
||||
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 = if cfg!(target_os = "windows") {
|
||||
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)])
|
||||
.output()
|
||||
.await
|
||||
} else {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 列出目录内容
|
||||
#[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"))
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub mod agent;
|
||||
pub mod assistant;
|
||||
pub mod config;
|
||||
pub mod device;
|
||||
pub mod extensions;
|
||||
|
||||
Reference in New Issue
Block a user