From 4ae97d9d7a4df36494d29da29e5b066bc2e5ea90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E6=8C=AF=E5=8D=8E?= Date: Thu, 28 May 2026 09:07:25 +0800 Subject: [PATCH 1/2] fix(file): save uploads to conversation workspace --- Cargo.lock | 1 + crates/aionui-app/src/router/state.rs | 6 +- crates/aionui-app/tests/file_e2e.rs | 54 ++++++++ crates/aionui-file/Cargo.toml | 1 + crates/aionui-file/src/service.rs | 179 +++++++++++++++++++++++++- crates/aionui-file/src/traits.rs | 11 +- 6 files changed, 240 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 23dc01a8..e780cb9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -622,6 +622,7 @@ version = "0.1.14" dependencies = [ "aionui-api-types", "aionui-common", + "aionui-db", "aionui-realtime", "async-trait", "axum", diff --git a/crates/aionui-app/src/router/state.rs b/crates/aionui-app/src/router/state.rs index 68a5e3d9..373a9e11 100644 --- a/crates/aionui-app/src/router/state.rs +++ b/crates/aionui-app/src/router/state.rs @@ -282,7 +282,11 @@ pub fn build_file_state(services: &AppServices) -> FileRouterState { let broadcaster = services.event_bus.clone(); let allowed_roots = default_allowed_roots(Some(services.work_dir.as_path())); let browse_roots = aionui_file::browse::default_browse_roots(); - let file_service = Arc::new(FileService::new(broadcaster.clone(), allowed_roots.clone())); + let upload_settings_repo = Arc::new(SqliteSettingsRepository::new(services.database.pool().clone())); + let file_service = Arc::new( + FileService::new(broadcaster.clone(), allowed_roots.clone()) + .with_upload_workspace_context(upload_settings_repo, services.conversation_repo.clone()), + ); let watch_service = Arc::new(FileWatchService::new(broadcaster).expect("file watch service initialization")); let snapshot_service = Arc::new(SnapshotService::new()); FileRouterState { diff --git a/crates/aionui-app/tests/file_e2e.rs b/crates/aionui-app/tests/file_e2e.rs index 28755e9c..36164a73 100644 --- a/crates/aionui-app/tests/file_e2e.rs +++ b/crates/aionui-app/tests/file_e2e.rs @@ -879,6 +879,60 @@ async fn upload_accepts_small_png_and_returns_readable_path() { let _ = std::fs::remove_dir(p.parent().unwrap()); } +#[tokio::test] +async fn upload_saves_to_conversation_workspace_when_setting_enabled() { + let (mut app, services) = build_app().await; + let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + + let req = json_with_token( + "PATCH", + "/api/settings", + json!({ "save_upload_to_workspace": true }), + &token, + &csrf, + ); + let resp = app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let workspace = tempfile::tempdir().unwrap(); + let req = json_with_token( + "POST", + "/api/conversations", + json!({ + "type": "acp", + "name": "Upload workspace target", + "extra": { "workspace": workspace.path().to_str().unwrap() } + }), + &token, + &csrf, + ); + let resp = app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); + let created = body_json(resp).await; + let conversation_id = created["data"]["id"].as_str().unwrap(); + + let bytes = br#"{"uploaded":true}"#.to_vec(); + let (content_type, body) = UploadMultipart::new() + .add_file("file", "workspace-upload.json", "application/json", &bytes) + .add_text("conversation_id", conversation_id) + .build(); + + let req = upload_request(&content_type, body, &token, &csrf); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let json = body_json(resp).await; + let path = json["data"].as_str().expect("data should be a string path"); + let path = std::path::Path::new(path); + let workspace_root = std::fs::canonicalize(workspace.path()).unwrap(); + + assert_eq!(path.parent().unwrap(), workspace_root); + assert_eq!(path.file_name().unwrap().to_string_lossy(), "workspace-upload.json"); + assert_eq!(std::fs::read(path).unwrap(), bytes); + + let _ = std::fs::remove_file(path); +} + #[tokio::test] async fn upload_uses_content_disposition_filename_when_file_name_missing() { let (mut app, services) = build_app().await; diff --git a/crates/aionui-file/Cargo.toml b/crates/aionui-file/Cargo.toml index 05db2749..23071b23 100644 --- a/crates/aionui-file/Cargo.toml +++ b/crates/aionui-file/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true [dependencies] aionui-api-types.workspace = true aionui-common.workspace = true +aionui-db.workspace = true aionui-realtime.workspace = true async-trait.workspace = true axum.workspace = true diff --git a/crates/aionui-file/src/service.rs b/crates/aionui-file/src/service.rs index 2b15d390..cf7b3d5d 100644 --- a/crates/aionui-file/src/service.rs +++ b/crates/aionui-file/src/service.rs @@ -11,6 +11,7 @@ use tracing::warn; use aionui_api_types::WebSocketMessage; use aionui_common::AppError; +use aionui_db::{IConversationRepository, ISettingsRepository}; use aionui_realtime::EventBroadcaster; use crate::path_safety::{has_traversal, validate_path, validate_path_for_write, validate_path_with_extra_root}; @@ -59,6 +60,7 @@ const PLACEHOLDER_SVG: &str = concat!( /// A concrete implementation of [`crate::traits::IFileService`]. pub struct FileService { broadcaster: Arc, + upload_workspace_context: Option, /// Allowed root directories for path safety validation. allowed_roots: Vec, /// In-memory cache for `list_workspace_files`, keyed by canonical root. @@ -67,16 +69,34 @@ pub struct FileService { zip_cancellations: DashMap>, } +struct UploadWorkspaceContext { + settings_repo: Arc, + conversation_repo: Arc, +} + impl FileService { pub fn new(broadcaster: Arc, allowed_roots: Vec) -> Self { Self { broadcaster, + upload_workspace_context: None, allowed_roots, workspace_files_cache: DashMap::new(), zip_cancellations: DashMap::new(), } } + pub fn with_upload_workspace_context( + mut self, + settings_repo: Arc, + conversation_repo: Arc, + ) -> Self { + self.upload_workspace_context = Some(UploadWorkspaceContext { + settings_repo, + conversation_repo, + }); + self + } + /// Invalidate the workspace files cache for a given root. /// Called when file changes are detected. pub fn invalidate_cache(&self, root: &str) { @@ -114,6 +134,57 @@ impl FileService { .any(|root| candidate.starts_with(root)) } + async fn resolve_upload_directory(&self, conversation_id: Option<&str>) -> Result { + let temp_dir = upload_temp_dir(conversation_id); + let Some(conversation_id) = conversation_id else { + return Ok(temp_dir); + }; + let Some(context) = self.upload_workspace_context.as_ref() else { + return Ok(temp_dir); + }; + + let settings = context + .settings_repo + .get_settings() + .await + .map_err(|e| AppError::Internal(format!("Failed to get upload setting: {e}")))?; + if !settings.as_ref().map(|s| s.save_upload_to_workspace).unwrap_or(false) { + return Ok(temp_dir); + } + + let Some(conversation) = context + .conversation_repo + .get(conversation_id) + .await + .map_err(|e| AppError::Internal(format!("Failed to get conversation for upload workspace: {e}")))? + else { + warn!( + conversation_id, + "upload workspace save requested but conversation was not found; falling back to temp upload directory" + ); + return Ok(temp_dir); + }; + + match workspace_from_conversation_extra(&conversation.extra) { + Ok(Some(workspace)) => Ok(workspace), + Ok(None) => { + warn!( + conversation_id, + "upload workspace save requested but conversation has no workspace; falling back to temp upload directory" + ); + Ok(temp_dir) + } + Err(e) => { + warn!( + conversation_id, + error = %e, + "upload workspace save requested but conversation extra is invalid; falling back to temp upload directory" + ); + Ok(temp_dir) + } + } + } + /// List immediate children of `dir`, building a single-level tree. /// Each child directory also lists *its* children (depth = 2 from `dir`). async fn build_dir_tree(&self, dir: &Path, root: &Path) -> Result, AppError> { @@ -352,6 +423,26 @@ fn split_base_ext(name: &str) -> (&str, &str) { } } +fn upload_temp_dir(conversation_id: Option<&str>) -> PathBuf { + let mut dir = std::env::temp_dir().join("aionui"); + if let Some(conversation_id) = conversation_id { + dir = dir.join(conversation_id); + } else { + dir = dir.join("general"); + } + dir +} + +fn workspace_from_conversation_extra(extra: &str) -> Result, serde_json::Error> { + let value: serde_json::Value = serde_json::from_str(extra)?; + Ok(value + .get("workspace") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|workspace| !workspace.is_empty()) + .map(PathBuf::from)) +} + /// Get file metadata synchronously. fn get_file_metadata_sync(path: &Path) -> Result { let metadata = std::fs::metadata(path) @@ -924,16 +1015,13 @@ impl crate::traits::IFileService for FileService { let name = file_name.to_owned(); let bytes = data.to_vec(); + let dir = self.resolve_upload_directory(conv_id.as_deref()).await?; tokio::task::spawn_blocking(move || { - let mut dir = std::env::temp_dir().join("aionui"); - if let Some(conv_id) = conv_id.as_deref() { - dir = dir.join(conv_id); - } else { - dir = dir.join("general"); - } std::fs::create_dir_all(&dir) .map_err(|e| AppError::Internal(format!("cannot create upload directory: {e}")))?; + let dir = std::fs::canonicalize(&dir) + .map_err(|e| AppError::Internal(format!("cannot resolve upload directory: {e}")))?; let (base, ext) = split_base_ext(&name); let mut candidate = name.clone(); @@ -1857,6 +1945,44 @@ mod tests { crate::service::FileService::new(Arc::new(NullBroadcaster), vec![]) } + async fn make_service_with_upload_context( + save_upload_to_workspace: bool, + conversation_id: &str, + workspace: &std::path::Path, + ) -> (crate::service::FileService, aionui_db::Database) { + use aionui_db::{IConversationRepository, ISettingsRepository}; + + let db = aionui_db::init_database_memory().await.unwrap(); + let settings_repo = Arc::new(aionui_db::SqliteSettingsRepository::new(db.pool().clone())); + settings_repo + .upsert_settings("en-US", true, false, false, save_upload_to_workspace) + .await + .unwrap(); + + let conversation_repo = Arc::new(aionui_db::SqliteConversationRepository::new(db.pool().clone())); + let now = aionui_common::now_ms(); + let row = aionui_db::models::ConversationRow { + id: conversation_id.to_owned(), + user_id: "system_default_user".to_owned(), + name: "Upload target".to_owned(), + r#type: "acp".to_owned(), + extra: serde_json::json!({ "workspace": workspace.to_string_lossy() }).to_string(), + model: None, + status: Some("pending".to_owned()), + source: Some("aionui".to_owned()), + channel_chat_id: None, + pinned: false, + pinned_at: None, + created_at: now, + updated_at: now, + }; + conversation_repo.create(&row).await.unwrap(); + + let service = crate::service::FileService::new(Arc::new(NullBroadcaster), vec![]) + .with_upload_workspace_context(settings_repo, conversation_repo); + (service, db) + } + #[tokio::test] async fn create_upload_file_writes_bytes_and_returns_path() { use crate::traits::IFileService; @@ -1906,6 +2032,47 @@ mod tests { let _ = std::fs::remove_dir(parent); } + #[tokio::test] + async fn create_upload_file_uses_conversation_workspace_when_setting_enabled() { + use crate::traits::IFileService; + + let workspace = tempfile::tempdir().unwrap(); + let conv = unique_conv_id("workspace"); + let (svc, _db) = make_service_with_upload_context(true, &conv, workspace.path()).await; + + let path_str = svc + .create_upload_file("project.json", br#"{"ok":true}"#, Some(&conv)) + .await + .unwrap(); + let path = std::path::Path::new(&path_str); + let workspace_root = std::fs::canonicalize(workspace.path()).unwrap(); + + assert_eq!(path.parent().unwrap(), workspace_root); + assert_eq!(path.file_name().unwrap().to_string_lossy(), "project.json"); + assert_eq!(std::fs::read(path).unwrap(), br#"{"ok":true}"#); + + let _ = std::fs::remove_file(path); + } + + #[tokio::test] + async fn create_upload_file_uses_temp_directory_when_setting_disabled() { + use crate::traits::IFileService; + + let workspace = tempfile::tempdir().unwrap(); + let conv = unique_conv_id("workspace-disabled"); + let (svc, _db) = make_service_with_upload_context(false, &conv, workspace.path()).await; + + let path_str = svc.create_upload_file("note.txt", b"temp", Some(&conv)).await.unwrap(); + let path = std::path::Path::new(&path_str); + let parent = path.parent().unwrap(); + + assert_eq!(parent.file_name().unwrap().to_string_lossy(), conv); + assert_ne!(parent, std::fs::canonicalize(workspace.path()).unwrap()); + assert_eq!(std::fs::read(path).unwrap(), b"temp"); + + let _ = std::fs::remove_dir_all(parent); + } + #[tokio::test] async fn create_upload_file_rejects_path_separators() { use crate::traits::IFileService; diff --git a/crates/aionui-file/src/traits.rs b/crates/aionui-file/src/traits.rs index 11177cb6..b158334a 100644 --- a/crates/aionui-file/src/traits.rs +++ b/crates/aionui-file/src/traits.rs @@ -60,12 +60,13 @@ pub trait IFileService: Send + Sync { /// Create an empty temporary file and return its absolute path. async fn create_temp_file(&self, file_name: &str) -> Result; - /// Write `data` to a temporary file and return its absolute path. + /// Write `data` to an upload file and return its absolute path. /// - /// When `conversation_id` is provided, the file is placed under a - /// per-conversation sub-directory (`/aionui//`); - /// otherwise the shared `/aionui/` directory is used (same as - /// [`create_temp_file`](Self::create_temp_file)). + /// When `conversation_id` is provided and upload-to-workspace is enabled, + /// the file is placed in that conversation's workspace. Otherwise it is + /// placed under a per-conversation temp directory + /// (`/aionui//`) or `/aionui/general/` when no + /// conversation is provided. /// /// `file_name` must not contain path separators or traversal patterns. async fn create_upload_file( From 164ffb1cdaeeda6e0e6d025913b8a94e703624b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E6=8C=AF=E5=8D=8E?= Date: Thu, 28 May 2026 14:00:41 +0800 Subject: [PATCH 2/2] fix(file): refresh workspace list after upload In Docker-hosted AionUI web mode, the conversation page can prime /api/fs/list while the auto-created workspace is still empty. If the user uploads a file into that workspace afterwards, the cached empty file list keeps @ file search from finding the new document. Invalidate the workspace file list cache after a successful upload so file mentions see newly uploaded files in the current conversation workspace. Tested: docker run --rm -v /Users/xzh/workspace/harness/AionCore:/workspace -w /workspace -e CARGO_TARGET_DIR=/tmp/aioncore-target rust:1.95.0 cargo test -p aionui-file create_upload_file_invalidates_workspace_file_list_cache Tested: docker run --rm -v /Users/xzh/workspace/harness/AionCore:/workspace -w /workspace rust:1.95.0 cargo fmt --all -- --check --- crates/aionui-file/src/service.rs | 35 ++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/crates/aionui-file/src/service.rs b/crates/aionui-file/src/service.rs index cf7b3d5d..5268e932 100644 --- a/crates/aionui-file/src/service.rs +++ b/crates/aionui-file/src/service.rs @@ -1017,7 +1017,7 @@ impl crate::traits::IFileService for FileService { let bytes = data.to_vec(); let dir = self.resolve_upload_directory(conv_id.as_deref()).await?; - tokio::task::spawn_blocking(move || { + let uploaded_path = tokio::task::spawn_blocking(move || { std::fs::create_dir_all(&dir) .map_err(|e| AppError::Internal(format!("cannot create upload directory: {e}")))?; let dir = std::fs::canonicalize(&dir) @@ -1059,7 +1059,14 @@ impl crate::traits::IFileService for FileService { } }) .await - .map_err(|e| AppError::Internal(format!("create upload file task failed: {e}")))? + .map_err(|e| AppError::Internal(format!("create upload file task failed: {e}")))??; + + // Uploads can create files inside a workspace after the file mention + // list has already cached an empty result. Clear all roots because an + // upload can affect both the exact workspace cache and ancestor roots. + self.workspace_files_cache.clear(); + + Ok(uploaded_path) } async fn get_image_base64(&self, path: &str, extra_root: Option<&Path>) -> Result { @@ -1978,7 +1985,7 @@ mod tests { }; conversation_repo.create(&row).await.unwrap(); - let service = crate::service::FileService::new(Arc::new(NullBroadcaster), vec![]) + let service = crate::service::FileService::new(Arc::new(NullBroadcaster), vec![workspace.to_path_buf()]) .with_upload_workspace_context(settings_repo, conversation_repo); (service, db) } @@ -2054,6 +2061,28 @@ mod tests { let _ = std::fs::remove_file(path); } + #[tokio::test] + async fn create_upload_file_invalidates_workspace_file_list_cache() { + use crate::traits::IFileService; + + let workspace = tempfile::tempdir().unwrap(); + let conv = unique_conv_id("workspace-cache"); + let (svc, _db) = make_service_with_upload_context(true, &conv, workspace.path()).await; + let workspace_str = workspace.path().to_string_lossy().into_owned(); + + let before = svc.list_workspace_files(&workspace_str).await.unwrap(); + assert!(before.is_empty()); + + let path_str = svc + .create_upload_file("cached.md", b"# cached", Some(&conv)) + .await + .unwrap(); + let after = svc.list_workspace_files(&workspace_str).await.unwrap(); + + assert!(after.iter().any(|file| file.name == "cached.md")); + let _ = std::fs::remove_file(path_str); + } + #[tokio::test] async fn create_upload_file_uses_temp_directory_when_setting_disabled() { use crate::traits::IFileService;