Files
clawpanel/src-tauri/src/commands/agent.rs
晴天 3c5a0d252b chore: release v0.11.6
feat: Skills multi-agent support — agent selector + per-agent skills directory (Rust/Node.js/frontend)
feat: Assistant tool mode streaming — typewriter effect + tool_calls chunk accumulation
improve: OpenClaw 4.5 compatibility — full agent event stream handling + 3-min ultimate timeout
improve: Replace hot-update with stable download links (website/GitHub)
fix: Gateway status flapping — dashboard throttle + TCP retry + debounce threshold
fix: Assistant empty gray bubbles — SSE 0-chunk detection + stream error capture + render filter
2026-04-07 16:17:09 +08:00

1128 lines
35 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.
/// Agent 管理命令 — 列表/改名直接读写 openclaw.json创建/删除走 CLI需要创建 workspace 等文件)
use crate::utils::openclaw_command_async;
use serde::{Deserialize, Serialize};
use serde_json::json;
use serde_json::Value;
use std::fs;
use std::io::Write;
use std::path::{Component, Path, PathBuf};
const AGENT_FILE_ALLOWLIST: &[&str] = &[
"AGENTS.md",
"SOUL.md",
"TOOLS.md",
"IDENTITY.md",
"USER.md",
"HEARTBEAT.md",
"BOOTSTRAP.md",
"MEMORY.md",
];
const WORKSPACE_TEXT_EXTENSIONS: &[&str] = &[
"md",
"markdown",
"mdx",
"txt",
"json",
"jsonc",
"yaml",
"yml",
"toml",
"ini",
"cfg",
"conf",
"log",
"csv",
"env",
"gitignore",
"gitattributes",
"editorconfig",
"js",
"mjs",
"cjs",
"ts",
"tsx",
"jsx",
"html",
"htm",
"css",
"scss",
"less",
"rs",
"py",
"sh",
"bash",
"zsh",
"fish",
"ps1",
"bat",
"cmd",
"sql",
"xml",
"java",
"kt",
"go",
"rb",
"php",
"c",
"cc",
"cpp",
"h",
"hpp",
"vue",
"svelte",
"lock",
"sample",
];
const WORKSPACE_TEXT_BASENAMES: &[&str] = &[
"dockerfile",
"makefile",
"readme",
"license",
".env",
".env.local",
".env.example",
".gitignore",
".gitattributes",
".editorconfig",
".npmrc",
];
const WORKSPACE_PREVIEW_EXTENSIONS: &[&str] = &["md", "markdown", "mdx"];
const MAX_WORKSPACE_FILE_SIZE: u64 = 1024 * 1024;
/// Workspace 状态信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceStatus {
/// 路径是否存在
pub exists: bool,
/// 是否为软链接
pub is_symlink: bool,
/// 软链接指向的目标路径(如果是软链接)
pub symlink_target: Option<String>,
/// 软链接目标是否有效(仅当 is_symlink=true 时有意义)
pub symlink_valid: bool,
/// 是否有读取权限
pub readable: bool,
}
/// Workspace 状态检测结果(包含状态和警告信息)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceCheckResult {
pub status: WorkspaceStatus,
pub warning: Option<String>,
}
/// 检测 workspace 路径的状态
/// 使用 symlink_metadata 而非 metadata避免跟随软链接
fn check_workspace_status(path: &std::path::Path) -> WorkspaceCheckResult {
let mut status = WorkspaceStatus {
exists: false,
is_symlink: false,
symlink_target: None,
symlink_valid: false,
readable: true,
};
let mut warning = None;
// 使用 symlink_metadata 不会跟随软链接,能正确检测软链接本身的状态
match std::fs::symlink_metadata(path) {
Ok(meta) => {
status.exists = true;
status.is_symlink = meta.file_type().is_symlink();
if status.is_symlink {
// 软链接:获取目标路径
match std::fs::read_link(path) {
Ok(target) => {
status.symlink_target = Some(target.to_string_lossy().to_string());
// 检查软链接目标是否存在
match std::fs::metadata(path) {
Ok(_) => status.symlink_valid = true,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
status.symlink_valid = false;
warning = Some("软链接目标不存在".to_string());
}
Err(e) => {
status.symlink_valid = false;
warning = Some(format!("无法访问软链接目标: {}", e));
}
}
}
Err(e) => {
warning = Some(format!("无法读取软链接目标: {}", e));
}
}
} else {
// 普通目录:验证读取权限
match std::fs::read_dir(path) {
Ok(_) => status.readable = true,
Err(e) => {
status.readable = false;
warning = Some(format!("权限不足: {}", e));
}
}
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
warning = Some("工作目录不存在".to_string());
}
Err(e) => {
status.readable = false;
warning = Some(format!("无法访问路径: {}", e));
}
}
WorkspaceCheckResult { status, warning }
}
/// 获取 agent 列表(直接读 openclaw.json不走 CLI毫秒级响应
#[tauri::command]
pub async fn list_agents() -> Result<Value, String> {
let config = super::config::load_openclaw_json()?;
let agents_list = config
.get("agents")
.and_then(|a| a.get("list"))
.and_then(|l| l.as_array())
.cloned()
.unwrap_or_default();
// 补全 main agent 的 workspaceconfig 中可能没有显式指定)
let default_workspace = config
.get("agents")
.and_then(|a| a.get("defaults"))
.and_then(|d| d.get("workspace"))
.and_then(|w| w.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| {
super::openclaw_dir()
.join("workspace")
.to_string_lossy()
.to_string()
});
// main agent 是隐式的(不在 agents.list 中),始终插入
let has_main = agents_list
.iter()
.any(|a| a.get("id").and_then(|v| v.as_str()) == Some("main"));
let all_agents = if has_main {
agents_list
} else {
let mut v = vec![serde_json::json!({
"id": "main",
"isDefault": true,
"workspace": default_workspace.clone(),
})];
v.extend(agents_list);
v
};
let enriched: Vec<Value> = all_agents
.into_iter()
.map(|mut agent| {
let id = agent
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
// 补全 workspace 路径
if agent.get("workspace").and_then(|w| w.as_str()).is_none()
|| agent.get("workspace").and_then(|w| w.as_str()) == Some("")
{
if id == "main" {
agent.as_object_mut().map(|o| {
o.insert(
"workspace".to_string(),
Value::String(default_workspace.clone()),
)
});
} else {
let ws = super::openclaw_dir()
.join("agents")
.join(&id)
.join("workspace")
.to_string_lossy()
.to_string();
agent
.as_object_mut()
.map(|o| o.insert("workspace".to_string(), Value::String(ws)));
}
}
// 检测 workspace 状态
if let Some(ws_str) = agent.get("workspace").and_then(|w| w.as_str()) {
let ws_path = std::path::Path::new(ws_str);
let check_result = check_workspace_status(ws_path);
// 添加 workspaceStatus 字段
agent.as_object_mut().map(|o| {
o.insert(
"workspaceStatus".to_string(),
serde_json::to_value(&check_result.status).unwrap_or(Value::Null),
)
});
// 添加警告信息
if let Some(w) = check_result.warning {
agent
.as_object_mut()
.map(|o| o.insert("workspaceWarning".to_string(), Value::String(w)));
}
}
// 补全 identityName 用于前端显示
let identity_name = agent
.get("identity")
.and_then(|i| i.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
if !identity_name.is_empty() {
agent
.as_object_mut()
.map(|o| o.insert("identityName".to_string(), Value::String(identity_name)));
}
agent
})
.collect();
Ok(Value::Array(enriched))
}
#[tauri::command]
pub async fn get_agent_detail(id: String) -> Result<Value, String> {
let config = super::config::load_openclaw_json()?;
let defaults = config
.get("agents")
.and_then(|a| a.get("defaults"))
.cloned()
.unwrap_or(Value::Null);
let bindings = config
.get("bindings")
.and_then(|b| b.as_array())
.cloned()
.unwrap_or_default();
let mut agent = config
.get("agents")
.and_then(|a| a.get("list"))
.and_then(|l| l.as_array())
.and_then(|list| {
list.iter()
.find(|a| a.get("id").and_then(|v| v.as_str()) == Some(id.as_str()))
.cloned()
})
.unwrap_or_else(|| json!({ "id": id.clone(), "default": id == "main" }));
let workspace = resolve_agent_workspace_path(&id, &config)
.to_string_lossy()
.to_string();
let agent_bindings: Vec<Value> = bindings
.into_iter()
.filter(|b| b.get("agentId").and_then(|v| v.as_str()).unwrap_or("main") == id)
.collect();
let is_default = agent
.get("default")
.and_then(|v| v.as_bool())
.unwrap_or(id == "main");
if let Some(obj) = agent.as_object_mut() {
obj.insert("workspace".to_string(), Value::String(workspace));
obj.insert("bindings".to_string(), Value::Array(agent_bindings));
obj.insert("isDefault".to_string(), Value::Bool(is_default));
obj.insert("defaults".to_string(), defaults);
}
Ok(agent)
}
#[tauri::command]
pub async fn list_agent_files(id: String) -> Result<Value, String> {
let config = super::config::load_openclaw_json()?;
let workspace_dir = resolve_agent_workspace_path(&id, &config);
let files: Vec<Value> = AGENT_FILE_ALLOWLIST
.iter()
.map(|name| {
let path = workspace_dir.join(name);
let meta = fs::metadata(&path).ok();
json!({
"name": name,
"desc": bootstrap_file_desc(name),
"exists": path.exists(),
"size": meta.as_ref().map(|m| m.len()).unwrap_or(0),
"mtime": meta.and_then(|m| m.modified().ok()).and_then(|m| chrono::DateTime::<chrono::Utc>::from(m).to_rfc3339().into()),
"path": path.to_string_lossy().to_string(),
})
})
.collect();
Ok(Value::Array(files))
}
#[tauri::command]
pub async fn read_agent_file(id: String, name: String) -> Result<Value, String> {
ensure_allowed_agent_file(&name)?;
let config = super::config::load_openclaw_json()?;
let path = resolve_agent_workspace_path(&id, &config).join(&name);
if !path.exists() {
return Ok(json!({ "exists": false, "content": "" }));
}
let content = fs::read_to_string(&path).map_err(|e| format!("读取文件失败: {e}"))?;
Ok(json!({ "exists": true, "content": content }))
}
#[tauri::command]
pub async fn write_agent_file(id: String, name: String, content: String) -> Result<Value, String> {
ensure_allowed_agent_file(&name)?;
let config = super::config::load_openclaw_json()?;
let dir = resolve_agent_workspace_path(&id, &config);
if !dir.exists() {
fs::create_dir_all(&dir).map_err(|e| format!("创建目录失败: {e}"))?;
}
fs::write(dir.join(&name), content).map_err(|e| format!("写入文件失败: {e}"))?;
Ok(json!({ "ok": true }))
}
#[tauri::command]
pub async fn get_agent_workspace_info(id: String) -> Result<Value, String> {
let config = super::config::load_openclaw_json()?;
let workspace_dir = resolve_agent_workspace_path(&id, &config);
Ok(json!({
"agentId": id,
"workspacePath": workspace_dir.to_string_lossy().to_string(),
"exists": workspace_dir.exists(),
"isDefault": id == "main",
}))
}
#[tauri::command]
pub async fn list_agent_workspace_entries(
id: String,
relative_path: Option<String>,
) -> Result<Value, String> {
let config = super::config::load_openclaw_json()?;
let workspace_dir = resolve_agent_workspace_path(&id, &config);
if !workspace_dir.exists() {
return Ok(Value::Array(Vec::new()));
}
let target_dir = resolve_workspace_target_path(&workspace_dir, relative_path.as_deref())?;
if !target_dir.exists() {
return Err("目录不存在".to_string());
}
if !target_dir.is_dir() {
return Err("目标不是目录".to_string());
}
let mut items: Vec<(u8, String, Value)> = fs::read_dir(&target_dir)
.map_err(|e| format!("读取目录失败: {e}"))?
.filter_map(|entry| {
let entry = entry.ok()?;
let path = entry.path();
let meta = entry.metadata().ok()?;
let is_dir = meta.is_dir();
let name = entry.file_name().to_string_lossy().to_string();
let relative = to_workspace_relative_path(&workspace_dir, &path);
let mtime = meta
.modified()
.ok()
.map(|m| chrono::DateTime::<chrono::Utc>::from(m).to_rfc3339());
Some((
if is_dir { 0 } else { 1 },
name.to_lowercase(),
json!({
"name": name,
"relativePath": relative,
"type": if is_dir { "dir" } else { "file" },
"size": if is_dir { 0 } else { meta.len() },
"mtime": mtime,
"editable": !is_dir && is_workspace_text_file(&path),
"previewable": !is_dir && is_workspace_previewable_file(&path),
}),
))
})
.collect();
items.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
Ok(Value::Array(
items.into_iter().map(|(_, _, item)| item).collect(),
))
}
#[tauri::command]
pub async fn read_agent_workspace_file(id: String, relative_path: String) -> Result<Value, String> {
let config = super::config::load_openclaw_json()?;
let workspace_dir = resolve_agent_workspace_path(&id, &config);
let normalized = normalize_workspace_relative_path(&relative_path)?;
if normalized.as_os_str().is_empty() {
return Err("文件路径不能为空".to_string());
}
let file_path = workspace_dir.join(&normalized);
if !file_path.exists() {
return Err("文件不存在".to_string());
}
if !file_path.is_file() {
return Err("目标不是文件".to_string());
}
let meta = fs::metadata(&file_path).map_err(|e| format!("读取文件信息失败: {e}"))?;
if meta.len() > MAX_WORKSPACE_FILE_SIZE {
return Err("文件过大,暂不支持在面板中打开".to_string());
}
let mtime = meta
.modified()
.ok()
.map(|m| chrono::DateTime::<chrono::Utc>::from(m).to_rfc3339());
let bytes = fs::read(&file_path).map_err(|e| format!("读取文件失败: {e}"))?;
if looks_binary_bytes(&bytes) {
return Err("暂不支持在面板中打开二进制文件".to_string());
}
let content = String::from_utf8(bytes)
.map_err(|_| "暂不支持在面板中打开非 UTF-8 文本文件".to_string())?;
Ok(json!({
"relativePath": normalized.to_string_lossy().replace('\\', "/"),
"path": file_path.to_string_lossy().to_string(),
"size": meta.len(),
"mtime": mtime,
"editable": true,
"previewable": is_workspace_previewable_file(&file_path),
"content": content,
}))
}
#[tauri::command]
pub async fn write_agent_workspace_file(
id: String,
relative_path: String,
content: String,
) -> Result<Value, String> {
let config = super::config::load_openclaw_json()?;
let workspace_dir = resolve_agent_workspace_path(&id, &config);
let normalized = normalize_workspace_relative_path(&relative_path)?;
if normalized.as_os_str().is_empty() {
return Err("文件路径不能为空".to_string());
}
let file_path = workspace_dir.join(&normalized);
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {e}"))?;
}
fs::write(&file_path, content.as_bytes()).map_err(|e| format!("写入文件失败: {e}"))?;
Ok(json!({
"ok": true,
"relativePath": normalized.to_string_lossy().replace('\\', "/"),
"size": content.len(),
}))
}
#[tauri::command]
pub async fn update_agent_config(
app: tauri::AppHandle,
id: String,
config: Value,
) -> Result<Value, String> {
let mut root = super::config::load_openclaw_json()?;
if root.get("agents").is_none() {
root.as_object_mut()
.ok_or("配置格式错误")?
.insert("agents".to_string(), json!({}));
}
if root["agents"].get("list").is_none() {
root["agents"]
.as_object_mut()
.ok_or("agents 格式错误")?
.insert("list".to_string(), json!([]));
}
let list = root["agents"]["list"]
.as_array_mut()
.ok_or("agents.list 格式错误")?;
let index = list
.iter()
.position(|agent| agent.get("id").and_then(|v| v.as_str()) == Some(id.as_str()));
let idx = match index {
Some(idx) => idx,
None if id == "main" => {
list.insert(0, json!({ "id": "main" }));
0
}
None => return Err(format!("Agent「{id}」不存在")),
};
let agent = list[idx].as_object_mut().ok_or("Agent 格式错误")?;
if let Some(identity) = config.get("identity").and_then(|v| v.as_object()) {
let identity_obj = agent
.entry("identity".to_string())
.or_insert_with(|| json!({}));
let identity_obj = identity_obj.as_object_mut().ok_or("identity 格式错误")?;
if let Some(name) = identity.get("name") {
if name.is_null() {
identity_obj.remove("name");
} else {
identity_obj.insert("name".to_string(), name.clone());
}
}
if let Some(emoji) = identity.get("emoji") {
if emoji.is_null() {
identity_obj.remove("emoji");
} else {
identity_obj.insert("emoji".to_string(), emoji.clone());
}
}
}
if let Some(model) = config.get("model") {
if model.is_null() {
agent.remove("model");
} else {
agent.insert("model".to_string(), model.clone());
}
}
if let Some(thinking) = config.get("thinkingDefault") {
if thinking.is_null() {
agent.remove("thinkingDefault");
} else {
agent.insert("thinkingDefault".to_string(), thinking.clone());
}
}
if let Some(skills) = config.get("skills") {
if skills.is_null() {
agent.remove("skills");
} else {
agent.insert("skills".to_string(), skills.clone());
}
}
if let Some(tools) = config.get("tools") {
if tools.is_null() {
agent.remove("tools");
} else {
agent.insert("tools".to_string(), tools.clone());
}
}
super::config::save_openclaw_json(&root)?;
let app2 = app.clone();
tauri::async_runtime::spawn(async move {
let _ = super::config::do_reload_gateway(&app2).await;
});
Ok(json!({ "ok": true }))
}
/// 创建新 agent优先走 CLI失败则直接写 openclaw.json 兜底)
#[tauri::command]
pub async fn add_agent(
app: tauri::AppHandle,
name: String,
model: String,
workspace: Option<String>,
) -> Result<Value, String> {
let ws = match workspace {
Some(ref w) if !w.is_empty() => std::path::PathBuf::from(w),
_ => super::openclaw_dir()
.join("agents")
.join(&name)
.join("workspace"),
};
// 验证 workspace 路径有效性
let ws_check = check_workspace_status(&ws);
if let Some(ref warning) = ws_check.warning {
eprintln!("[agent] Workspace 警告: {}", warning);
}
if ws_check.status.is_symlink && !ws_check.status.symlink_valid {
return Err(format!(
"指定的 workspace 是软链接,但目标不存在: {}",
ws_check.status.symlink_target.as_deref().unwrap_or("未知")
));
}
let mut args = vec![
"agents".to_string(),
"add".to_string(),
name.clone(),
"--non-interactive".to_string(),
"--workspace".to_string(),
ws.to_string_lossy().to_string(),
];
if !model.is_empty() {
args.push("--model".to_string());
args.push(model.clone());
}
// 尝试 CLI15s 超时),失败则直接写配置兜底
let cli_ok = match tokio::time::timeout(
std::time::Duration::from_secs(15),
openclaw_command_async().args(&args).output(),
)
.await
{
Ok(Ok(o)) if o.status.success() => true,
Ok(Ok(o)) => {
let stderr = String::from_utf8_lossy(&o.stderr);
eprintln!(
"[agent] CLI 创建失败: {}",
stderr.chars().take(200).collect::<String>()
);
false
}
Ok(Err(e)) => {
eprintln!("[agent] CLI 执行错误: {e}");
false
}
Err(_) => {
eprintln!("[agent] CLI 超时 (15s),可能是 OpenClaw 未响应");
false
}
};
if !cli_ok {
// 兜底:直接写 openclaw.json
if let Err(e) = add_agent_to_config(&name, &model, &ws) {
return Err(format!(
"CLI 创建超时且配置写入失败: {}\n请尝试手动运行: openclaw agents add {} --workspace {}",
e,
name,
ws.to_string_lossy()
));
}
}
// 确保 workspace 目录存在
if !ws.exists() {
if let Err(e) = fs::create_dir_all(&ws) {
eprintln!("[agent] 创建 workspace 目录失败: {e}");
}
}
// 验证步骤
let agents = list_agents().await?;
let created = agents.as_array().and_then(|arr| {
arr.iter()
.find(|a| a.get("id").and_then(|v| v.as_str()) == Some(&name))
});
if created.is_none() {
eprintln!("[agent] 警告: Agent 创建后未在列表中出现");
}
if !ws.exists() {
eprintln!("[agent] 警告: Agent workspace 目录未创建");
}
// 触发 Gateway 重载使新 agent 生效
let _ = super::config::do_reload_gateway(&app).await;
list_agents().await
}
/// 直接写 openclaw.json 创建 agentCLI 不可用时的兜底方案)
fn add_agent_to_config(id: &str, model: &str, workspace: &std::path::Path) -> Result<(), String> {
let mut config = super::config::load_openclaw_json()?;
// 确保 agents.list 存在
if config.get("agents").is_none() {
config
.as_object_mut()
.ok_or("配置格式错误")?
.insert("agents".to_string(), serde_json::json!({}));
}
if config["agents"].get("list").is_none() {
config["agents"]
.as_object_mut()
.ok_or("agents 格式错误")?
.insert("list".to_string(), serde_json::json!([]));
}
let list = config["agents"]["list"]
.as_array_mut()
.ok_or("agents.list 格式错误")?;
// 检查是否已存在同名 agent
let exists = list
.iter()
.any(|a| a.get("id").and_then(|v| v.as_str()) == Some(id));
if exists {
return Err(format!("Agent「{id}」已存在"));
}
let mut agent = serde_json::json!({
"id": id,
"workspace": workspace.to_string_lossy(),
});
if !model.is_empty() {
agent
.as_object_mut()
.unwrap()
.insert("model".to_string(), serde_json::json!({ "primary": model }));
}
list.push(agent);
super::config::save_openclaw_json(&config)?;
Ok(())
}
/// 删除 agent直接操作 openclaw.json + 删除 agent 目录,不走 CLI
#[tauri::command]
pub async fn delete_agent(app: tauri::AppHandle, id: String) -> Result<String, String> {
if id == "main" {
return Err("不能删除默认 Agent".into());
}
// 1. 从 openclaw.json 的 agents.list 中移除
let mut config = super::config::load_openclaw_json()?;
if let Some(list) = config
.get_mut("agents")
.and_then(|a| a.get_mut("list"))
.and_then(|l| l.as_array_mut())
{
list.retain(|a| a.get("id").and_then(|v| v.as_str()) != Some(&id));
}
// 同时清理 agents.profiles 中的配置
if let Some(profiles) = config
.get_mut("agents")
.and_then(|a| a.get_mut("profiles"))
.and_then(|p| p.as_object_mut())
{
profiles.remove(&id);
}
super::config::save_openclaw_json(&config)?;
// 2. 删除 agent 目录workspace + sessions 等)
let agent_dir = super::openclaw_dir().join("agents").join(&id);
if agent_dir.exists() {
if let Err(e) = fs::remove_dir_all(&agent_dir) {
eprintln!("[agent] 删除 agent 目录失败: {e},不影响配置删除");
}
}
// 3. 触发 Gateway 重载
let _ = super::config::do_reload_gateway(&app).await;
Ok("已删除".into())
}
/// 更新 agent 身份信息
#[tauri::command]
pub async fn update_agent_identity(
app: tauri::AppHandle,
id: String,
name: Option<String>,
emoji: Option<String>,
) -> Result<String, String> {
let mut config = super::config::load_openclaw_json()?;
let agents_list = config
.get_mut("agents")
.and_then(|a| a.get_mut("list"))
.and_then(|l| l.as_array_mut())
.ok_or("配置格式错误")?;
let agent = agents_list
.iter_mut()
.find(|a| a.get("id").and_then(|v| v.as_str()) == Some(&id))
.ok_or(format!("Agent「{id}」不存在"))?;
// 确保 identity 字段存在且为对象
if agent.get("identity").and_then(|i| i.as_object()).is_none() {
agent
.as_object_mut()
.ok_or("Agent 格式错误")?
.insert("identity".to_string(), serde_json::json!({}));
}
let identity = agent
.get_mut("identity")
.and_then(|i| i.as_object_mut())
.ok_or("identity 格式错误")?;
if let Some(n) = name {
if !n.is_empty() {
identity.insert("name".to_string(), Value::String(n));
}
}
if let Some(e) = emoji {
if !e.is_empty() {
identity.insert("emoji".to_string(), Value::String(e));
}
}
// 提前提取 workspace 路径(克隆为 String避免借用冲突
let workspace_path = agent
.get("workspace")
.and_then(|w| w.as_str())
.map(|s| s.to_string())
.or_else(|| {
config
.get("agents")
.and_then(|a| a.get("defaults"))
.and_then(|d| d.get("workspace"))
.and_then(|w| w.as_str())
.map(|s| s.to_string())
});
super::config::save_openclaw_json(&config)?;
// 删除 IDENTITY.md 文件,让配置文件生效
if let Some(ws_str) = workspace_path {
let identity_file = std::path::PathBuf::from(ws_str).join("IDENTITY.md");
if identity_file.exists() {
let _ = fs::remove_file(&identity_file);
}
}
// 触发 Gateway 重载使配置生效
let _ = super::config::do_reload_gateway(&app).await;
Ok("已更新".into())
}
/// 备份 agent 数据agent 配置 + 会话记录)打包为 zip
#[tauri::command]
pub fn backup_agent(id: String) -> Result<String, String> {
let agent_dir = super::openclaw_dir().join("agents").join(&id);
if !agent_dir.exists() {
return Err(format!("Agent「{id}」数据目录不存在"));
}
let tmp_dir = std::env::temp_dir();
let now = chrono::Local::now();
let zip_name = format!("agent-{}-{}.zip", id, now.format("%Y%m%d-%H%M%S"));
let zip_path = tmp_dir.join(&zip_name);
let file = fs::File::create(&zip_path).map_err(|e| format!("创建 zip 失败: {e}"))?;
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
collect_dir_to_zip(&agent_dir, &agent_dir, &mut zip, options)?;
zip.finish().map_err(|e| format!("完成 zip 失败: {e}"))?;
Ok(zip_path.to_string_lossy().to_string())
}
fn collect_dir_to_zip(
base: &std::path::Path,
dir: &std::path::Path,
zip: &mut zip::ZipWriter<fs::File>,
options: zip::write::SimpleFileOptions,
) -> Result<(), String> {
let entries = fs::read_dir(dir).map_err(|e| format!("读取目录失败: {e}"))?;
for entry in entries.flatten() {
let path = entry.path();
let rel = path
.strip_prefix(base)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
if path.is_dir() {
collect_dir_to_zip(base, &path, zip, options)?;
} else {
let content = fs::read(&path).map_err(|e| format!("读取 {rel} 失败: {e}"))?;
zip.start_file(&rel, options)
.map_err(|e| format!("写入 zip 失败: {e}"))?;
zip.write_all(&content)
.map_err(|e| format!("写入内容失败: {e}"))?;
}
}
Ok(())
}
/// 更新 agent 模型配置
#[tauri::command]
pub async fn update_agent_model(
app: tauri::AppHandle,
id: String,
model: String,
) -> Result<String, String> {
let mut config = super::config::load_openclaw_json()?;
let agents_list = config
.get_mut("agents")
.and_then(|a| a.get_mut("list"))
.and_then(|l| l.as_array_mut())
.ok_or("配置格式错误")?;
let agent = agents_list
.iter_mut()
.find(|a| a.get("id").and_then(|v| v.as_str()) == Some(&id))
.ok_or(format!("Agent「{id}」不存在"))?;
let model_obj = serde_json::json!({ "primary": model });
agent
.as_object_mut()
.ok_or("Agent 格式错误")?
.insert("model".to_string(), model_obj);
super::config::save_openclaw_json(&config)?;
// 触发 Gateway 重载使配置生效
let _ = super::config::do_reload_gateway(&app).await;
Ok("已更新".into())
}
fn resolve_agent_workspace(id: &str, config: &Value) -> String {
config
.get("agents")
.and_then(|a| a.get("list"))
.and_then(|l| l.as_array())
.and_then(|list| {
list.iter()
.find(|a| a.get("id").and_then(|v| v.as_str()) == Some(id))
.and_then(|a| a.get("workspace"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
})
.unwrap_or_else(|| {
if id == "main" {
super::openclaw_dir()
.join("workspace")
.to_string_lossy()
.to_string()
} else {
super::openclaw_dir()
.join("agents")
.join(id)
.join("workspace")
.to_string_lossy()
.to_string()
}
})
}
/// Public wrapper for expand_user_path, used by skills.rs
pub fn expand_user_path_pub(raw: &str) -> std::path::PathBuf {
expand_user_path(raw)
}
fn expand_user_path(raw: &str) -> std::path::PathBuf {
let trimmed = raw.trim();
let path = if let Some(rest) = trimmed
.strip_prefix("~/")
.or_else(|| trimmed.strip_prefix("~\\"))
{
dirs::home_dir().unwrap_or_default().join(rest)
} else {
std::path::PathBuf::from(trimmed)
};
if path.is_absolute() {
path
} else {
std::env::current_dir()
.map(|cwd| cwd.join(&path))
.unwrap_or(path)
}
}
fn resolve_agent_workspace_path(id: &str, config: &Value) -> std::path::PathBuf {
expand_user_path(&resolve_agent_workspace(id, config))
}
fn normalize_workspace_relative_path(raw: &str) -> Result<PathBuf, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(PathBuf::new());
}
let path = PathBuf::from(trimmed);
if path.is_absolute() {
return Err("不允许使用绝对路径".to_string());
}
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::Normal(seg) => normalized.push(seg),
Component::CurDir => {}
Component::ParentDir => return Err("不允许访问工作区外部路径".to_string()),
Component::RootDir | Component::Prefix(_) => {
return Err("不允许使用绝对路径".to_string())
}
}
}
Ok(normalized)
}
fn resolve_workspace_target_path(
root: &Path,
relative_path: Option<&str>,
) -> Result<PathBuf, String> {
let normalized = normalize_workspace_relative_path(relative_path.unwrap_or_default())?;
Ok(root.join(normalized))
}
fn to_workspace_relative_path(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.components()
.filter_map(|component| match component {
Component::Normal(seg) => Some(seg.to_string_lossy().to_string()),
_ => None,
})
.collect::<Vec<_>>()
.join("/")
}
fn is_workspace_text_file(path: &Path) -> bool {
if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) {
if WORKSPACE_TEXT_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()) {
return true;
}
}
path.file_name()
.and_then(|name| name.to_str())
.map(|name| WORKSPACE_TEXT_BASENAMES.contains(&name.to_ascii_lowercase().as_str()))
.unwrap_or(false)
}
fn is_workspace_previewable_file(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| WORKSPACE_PREVIEW_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
.unwrap_or(false)
}
fn looks_binary_bytes(bytes: &[u8]) -> bool {
bytes.iter().take(512).any(|b| *b == 0)
}
fn ensure_allowed_agent_file(name: &str) -> Result<(), String> {
if AGENT_FILE_ALLOWLIST.contains(&name) {
Ok(())
} else {
Err("不允许访问此文件".to_string())
}
}
fn bootstrap_file_desc(name: &str) -> &'static str {
match name {
"AGENTS.md" => "Agent 规则",
"SOUL.md" => "灵魂/人格",
"TOOLS.md" => "工具白名单",
"IDENTITY.md" => "身份信息",
"USER.md" => "用户上下文",
"HEARTBEAT.md" => "心跳指令",
"BOOTSTRAP.md" => "初始化引导",
"MEMORY.md" => "记忆存储",
_ => "",
}
}