Skip to content

Commit f71214f

Browse files
committed
feat(agent): 合并工作目录设置
2 parents ff09a81 + ec1ffb1 commit f71214f

11 files changed

Lines changed: 572 additions & 8 deletions

File tree

src-tauri/crates/core/src/path_vars.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ const PATH_SETTING_KEYS: &[&str] = &[
133133
"backup_dir",
134134
"gateway_ssl_cert_path",
135135
"gateway_ssl_key_path",
136+
"agent_workspace_root",
136137
];
137138

138139
/// Migrate hardcoded absolute paths in settings to use dynamic variables.

src-tauri/crates/core/src/types.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,12 @@ pub struct AppSettings {
745745
pub multi_model_display_mode: String,
746746
/// Render user messages as Markdown (like AI messages). Default: false.
747747
pub render_user_markdown: bool,
748+
/// Agent default workspace root. None uses ~/.aqbot/workspace.
749+
pub agent_workspace_root: Option<String>,
750+
/// Agent workspace subdirectory naming strategy.
751+
pub agent_workspace_name_strategy: String,
752+
/// Agent workspace datetime naming format.
753+
pub agent_workspace_datetime_format: Option<String>,
748754
}
749755

750756
impl Default for AppSettings {
@@ -866,6 +872,9 @@ impl Default for AppSettings {
866872
show_image_models_in_model_selector: false,
867873
multi_model_display_mode: "tabs".to_string(),
868874
render_user_markdown: false,
875+
agent_workspace_root: None,
876+
agent_workspace_name_strategy: "uuid".to_string(),
877+
agent_workspace_datetime_format: Some("YYYY-MM-DD-HH-mm-ss".to_string()),
869878
}
870879
}
871880
}
@@ -997,6 +1006,39 @@ mod app_settings_tests {
9971006
serde_json::from_value(json!({})).expect("settings should default missing fields");
9981007
assert!(settings.inherit_conversation_preferences_on_create);
9991008
}
1009+
1010+
#[test]
1011+
fn agent_workspace_settings_default_and_roundtrip() {
1012+
let settings = AppSettings::default();
1013+
assert_eq!(settings.agent_workspace_root, None);
1014+
assert_eq!(settings.agent_workspace_name_strategy, "uuid");
1015+
assert_eq!(
1016+
settings.agent_workspace_datetime_format,
1017+
Some("YYYY-MM-DD-HH-mm-ss".to_string())
1018+
);
1019+
1020+
let settings: AppSettings = serde_json::from_value(json!({
1021+
"agent_workspace_root": "/tmp/aqbot-agents",
1022+
"agent_workspace_name_strategy": "created_datetime",
1023+
"agent_workspace_datetime_format": "YYYY-MM-DD-HH:mm:ss"
1024+
}))
1025+
.expect("settings should deserialize");
1026+
1027+
assert_eq!(
1028+
settings.agent_workspace_root.as_deref(),
1029+
Some("/tmp/aqbot-agents")
1030+
);
1031+
assert_eq!(settings.agent_workspace_name_strategy, "created_datetime");
1032+
assert_eq!(
1033+
settings.agent_workspace_datetime_format.as_deref(),
1034+
Some("YYYY-MM-DD-HH:mm:ss")
1035+
);
1036+
1037+
let settings: AppSettings =
1038+
serde_json::from_value(json!({})).expect("settings should default missing fields");
1039+
assert_eq!(settings.agent_workspace_root, None);
1040+
assert_eq!(settings.agent_workspace_name_strategy, "uuid");
1041+
}
10001042
}
10011043

10021044
// === Chat Streaming Types ===

src-tauri/src/commands/agent.rs

Lines changed: 270 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use open_agent_sdk::{
1313
use serde::{Deserialize, Serialize};
1414
use serde_json::Value;
1515
use std::collections::HashMap;
16+
use std::path::PathBuf;
1617
use std::sync::{Arc, LazyLock, Mutex};
1718
use tauri::{Emitter, State};
1819
use tokio::sync::RwLock;
@@ -22,6 +23,9 @@ use tokio::sync::RwLock;
2223
static RUNNING_AGENTS: LazyLock<Mutex<HashMap<String, String>>> =
2324
LazyLock::new(|| Mutex::new(HashMap::new()));
2425

26+
const DEFAULT_AGENT_WORKSPACE_DATETIME_FORMAT: &str = "YYYY-MM-DD-HH-mm-ss";
27+
const MAX_AGENT_WORKSPACE_NAME_LEN: usize = 80;
28+
2529
/// RAII guard that removes a conversation ID from RUNNING_AGENTS on drop.
2630
/// Ensures cleanup even if the spawned task panics.
2731
struct RunningAgentGuard {
@@ -54,6 +58,168 @@ impl Drop for AgentCancelTokenGuard {
5458
}
5559
}
5660

61+
fn agent_workspace_root(settings: &AppSettings) -> PathBuf {
62+
settings
63+
.agent_workspace_root
64+
.as_deref()
65+
.map(str::trim)
66+
.filter(|path| !path.is_empty())
67+
.map(PathBuf::from)
68+
.unwrap_or_else(|| crate::paths::aqbot_home().join("workspace"))
69+
}
70+
71+
fn agent_workspace_dir_name(
72+
conv: &aqbot_core::types::Conversation,
73+
settings: &AppSettings,
74+
) -> String {
75+
let raw = match settings.agent_workspace_name_strategy.as_str() {
76+
"conversation_id" | "uuid" => conv.id.clone(),
77+
"created_timestamp" => conv.created_at.to_string(),
78+
"created_datetime" => {
79+
let format = settings
80+
.agent_workspace_datetime_format
81+
.as_deref()
82+
.map(str::trim)
83+
.filter(|format| !format.is_empty())
84+
.unwrap_or(DEFAULT_AGENT_WORKSPACE_DATETIME_FORMAT);
85+
format_agent_workspace_datetime(conv.created_at, format)
86+
}
87+
_ => conv.id.clone(),
88+
};
89+
90+
sanitize_workspace_dir_name(&raw)
91+
}
92+
93+
fn format_agent_workspace_datetime(timestamp: i64, format: &str) -> String {
94+
use chrono::{Local, TimeZone};
95+
96+
let dt = Local
97+
.timestamp_opt(timestamp, 0)
98+
.single()
99+
.or_else(|| Local.timestamp_opt(0, 0).single())
100+
.expect("local epoch timestamp should be valid");
101+
102+
format
103+
.replace("YYYY", &dt.format("%Y").to_string())
104+
.replace("MM", &dt.format("%m").to_string())
105+
.replace("DD", &dt.format("%d").to_string())
106+
.replace("HH", &dt.format("%H").to_string())
107+
.replace("mm", &dt.format("%M").to_string())
108+
.replace("ss", &dt.format("%S").to_string())
109+
}
110+
111+
fn sanitize_workspace_dir_name(raw: &str) -> String {
112+
let mut sanitized = String::with_capacity(raw.len().min(MAX_AGENT_WORKSPACE_NAME_LEN));
113+
let mut last_was_dash = false;
114+
115+
for ch in raw.chars() {
116+
let safe = if ch.is_control()
117+
|| matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*')
118+
{
119+
'-'
120+
} else {
121+
ch
122+
};
123+
124+
if safe == '-' {
125+
if !last_was_dash {
126+
sanitized.push('-');
127+
last_was_dash = true;
128+
}
129+
} else {
130+
sanitized.push(safe);
131+
last_was_dash = false;
132+
}
133+
}
134+
135+
let trimmed = sanitized.trim_matches(|ch| matches!(ch, '-' | '.' | ' '));
136+
let bounded = truncate_workspace_name(
137+
if trimmed.is_empty() {
138+
"workspace"
139+
} else {
140+
trimmed
141+
},
142+
MAX_AGENT_WORKSPACE_NAME_LEN,
143+
);
144+
let final_name = bounded
145+
.trim_matches(|ch| matches!(ch, '-' | '.' | ' '))
146+
.to_string();
147+
148+
if final_name.is_empty() {
149+
"workspace".to_string()
150+
} else {
151+
final_name
152+
}
153+
}
154+
155+
fn truncate_workspace_name(value: &str, max_len: usize) -> String {
156+
let mut output = String::new();
157+
for ch in value.chars() {
158+
if output.len() + ch.len_utf8() > max_len {
159+
break;
160+
}
161+
output.push(ch);
162+
}
163+
output
164+
}
165+
166+
fn resolve_agent_workspace_dir(
167+
settings: &AppSettings,
168+
conv: &aqbot_core::types::Conversation,
169+
) -> PathBuf {
170+
let root = agent_workspace_root(settings);
171+
let base_name = agent_workspace_dir_name(conv, settings);
172+
let first = root.join(&base_name);
173+
if !first.exists() {
174+
return first;
175+
}
176+
177+
let id_suffix = short_conversation_id(&conv.id);
178+
let with_id = root.join(append_workspace_suffix(&base_name, &id_suffix));
179+
if !with_id.exists() {
180+
return with_id;
181+
}
182+
183+
for index in 2..10_000 {
184+
let name = append_workspace_suffix(&base_name, &format!("{}-{}", id_suffix, index));
185+
let path = root.join(name);
186+
if !path.exists() {
187+
return path;
188+
}
189+
}
190+
191+
root.join(append_workspace_suffix(
192+
&base_name,
193+
&aqbot_core::utils::gen_id()
194+
.chars()
195+
.take(8)
196+
.collect::<String>(),
197+
))
198+
}
199+
200+
fn append_workspace_suffix(base: &str, suffix: &str) -> String {
201+
let safe_suffix = sanitize_workspace_dir_name(suffix);
202+
let suffix_part = format!("-{}", safe_suffix);
203+
let max_base_len = MAX_AGENT_WORKSPACE_NAME_LEN.saturating_sub(suffix_part.len());
204+
let base_part = truncate_workspace_name(base, max_base_len.max(1))
205+
.trim_matches(|ch| matches!(ch, '-' | '.' | ' '))
206+
.to_string();
207+
format!(
208+
"{}{}",
209+
if base_part.is_empty() {
210+
"workspace"
211+
} else {
212+
&base_part
213+
},
214+
suffix_part
215+
)
216+
}
217+
218+
fn short_conversation_id(conversation_id: &str) -> String {
219+
let prefix = conversation_id.chars().take(8).collect::<String>();
220+
sanitize_workspace_dir_name(if prefix.is_empty() { "conv" } else { &prefix })
221+
}
222+
57223
async fn ensure_agent_assistant_message(
58224
db: &sea_orm::DatabaseConnection,
59225
app: &tauri::AppHandle,
@@ -1527,10 +1693,21 @@ pub async fn agent_get_session(
15271693

15281694
/// Create default workspace directory under config home and return its path.
15291695
#[tauri::command]
1530-
pub async fn agent_ensure_workspace(conversation_id: String) -> Result<String, String> {
1531-
let workspace_dir = crate::paths::aqbot_home()
1532-
.join("workspace")
1533-
.join(&conversation_id);
1696+
pub async fn agent_ensure_workspace(
1697+
state: State<'_, AppState>,
1698+
conversation_id: String,
1699+
) -> Result<String, String> {
1700+
let mut settings = aqbot_core::repo::settings::get_settings(&state.sea_db)
1701+
.await
1702+
.map_err(|e| e.to_string())?;
1703+
settings.agent_workspace_root =
1704+
aqbot_core::path_vars::decode_path_opt(&settings.agent_workspace_root);
1705+
1706+
let conv = conversation::get_conversation(&state.sea_db, &conversation_id)
1707+
.await
1708+
.map_err(|e| e.to_string())?;
1709+
1710+
let workspace_dir = resolve_agent_workspace_dir(&settings, &conv);
15341711
std::fs::create_dir_all(&workspace_dir)
15351712
.map_err(|e| format!("Failed to create workspace: {}", e))?;
15361713
workspace_dir
@@ -1570,6 +1747,36 @@ mod tests {
15701747
use std::fs;
15711748
use std::io::{Cursor, Write};
15721749

1750+
fn test_conversation(id: &str, created_at: i64) -> aqbot_core::types::Conversation {
1751+
aqbot_core::types::Conversation {
1752+
id: id.to_string(),
1753+
title: "Agent test".to_string(),
1754+
model_id: "model".to_string(),
1755+
provider_id: "provider".to_string(),
1756+
system_prompt: None,
1757+
temperature: None,
1758+
max_tokens: None,
1759+
top_p: None,
1760+
frequency_penalty: None,
1761+
search_enabled: false,
1762+
search_provider_id: None,
1763+
thinking_budget: None,
1764+
thinking_level: None,
1765+
enabled_mcp_server_ids: Vec::new(),
1766+
enabled_knowledge_base_ids: Vec::new(),
1767+
enabled_memory_namespace_ids: Vec::new(),
1768+
message_count: 0,
1769+
is_pinned: false,
1770+
is_archived: false,
1771+
context_compression: false,
1772+
category_id: None,
1773+
parent_conversation_id: None,
1774+
mode: "agent".to_string(),
1775+
created_at,
1776+
updated_at: created_at,
1777+
}
1778+
}
1779+
15731780
fn test_docx_bytes(text: &str) -> Vec<u8> {
15741781
let cursor = Cursor::new(Vec::new());
15751782
let mut zip = zip::ZipWriter::new(cursor);
@@ -1653,4 +1860,63 @@ mod tests {
16531860
assert!(result.1.contains("agent-notes.docx"));
16541861
assert!(result.1.contains("Agent document context"));
16551862
}
1863+
1864+
#[test]
1865+
fn agent_workspace_name_uses_selected_strategy() {
1866+
let conv = test_conversation("conv-12345678", 1_700_000_000);
1867+
let mut settings = AppSettings::default();
1868+
1869+
assert_eq!(agent_workspace_dir_name(&conv, &settings), "conv-12345678");
1870+
1871+
settings.agent_workspace_name_strategy = "conversation_id".to_string();
1872+
assert_eq!(agent_workspace_dir_name(&conv, &settings), "conv-12345678");
1873+
1874+
settings.agent_workspace_name_strategy = "created_timestamp".to_string();
1875+
assert_eq!(agent_workspace_dir_name(&conv, &settings), "1700000000");
1876+
1877+
settings.agent_workspace_name_strategy = "created_datetime".to_string();
1878+
settings.agent_workspace_datetime_format = Some("YYYY-MM-DD-HH:mm:ss".to_string());
1879+
let expected = {
1880+
use chrono::{Local, TimeZone};
1881+
Local
1882+
.timestamp_opt(1_700_000_000, 0)
1883+
.single()
1884+
.unwrap()
1885+
.format("%Y-%m-%d-%H-%M-%S")
1886+
.to_string()
1887+
};
1888+
assert_eq!(agent_workspace_dir_name(&conv, &settings), expected);
1889+
}
1890+
1891+
#[test]
1892+
fn agent_workspace_name_is_filesystem_safe_and_bounded() {
1893+
let raw = format!("bad/name:with*chars?{}", "x".repeat(120));
1894+
1895+
let sanitized = sanitize_workspace_dir_name(&raw);
1896+
1897+
assert!(!sanitized.contains('/'));
1898+
assert!(!sanitized.contains(':'));
1899+
assert!(!sanitized.contains('*'));
1900+
assert!(!sanitized.contains('?'));
1901+
assert!(sanitized.len() <= 80);
1902+
}
1903+
1904+
#[test]
1905+
fn agent_workspace_path_uses_collision_suffixes() {
1906+
let temp_dir = tempfile::tempdir().unwrap();
1907+
let conv = test_conversation("abcdef12-3456-7890-abcd-ef1234567890", 1_700_000_000);
1908+
let mut settings = AppSettings::default();
1909+
settings.agent_workspace_root = Some(temp_dir.path().to_string_lossy().to_string());
1910+
settings.agent_workspace_name_strategy = "created_timestamp".to_string();
1911+
1912+
fs::create_dir_all(temp_dir.path().join("1700000000")).unwrap();
1913+
fs::create_dir_all(temp_dir.path().join("1700000000-abcdef12")).unwrap();
1914+
1915+
let workspace = resolve_agent_workspace_dir(&settings, &conv);
1916+
1917+
assert_eq!(
1918+
workspace.file_name().and_then(|name| name.to_str()),
1919+
Some("1700000000-abcdef12-2")
1920+
);
1921+
}
16561922
}

0 commit comments

Comments
 (0)