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:
晴天
2026-03-06 04:33:43 +08:00
parent 7eb78ea186
commit 860218fa09
26 changed files with 5046 additions and 50 deletions

View 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"))
}

View File

@@ -1,6 +1,7 @@
use std::path::PathBuf;
pub mod agent;
pub mod assistant;
pub mod config;
pub mod device;
pub mod extensions;