@@ -13,6 +13,7 @@ use open_agent_sdk::{
1313use serde:: { Deserialize , Serialize } ;
1414use serde_json:: Value ;
1515use std:: collections:: HashMap ;
16+ use std:: path:: PathBuf ;
1617use std:: sync:: { Arc , LazyLock , Mutex } ;
1718use tauri:: { Emitter , State } ;
1819use tokio:: sync:: RwLock ;
@@ -22,6 +23,9 @@ use tokio::sync::RwLock;
2223static 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.
2731struct 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+
57223async 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