diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 5a52fda29..6944ce9eb 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -1761,20 +1761,32 @@ async fn apply_and_emit_session_config_options( emit_session_config_options_values(state, emitter, agent_type, updated).await; } +fn effective_prompt_capabilities( + agent_type: AgentType, + capabilities: &sacp::schema::PromptCapabilities, +) -> PromptCapabilitiesInfo { + PromptCapabilitiesInfo { + // Grok 0.2.101/0.2.102 advertises `image=false`, but its ACP + // `session/prompt` accepts ImageContent and forwards the bytes to the + // model. Treat that known adapter mismatch as image-capable so the UI + // does not degrade explicit images into unreadable file links. + image: capabilities.image || matches!(agent_type, AgentType::Grok), + audio: capabilities.audio, + embedded_context: capabilities.embedded_context, + } +} + async fn emit_prompt_capabilities( state: &Arc>, emitter: &EventEmitter, + agent_type: AgentType, capabilities: &sacp::schema::PromptCapabilities, ) { emit_with_state( state, emitter, AcpEvent::PromptCapabilities { - prompt_capabilities: PromptCapabilitiesInfo { - image: capabilities.image, - audio: capabilities.audio, - embedded_context: capabilities.embedded_context, - }, + prompt_capabilities: effective_prompt_capabilities(agent_type, capabilities), }, ) .await; @@ -2573,9 +2585,18 @@ async fn run_connection( return Err(sacp::util::internal_error(INIT_TIMEOUT_SENTINEL)); } }; + if matches!(agent_type, AgentType::Grok) + && !init_resp.agent_capabilities.prompt_capabilities.image + { + tracing::warn!( + "[ACP][Grok] overriding advertised promptCapabilities.image=false; \ + current Grok ACP accepts inline ImageContent" + ); + } emit_prompt_capabilities( &state, &emitter_clone, + agent_type, &init_resp.agent_capabilities.prompt_capabilities, ) .await; @@ -6498,6 +6519,21 @@ mod tests { crate::acp::question::validate_specs(&specs).unwrap(); } + #[test] + fn grok_effective_prompt_capabilities_include_verified_image_support() { + let advertised = sacp::schema::PromptCapabilities::new().embedded_context(true); + + let grok = effective_prompt_capabilities(AgentType::Grok, &advertised); + assert!(grok.image); + assert!(!grok.audio); + assert!(grok.embedded_context); + + let claude = effective_prompt_capabilities(AgentType::ClaudeCode, &advertised); + assert!(!claude.image); + assert!(!claude.audio); + assert!(claude.embedded_context); + } + fn diff_content(path: &str, old: Option<&str>, new: &str) -> ToolCallContent { let mut d = Diff::new(path, new); if let Some(o) = old { diff --git a/src-tauri/src/app_error.rs b/src-tauri/src/app_error.rs index 68defc36f..902c990fc 100644 --- a/src-tauri/src/app_error.rs +++ b/src-tauri/src/app_error.rs @@ -21,19 +21,20 @@ use crate::db::error::DbError; // becomes a loud CI failure rather than a silent demotion to the // generic "upload failed" toast. -/// Error key emitted when an upload payload exceeds `UPLOAD_MAX_BYTES`, -/// at any of three layers (local pre-read, base64 pre-decode, post-decode). +/// Error key emitted when an attachment exceeds its Rust-controlled size +/// limit, including local pre-read and post-read checks and remote upload +/// base64 pre-decode/post-decode checks. /// Frontend params: `size`, `limit`. pub const UPLOAD_I18N_KEY_TOO_LARGE: &str = "errors.upload.tooLarge"; -/// Error key emitted when `read_local_file_for_upload` is handed a path -/// that resolves to a directory, FIFO, device node, or other non-regular -/// file. No params. +/// Error key emitted when a local attachment reader is handed a path that is +/// a symlink, directory, FIFO, device node, or other non-regular file. No +/// params. /// -/// Only `commands/remote_proxy.rs` emits this today (the command is gated -/// on `feature = "tauri-runtime"`), so the server-only build won't see a -/// use site. The constant still has to exist there because it is part of -/// the wire-format contract the frontend depends on, hence `allow(dead_code)`. +/// Production readers are gated on `feature = "tauri-runtime"`, so the +/// server-only build won't see a use site outside tests. The constant still +/// has to exist there because it is part of the frontend wire-format contract, +/// hence `allow(dead_code)`. #[allow(dead_code)] pub const UPLOAD_I18N_KEY_NOT_A_FILE: &str = "errors.upload.notAFile"; diff --git a/src-tauri/src/commands/local_attachment.rs b/src-tauri/src/commands/local_attachment.rs new file mode 100644 index 000000000..22ef46ccb --- /dev/null +++ b/src-tauri/src/commands/local_attachment.rs @@ -0,0 +1,181 @@ +use std::collections::BTreeMap; +use std::path::Path; + +use crate::app_error::{AppCommandError, UPLOAD_I18N_KEY_NOT_A_FILE, UPLOAD_I18N_KEY_TOO_LARGE}; + +pub(crate) const UPLOAD_MAX_BYTES: u64 = 2 * 1024 * 1024; +pub(crate) const IMAGE_MAX_BYTES: u64 = 20_000_000; + +fn size_limit_error(size: u64, limit: u64) -> AppCommandError { + AppCommandError::io_error("Local file exceeds the size limit") + .with_detail(format!("size={size} limit={limit}")) + .with_i18n( + UPLOAD_I18N_KEY_TOO_LARGE, + BTreeMap::from([ + ("size".to_string(), size.to_string()), + ("limit".to_string(), limit.to_string()), + ]), + ) +} + +async fn read_regular_file_with_limit(path: &Path, limit: u64) -> Result, AppCommandError> { + let metadata = tokio::fs::symlink_metadata(path).await.map_err(|e| { + AppCommandError::io_error("Failed to stat local attachment") + .with_detail(format!("{}: {e}", path.display())) + })?; + if !metadata.file_type().is_file() { + return Err(AppCommandError::io_error("Not a regular file") + .with_detail(path.display().to_string()) + .with_i18n(UPLOAD_I18N_KEY_NOT_A_FILE, BTreeMap::new())); + } + if metadata.len() > limit { + return Err(size_limit_error(metadata.len(), limit)); + } + + let bytes = tokio::fs::read(path).await.map_err(|e| { + AppCommandError::io_error("Failed to read local attachment") + .with_detail(format!("{}: {e}", path.display())) + })?; + let actual_size = bytes.len() as u64; + if actual_size > limit { + return Err(size_limit_error(actual_size, limit)); + } + + Ok(bytes) +} + +pub(crate) async fn read_upload_file(path: &Path) -> Result, AppCommandError> { + read_regular_file_with_limit(path, UPLOAD_MAX_BYTES).await +} + +pub(crate) async fn read_image_file(path: &Path) -> Result, AppCommandError> { + read_regular_file_with_limit(path, IMAGE_MAX_BYTES).await +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + async fn create_sparse_file(dir: &Path, name: &str, size: u64) -> PathBuf { + let path = dir.join(name); + let file = tokio::fs::File::create(&path) + .await + .expect("create sparse test file"); + file.set_len(size).await.expect("size sparse test file"); + path + } + + fn assert_not_regular_file(err: &AppCommandError) { + assert_eq!(err.i18n_key.as_deref(), Some(UPLOAD_I18N_KEY_NOT_A_FILE)); + assert!(err.message.contains("Not a regular file")); + } + + #[tokio::test] + async fn image_read_accepts_file_above_upload_limit() { + let dir = tempfile::tempdir().expect("tempdir"); + let size = UPLOAD_MAX_BYTES + 1; + let path = create_sparse_file(dir.path(), "medium.png", size).await; + + let file = read_image_file(&path) + .await + .expect("2-20 MB image should be accepted"); + + assert_eq!(file.len() as u64, size); + } + + #[tokio::test] + async fn image_read_accepts_exact_twenty_million_byte_limit() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = create_sparse_file(dir.path(), "boundary.png", IMAGE_MAX_BYTES).await; + + let file = read_image_file(&path) + .await + .expect("exact image limit should be accepted"); + + assert_eq!(file.len() as u64, IMAGE_MAX_BYTES); + } + + #[tokio::test] + async fn image_read_rejects_above_twenty_million_bytes() { + let dir = tempfile::tempdir().expect("tempdir"); + let size = IMAGE_MAX_BYTES + 1; + let path = create_sparse_file(dir.path(), "oversize.png", size).await; + + let err = read_image_file(&path) + .await + .expect_err("image above limit should be rejected"); + + assert_eq!(err.i18n_key.as_deref(), Some(UPLOAD_I18N_KEY_TOO_LARGE)); + assert_eq!( + err.i18n_params + .as_ref() + .and_then(|params| params.get("limit")) + .map(String::as_str), + Some("20000000") + ); + } + + #[tokio::test] + async fn upload_read_still_rejects_file_above_two_mib() { + let dir = tempfile::tempdir().expect("tempdir"); + let size = UPLOAD_MAX_BYTES + 1; + let path = create_sparse_file(dir.path(), "oversize.txt", size).await; + + let err = read_upload_file(&path) + .await + .expect_err("ordinary upload above 2 MiB should stay rejected"); + + assert_eq!(err.i18n_key.as_deref(), Some(UPLOAD_I18N_KEY_TOO_LARGE)); + assert_eq!( + err.i18n_params + .as_ref() + .and_then(|params| params.get("limit")) + .map(String::as_str), + Some("2097152") + ); + } + + #[tokio::test] + async fn image_read_rejects_directory() { + let dir = tempfile::tempdir().expect("tempdir"); + + let err = read_image_file(dir.path()) + .await + .expect_err("directory should be rejected"); + + assert_not_regular_file(&err); + } + + #[cfg(unix)] + #[tokio::test] + async fn image_read_rejects_symlink_fifo_and_device() { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().expect("tempdir"); + let target = create_sparse_file(dir.path(), "target.png", 1).await; + let link = dir.path().join("link.png"); + symlink(&target, &link).expect("create symlink"); + + let link_err = read_image_file(&link) + .await + .expect_err("symlink should be rejected"); + assert_not_regular_file(&link_err); + + let fifo = dir.path().join("image.fifo"); + let fifo_c = CString::new(fifo.as_os_str().as_bytes()).expect("fifo path CString"); + let rc = unsafe { libc::mkfifo(fifo_c.as_ptr(), 0o600) }; + assert_eq!(rc, 0, "create FIFO: {}", std::io::Error::last_os_error()); + let fifo_err = read_image_file(&fifo) + .await + .expect_err("FIFO should be rejected without reading"); + assert_not_regular_file(&fifo_err); + + let device_err = read_image_file(Path::new("/dev/null")) + .await + .expect_err("device should be rejected"); + assert_not_regular_file(&device_err); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6c5d6a594..a2bbd8163 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -14,6 +14,8 @@ pub mod feedback; pub mod file_io; pub mod folder_commands; pub mod folders; +#[cfg(any(feature = "tauri-runtime", test))] +pub(crate) mod local_attachment; pub mod logging; pub mod mcp; pub mod model_provider; diff --git a/src-tauri/src/commands/remote_proxy.rs b/src-tauri/src/commands/remote_proxy.rs index 30204d2ae..a1b25761d 100644 --- a/src-tauri/src/commands/remote_proxy.rs +++ b/src-tauri/src/commands/remote_proxy.rs @@ -60,6 +60,7 @@ const OUTBOUND_SEND_TIMEOUT: Duration = Duration::from_secs(2); use crate::app_error::{ AppCommandError, AppErrorCode, UPLOAD_I18N_KEY_NOT_A_FILE, UPLOAD_I18N_KEY_TOO_LARGE, }; +use crate::commands::local_attachment::{read_image_file, read_upload_file, UPLOAD_MAX_BYTES}; use crate::db::service::remote_workspace_connection_service; use crate::db::AppDatabase; use crate::workspace_transfer::{ @@ -355,14 +356,6 @@ pub async fn remote_http_call( // ─── Multipart upload proxy ─────────────────────────────────────────── -/// Hard ceiling for `read_local_file_for_upload`. Mirrors the server-side -/// `UPLOAD_MAX_BYTES` in `web/handlers/files.rs`; kept here as a local -/// constant so this command can reject oversize reads *before* incurring -/// the file I/O cost — the remote `/api/upload_attachment` enforces the -/// same cap regardless, but a 100 MB read followed by a base64 encode and -/// an IPC trip would be a noticeable waste compared to early rejection. -const UPLOAD_MAX_BYTES: u64 = 2 * 1024 * 1024; - /// Maximum tolerated base64 payload length, pre-decode. Exactly /// `ceil(UPLOAD_MAX_BYTES / 3) * 4` — that formula already accounts for /// padding to the nearest 4-byte boundary, so a legitimate envelope of @@ -417,15 +410,9 @@ fn sanitize_upload_file_name(raw: &str) -> String { } } -/// Stream a local file into a base64-wrapped JSON envelope ready to be -/// passed to `remote_upload_attachment`. Two callers need this today: the -/// Tauri-native drag-drop path (which receives OS paths from the webview -/// drag handler) and a future "attach this local path" command palette. -/// -/// Rejects anything larger than `UPLOAD_MAX_BYTES` with a structured -/// `IoError` carrying the limit in `detail` so the frontend can format an -/// `attachUploadTooLarge` toast without round-tripping through the actual -/// upload endpoint. +/// Base64-wrapped bytes read through one of the fixed-purpose local attachment +/// readers. Ordinary uploads are capped at 2 MiB; inline images are capped at +/// 20,000,000 bytes. Both limits are selected and enforced by Rust. #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct LocalFileForUpload { @@ -435,55 +422,36 @@ pub struct LocalFileForUpload { pub data_base64: String, } -#[tauri::command] -pub async fn read_local_file_for_upload( - path: String, -) -> Result { - let path_buf = std::path::PathBuf::from(&path); - // Use symlink_metadata + explicit `is_file()` so a webview-driven - // invoke can't follow a `/tmp/symlink → /etc/shadow` into reading - // anything outside the user's intent. The same guard rejects FIFOs - // and device nodes — `tokio::fs::read` would otherwise block on a - // FIFO until the writing side closes, hanging this command (and the - // calling webview's drag-drop handler) indefinitely. - let metadata = tokio::fs::symlink_metadata(&path_buf).await.map_err(|e| { - AppCommandError::io_error("Failed to stat local file for upload") - .with_detail(format!("{}: {e}", path_buf.display())) - })?; - if !metadata.file_type().is_file() { - return Err(AppCommandError::io_error("Not a regular file") - .with_detail(path_buf.display().to_string()) - .with_i18n(UPLOAD_I18N_KEY_NOT_A_FILE, BTreeMap::new())); - } - let size = metadata.len(); - if size > UPLOAD_MAX_BYTES { - return Err( - AppCommandError::io_error("Local file exceeds the upload size limit") - .with_detail(format!("size={size} limit={UPLOAD_MAX_BYTES}")) - .with_i18n( - UPLOAD_I18N_KEY_TOO_LARGE, - upload_i18n_params([ - ("size", size.to_string()), - ("limit", UPLOAD_MAX_BYTES.to_string()), - ]), - ), - ); - } - let bytes = tokio::fs::read(&path_buf).await.map_err(|e| { - AppCommandError::io_error("Failed to read local file for upload") - .with_detail(format!("{}: {e}", path_buf.display())) - })?; +fn local_attachment_from_bytes(path_buf: PathBuf, bytes: Vec) -> LocalFileForUpload { let file_name = path_buf .file_name() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_else(|| "file".to_string()); let mime_type = guess_mime_from_path(&path_buf); - Ok(LocalFileForUpload { + LocalFileForUpload { file_name: sanitize_upload_file_name(&file_name), mime_type, - size, + size: bytes.len() as u64, data_base64: STANDARD.encode(&bytes), - }) + } +} + +#[tauri::command] +pub async fn read_local_file_for_upload( + path: String, +) -> Result { + let path_buf = PathBuf::from(path); + let bytes = read_upload_file(&path_buf).await?; + Ok(local_attachment_from_bytes(path_buf, bytes)) +} + +#[tauri::command] +pub async fn read_local_image_for_attachment( + path: String, +) -> Result { + let path_buf = PathBuf::from(path); + let bytes = read_image_file(&path_buf).await?; + Ok(local_attachment_from_bytes(path_buf, bytes)) } /// Forward a multipart upload (file bytes + optional session bucket) to the diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f789bfb86..f892df19c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1001,6 +1001,7 @@ mod tauri_app { remote_proxy_commands::remote_download_workspace_file, remote_proxy_commands::remote_download_workspace_dir, remote_proxy_commands::read_local_file_for_upload, + remote_proxy_commands::read_local_image_for_attachment, remote_proxy_commands::remote_ws_subscribe, remote_proxy_commands::remote_ws_unsubscribe, remote_proxy_commands::remote_ws_send_text, diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 940c9c257..a4a7822d4 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -18,6 +18,8 @@ use super::{auth, handlers, ws}; use crate::app_state::AppState; use tracing::Instrument; +const ACP_PROMPT_MAX_BYTES: usize = 32 * 1024 * 1024; + pub fn build_router( state: Arc, token: String, @@ -597,7 +599,13 @@ pub fn build_router( "/acp_touch_connection", post(handlers::acp::acp_touch_connection), ) - .route("/acp_prompt", post(handlers::acp::acp_prompt)) + .route( + "/acp_prompt", + // Browser image prompts carry the pasted file inline as base64. + // The native image-read path permits 20 MB, which expands to about + // 26.7 MB before the surrounding JSON is added. + post(handlers::acp::acp_prompt).layer(DefaultBodyLimit::max(ACP_PROMPT_MAX_BYTES)), + ) .route("/acp_preflight", post(handlers::acp::acp_preflight)) .route("/acp_set_mode", post(handlers::acp::acp_set_mode)) .route( @@ -1357,3 +1365,43 @@ async fn api_not_found(uri: axum::http::Uri) -> impl IntoResponse { })), ) } + +#[cfg(test)] +mod tests { + use super::*; + use axum_test::TestServer; + + #[tokio::test] + async fn acp_prompt_accepts_image_payload_above_axum_default_limit() { + let temp_dir = tempfile::tempdir().expect("create test data directory"); + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let state = Arc::new(AppState::new_for_test(db, temp_dir.path().to_path_buf())); + let router = build_router( + state, + "test-token".to_owned(), + temp_dir.path().to_path_buf(), + Arc::new(ShutdownSignal::new()), + ); + let server = TestServer::new(router).expect("create test server"); + + let response = server + .post("/api/acp_prompt") + .authorization_bearer("test-token") + .json(&serde_json::json!({ + "connectionId": "missing-connection", + "blocks": [{ + "type": "image", + "data": "A".repeat(2 * 1024 * 1024), + "mime_type": "image/png", + "uri": null + }], + "folderId": null, + "conversationId": null, + "clientMessageId": null + })) + .await; + + assert_eq!(response.status_code(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(response.text().contains("connection not found")); + } +} diff --git a/src/components/chat/attachment-routing.test.ts b/src/components/chat/attachment-routing.test.ts new file mode 100644 index 000000000..c7be9c475 --- /dev/null +++ b/src/components/chat/attachment-routing.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest" + +import { + partitionAttachmentFiles, + partitionAttachmentPaths, +} from "./attachment-routing" + +describe("attachment routing", () => { + it("recognizes image File objects by declared MIME type", () => { + const image = new File(["image"], "capture.bin", { type: "image/png" }) + const text = new File(["text"], "notes.txt", { type: "text/plain" }) + + expect(partitionAttachmentFiles([image, text], true)).toEqual({ + images: [image], + resources: [text], + }) + }) + + it("falls back to the file extension when MIME type is empty", () => { + const image = new File(["image"], "capture.PNG") + + expect(partitionAttachmentFiles([image], true)).toEqual({ + images: [image], + resources: [], + }) + }) + + it("keeps images as resources when image capability is disabled", () => { + const image = new File(["image"], "capture.png", { type: "image/png" }) + + expect(partitionAttachmentFiles([image], false)).toEqual({ + images: [], + resources: [image], + }) + }) + + it("classifies POSIX and Windows paths case-insensitively", () => { + expect( + partitionAttachmentPaths(["/outside/a.PNG", "C:\\x\\b.txt"], true) + ).toEqual({ + images: ["/outside/a.PNG"], + resources: ["C:\\x\\b.txt"], + }) + }) +}) diff --git a/src/components/chat/attachment-routing.ts b/src/components/chat/attachment-routing.ts new file mode 100644 index 000000000..074ec9610 --- /dev/null +++ b/src/components/chat/attachment-routing.ts @@ -0,0 +1,74 @@ +export interface PartitionedAttachments { + images: T[] + resources: T[] +} + +const MIME_BY_EXT: Record = { + txt: "text/plain", + md: "text/markdown", + json: "application/json", + yaml: "application/yaml", + yml: "application/yaml", + csv: "text/csv", + html: "text/html", + css: "text/css", + js: "text/javascript", + mjs: "text/javascript", + cjs: "text/javascript", + ts: "text/typescript", + tsx: "text/tsx", + jsx: "text/jsx", + py: "text/x-python", + rs: "text/rust", + go: "text/x-go", + java: "text/x-java-source", + xml: "application/xml", + toml: "application/toml", + pdf: "application/pdf", + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", +} + +export function mimeTypeFromPath(path: string): string | null { + const ext = path.split(".").pop()?.toLowerCase() ?? "" + return MIME_BY_EXT[ext] ?? null +} + +function partition( + items: T[], + canAttachImages: boolean, + getMimeType: (item: T) => string | null +): PartitionedAttachments { + const result: PartitionedAttachments = { images: [], resources: [] } + for (const item of items) { + const isImage = getMimeType(item)?.startsWith("image/") ?? false + if (canAttachImages && isImage) { + result.images.push(item) + } else { + result.resources.push(item) + } + } + return result +} + +export function partitionAttachmentFiles( + files: File[], + canAttachImages: boolean +): PartitionedAttachments { + return partition( + files, + canAttachImages, + (file) => file.type || mimeTypeFromPath(file.name) + ) +} + +export function partitionAttachmentPaths( + paths: string[], + canAttachImages: boolean +): PartitionedAttachments { + return partition(paths, canAttachImages, mimeTypeFromPath) +} diff --git a/src/components/chat/message-input.test.tsx b/src/components/chat/message-input.test.tsx index ad00e5d50..16512fbdd 100644 --- a/src/components/chat/message-input.test.tsx +++ b/src/components/chat/message-input.test.tsx @@ -25,6 +25,22 @@ import { emitAttachFileToSession } from "@/lib/session-attachment-events" const composerHandle = vi.hoisted(() => ({ current: null as RichComposerHandle | null, })) +const uploadAttachmentMock = vi.hoisted(() => vi.fn()) +const readFileBase64Mock = vi.hoisted(() => vi.fn()) +const readLocalPathForAttachmentMock = vi.hoisted(() => vi.fn()) +const readLocalImagePathForAttachmentMock = vi.hoisted(() => vi.fn()) +const uploadLocalPathToRemoteMock = vi.hoisted(() => vi.fn()) +const toastErrorMock = vi.hoisted(() => vi.fn()) +const platformMock = vi.hoisted(() => ({ + desktop: false, + openFileDialog: vi.fn(), +})) +const transportMock = vi.hoisted(() => ({ + remoteId: null as string | null, +})) +const tauriListenerMock = vi.hoisted(() => ({ + listeners: new Map void>>(), +})) vi.mock("./composer/rich-composer", async (importOriginal) => { const actual = await importOriginal() @@ -74,11 +90,69 @@ vi.mock("@/components/chat/conversation-context-bar", () => ({ }) =>
{extraContent}
, })) vi.mock("@/lib/platform", () => ({ - isDesktop: () => false, - openFileDialog: vi.fn(), + isDesktop: () => platformMock.desktop, + openFileDialog: platformMock.openFileDialog, })) vi.mock("@/lib/transport", () => ({ - getActiveRemoteConnectionId: () => null, + getActiveRemoteConnectionId: () => transportMock.remoteId, +})) +vi.mock("@/lib/api", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + quickMessagesList: vi.fn().mockResolvedValue([]), + readFileBase64: readFileBase64Mock, + readLocalImagePathForAttachment: readLocalImagePathForAttachmentMock, + readLocalPathForAttachment: readLocalPathForAttachmentMock, + uploadAttachment: uploadAttachmentMock, + uploadLocalPathToRemote: uploadLocalPathToRemoteMock, + } +}) +vi.mock("sonner", () => ({ + toast: { error: toastErrorMock, success: vi.fn() }, +})) +vi.mock("@/components/shared/server-file-browser-dialog", () => ({ + ServerFileBrowserDialog: ({ + open, + onSelect, + }: { + open: boolean + onSelect: (paths: string[]) => void + }) => + open ? ( + + ) : null, +})) +vi.mock("@tauri-apps/api/webview", () => ({ + getCurrentWebview: () => ({ + listen: vi.fn( + async ( + event: string, + callback: (event: { payload: unknown }) => void + ) => { + const listeners = tauriListenerMock.listeners.get(event) ?? [] + listeners.push(callback) + tauriListenerMock.listeners.set(event, listeners) + return () => { + const current = tauriListenerMock.listeners.get(event) ?? [] + tauriListenerMock.listeners.set( + event, + current.filter((item) => item !== callback) + ) + } + } + ), + }), +})) +vi.mock("@tauri-apps/api/event", () => ({ + TauriEvent: { + DRAG_ENTER: "tauri://drag-enter", + DRAG_OVER: "tauri://drag-over", + DRAG_DROP: "tauri://drag-drop", + DRAG_LEAVE: "tauri://drag-leave", + }, })) // virtua renders 0 rows under jsdom — render children directly so the large // (searchable + virtualized) model list is exercisable here too. @@ -525,3 +599,521 @@ describe("MessageInput collapsed selectors popover", () => { ) }) }) + +describe("MessageInput local file upload", () => { + afterEach(() => { + cleanup() + uploadAttachmentMock.mockReset() + }) + + it("sends an uploaded image as inline image data", async () => { + const user = userEvent.setup() + const onSend = vi.fn() + const png = new File(["pixels"], "outside.png", { type: "image/png" }) + const inputClick = vi + .spyOn(HTMLInputElement.prototype, "click") + .mockImplementation(function (this: HTMLInputElement) { + Object.defineProperty(this, "files", { + configurable: true, + value: [png], + }) + this.dispatchEvent(new Event("change")) + }) + + renderInput({ onSend }) + await user.click( + screen.getByRole("button", { + name: enMessages.Folder.chat.messageInput.addActions, + }) + ) + await user.click( + await screen.findByRole("menuitem", { + name: enMessages.Folder.chat.messageInput.attachLocalUpload, + }) + ) + + const send = screen.getByTitle(enMessages.Folder.chat.messageInput.send) + await waitFor(() => expect(send).toBeEnabled()) + await user.click(send) + + expect(onSend).toHaveBeenCalledWith( + expect.objectContaining({ + blocks: [ + expect.objectContaining({ + type: "image", + mime_type: "image/png", + data: "cGl4ZWxz", + }), + ], + }), + null + ) + expect(uploadAttachmentMock).not.toHaveBeenCalled() + expect(inputClick).toHaveBeenCalledOnce() + inputClick.mockRestore() + }) + + it("rejects an oversized uploaded image before it enters composer state", async () => { + const user = userEvent.setup() + const png = new File(["pixels"], "oversized.png", { type: "image/png" }) + Object.defineProperty(png, "size", { + configurable: true, + value: 20_000_001, + }) + const inputClick = vi + .spyOn(HTMLInputElement.prototype, "click") + .mockImplementation(function (this: HTMLInputElement) { + Object.defineProperty(this, "files", { + configurable: true, + value: [png], + }) + this.dispatchEvent(new Event("change")) + }) + + renderInput({}) + await user.click( + screen.getByRole("button", { + name: enMessages.Folder.chat.messageInput.addActions, + }) + ) + await user.click( + await screen.findByRole("menuitem", { + name: enMessages.Folder.chat.messageInput.attachLocalUpload, + }) + ) + + await waitFor(() => expect(inputClick).toHaveBeenCalledOnce()) + expect( + screen.getByTitle(enMessages.Folder.chat.messageInput.send) + ).toBeDisabled() + expect(uploadAttachmentMock).not.toHaveBeenCalled() + inputClick.mockRestore() + }) + + it("keeps successful attachments when another image read fails", async () => { + const user = userEvent.setup() + const onSend = vi.fn() + const good = new File(["good"], "good.png", { type: "image/png" }) + const broken = new File(["broken"], "broken.png", { + type: "image/png", + }) + const notes = new File(["notes"], "notes.txt", { type: "text/plain" }) + uploadAttachmentMock.mockResolvedValue({ path: "/uploads/notes.txt" }) + const nativeReadAsDataUrl = FileReader.prototype.readAsDataURL + const fileReaderSpy = vi + .spyOn(FileReader.prototype, "readAsDataURL") + .mockImplementation(function (this: FileReader, blob: Blob) { + if ((blob as File).name === "broken.png") { + queueMicrotask(() => { + this.onerror?.(new ProgressEvent("error")) + }) + return + } + nativeReadAsDataUrl.call(this, blob) + }) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + const inputClick = vi + .spyOn(HTMLInputElement.prototype, "click") + .mockImplementation(function (this: HTMLInputElement) { + Object.defineProperty(this, "files", { + configurable: true, + value: [good, broken, notes], + }) + this.dispatchEvent(new Event("change")) + }) + + renderInput({ onSend }) + await user.click( + screen.getByRole("button", { + name: enMessages.Folder.chat.messageInput.addActions, + }) + ) + await user.click( + await screen.findByRole("menuitem", { + name: enMessages.Folder.chat.messageInput.attachLocalUpload, + }) + ) + + await waitFor(() => + expect(uploadAttachmentMock).toHaveBeenCalledWith(notes, null) + ) + const send = screen.getByTitle(enMessages.Folder.chat.messageInput.send) + await waitFor(() => expect(send).toBeEnabled()) + await user.click(send) + + expect(onSend).toHaveBeenCalledWith( + expect.objectContaining({ + blocks: expect.arrayContaining([ + expect.objectContaining({ + type: "image", + data: "Z29vZA==", + }), + expect.objectContaining({ + type: "text", + text: "[notes.txt](file:///uploads/notes.txt)", + }), + ]), + }), + null + ) + expect(consoleError).toHaveBeenCalledWith( + "[MessageInput] image attachment read failed (broken.png):", + expect.any(Error) + ) + inputClick.mockRestore() + consoleError.mockRestore() + fileReaderSpy.mockRestore() + }) + + it("keeps uploaded non-image files on the resource path", async () => { + const user = userEvent.setup() + const onSend = vi.fn() + const text = new File(["notes"], "notes.txt", { type: "text/plain" }) + uploadAttachmentMock.mockResolvedValue({ path: "/uploads/notes.txt" }) + const inputClick = vi + .spyOn(HTMLInputElement.prototype, "click") + .mockImplementation(function (this: HTMLInputElement) { + Object.defineProperty(this, "files", { + configurable: true, + value: [text], + }) + this.dispatchEvent(new Event("change")) + }) + + renderInput({ onSend }) + await user.click( + screen.getByRole("button", { + name: enMessages.Folder.chat.messageInput.addActions, + }) + ) + await user.click( + await screen.findByRole("menuitem", { + name: enMessages.Folder.chat.messageInput.attachLocalUpload, + }) + ) + + await waitFor(() => + expect(uploadAttachmentMock).toHaveBeenCalledWith(text, null) + ) + const send = screen.getByTitle(enMessages.Folder.chat.messageInput.send) + await waitFor(() => expect(send).toBeEnabled()) + await user.click(send) + + expect(onSend).toHaveBeenCalledWith( + expect.objectContaining({ + blocks: [ + expect.objectContaining({ + type: "text", + text: "[notes.txt](file:///uploads/notes.txt)", + }), + ], + }), + null + ) + inputClick.mockRestore() + }) +}) + +describe("MessageInput selected image paths", () => { + afterEach(() => { + cleanup() + platformMock.desktop = false + platformMock.openFileDialog.mockReset() + readFileBase64Mock.mockReset() + }) + + it("reads a native-picker image as bounded inline data", async () => { + const user = userEvent.setup() + const onSend = vi.fn() + platformMock.desktop = true + platformMock.openFileDialog.mockResolvedValue([ + "/outside/image.png", + "/outside/notes.txt", + ]) + readFileBase64Mock.mockResolvedValue("bmF0aXZlLWltYWdl") + + renderInput({ onSend }) + await user.click( + screen.getByRole("button", { + name: enMessages.Folder.chat.messageInput.addActions, + }) + ) + await user.click( + await screen.findByRole("menuitem", { + name: enMessages.Folder.chat.messageInput.attachFiles, + }) + ) + + await waitFor(() => + expect(readFileBase64Mock).toHaveBeenCalledWith( + "/outside/image.png", + 20_000_000 + ) + ) + const send = screen.getByTitle(enMessages.Folder.chat.messageInput.send) + await waitFor(() => expect(send).toBeEnabled()) + await user.click(send) + + expect(onSend).toHaveBeenCalledWith( + expect.objectContaining({ + blocks: expect.arrayContaining([ + expect.objectContaining({ + type: "image", + data: "bmF0aXZlLWltYWdl", + }), + expect.objectContaining({ + type: "text", + text: "[notes.txt](file:///outside/notes.txt)", + }), + ]), + }), + null + ) + }) + + it("routes a server-picker image through the bounded reader", async () => { + const user = userEvent.setup() + readFileBase64Mock.mockResolvedValue("c2VydmVyLWltYWdl") + + renderInput({}) + await user.click( + screen.getByRole("button", { + name: enMessages.Folder.chat.messageInput.addActions, + }) + ) + await user.click( + await screen.findByRole("menuitem", { + name: enMessages.Folder.chat.messageInput.attachServerFile, + }) + ) + await user.click(await screen.findByText("Select server image")) + + await waitFor(() => + expect(readFileBase64Mock).toHaveBeenCalledWith( + "/server/outside.png", + 20_000_000 + ) + ) + }) + + it("does not append an image when a selected path cannot be read", async () => { + const user = userEvent.setup() + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + platformMock.desktop = true + platformMock.openFileDialog.mockResolvedValue(["/outside/unreadable.png"]) + readFileBase64Mock.mockRejectedValue(new Error("read denied")) + + renderInput({}) + await user.click( + screen.getByRole("button", { + name: enMessages.Folder.chat.messageInput.addActions, + }) + ) + await user.click( + await screen.findByRole("menuitem", { + name: enMessages.Folder.chat.messageInput.attachFiles, + }) + ) + + await waitFor(() => expect(readFileBase64Mock).toHaveBeenCalledOnce()) + expect( + screen.getByTitle(enMessages.Folder.chat.messageInput.send) + ).toBeDisabled() + expect( + screen.queryByRole("button", { name: /Remove unreadable\.png/ }) + ).toBeNull() + expect(consoleError).toHaveBeenCalledWith( + "[MessageInput] drop image path failed (/outside/unreadable.png):", + expect.any(Error) + ) + consoleError.mockRestore() + }) + + it("routes a whole-file session image through the bounded reader", async () => { + readFileBase64Mock.mockResolvedValue("c2Vzc2lvbi1pbWFnZQ==") + renderInput({ attachmentTabId: "tab-image" }) + + act(() => { + emitAttachFileToSession({ + tabId: "tab-image", + path: "/outside/session.png", + }) + }) + + await waitFor(() => + expect(readFileBase64Mock).toHaveBeenCalledWith( + "/outside/session.png", + 20_000_000 + ) + ) + }) +}) + +describe("MessageInput remote desktop paths", () => { + afterEach(() => { + cleanup() + platformMock.desktop = false + transportMock.remoteId = null + tauriListenerMock.listeners.clear() + readLocalImagePathForAttachmentMock.mockReset() + readLocalPathForAttachmentMock.mockReset() + uploadLocalPathToRemoteMock.mockReset() + toastErrorMock.mockReset() + }) + + async function renderRemoteAndDrop( + paths: string[], + props: Partial> = {} + ) { + platformMock.desktop = true + transportMock.remoteId = "remote-1" + tauriListenerMock.listeners.clear() + + const rendered = renderInput(props) + const host = rendered.container.firstElementChild as HTMLElement | null + expect(host).not.toBeNull() + vi.spyOn(host!, "getBoundingClientRect").mockReturnValue({ + left: 0, + top: 0, + right: 100, + bottom: 100, + width: 100, + height: 100, + x: 0, + y: 0, + toJSON: () => ({}), + }) + await waitFor(() => + expect( + tauriListenerMock.listeners.get("tauri://drag-drop")?.length + ).toBeGreaterThan(0) + ) + + act(() => { + tauriListenerMock.listeners.get("tauri://drag-drop")?.at(-1)?.({ + payload: { + paths, + position: { x: 10, y: 10 }, + }, + }) + }) + return rendered + } + + it("keeps remote desktop images inline and uploads only resources", async () => { + const onSend = vi.fn() + readLocalImagePathForAttachmentMock.mockResolvedValue({ + fileName: "outside.png", + mimeType: "image/png", + size: 10_000_000, + dataBase64: "cmVtb3RlLWltYWdl", + }) + uploadLocalPathToRemoteMock.mockResolvedValue({ + path: "/uploads/notes.txt", + }) + + await renderRemoteAndDrop(["/outside/outside.png", "/outside/notes.txt"], { + onSend, + }) + + await waitFor(() => + expect(readLocalImagePathForAttachmentMock).toHaveBeenCalledWith( + "/outside/outside.png" + ) + ) + expect(readLocalPathForAttachmentMock).not.toHaveBeenCalled() + expect(uploadLocalPathToRemoteMock).toHaveBeenCalledTimes(1) + expect(uploadLocalPathToRemoteMock).toHaveBeenCalledWith( + "/outside/notes.txt", + null + ) + + const send = screen.getByTitle(enMessages.Folder.chat.messageInput.send) + await waitFor(() => expect(send).toBeEnabled()) + await userEvent.setup().click(send) + expect(onSend).toHaveBeenCalledWith( + expect.objectContaining({ + blocks: expect.arrayContaining([ + expect.objectContaining({ + type: "image", + mime_type: "image/png", + data: "cmVtb3RlLWltYWdl", + }), + expect.objectContaining({ + type: "text", + text: "[notes.txt](file:///uploads/notes.txt)", + }), + ]), + }), + null + ) + expect(send).toBeDisabled() + await userEvent.setup().click(send) + expect(onSend).toHaveBeenCalledOnce() + }) + + it("removes remote image bytes from the draft", async () => { + readLocalImagePathForAttachmentMock.mockResolvedValue({ + fileName: "outside.png", + mimeType: "image/png", + size: 6, + dataBase64: "cmVtb3RlLWltYWdl", + }) + + await renderRemoteAndDrop(["/outside/outside.png"]) + const remove = await screen.findByRole("button", { + name: "Remove outside.png", + }) + await userEvent.setup().click(remove) + + expect( + screen.getByTitle(enMessages.Folder.chat.messageInput.send) + ).toBeDisabled() + }) + + it("does not upload a remote image after its local read fails", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + readLocalImagePathForAttachmentMock.mockRejectedValue( + new Error("read denied") + ) + + await renderRemoteAndDrop(["/outside/unreadable.png"]) + await waitFor(() => + expect(readLocalImagePathForAttachmentMock).toHaveBeenCalledOnce() + ) + + expect(uploadLocalPathToRemoteMock).not.toHaveBeenCalled() + expect( + screen.getByTitle(enMessages.Folder.chat.messageInput.send) + ).toBeDisabled() + expect(consoleError).toHaveBeenCalledWith( + "[MessageInput] remote path upload failed (unreadable.png):", + expect.any(Error) + ) + consoleError.mockRestore() + }) + + it("shows separate 20 MB image and 2 MB resource limits", async () => { + const tooLarge = (limit: number) => ({ + code: "io_error", + message: "Local file exceeds the size limit", + i18n_key: "errors.upload.tooLarge", + i18n_params: { limit: String(limit) }, + }) + readLocalImagePathForAttachmentMock.mockRejectedValue(tooLarge(20_000_000)) + uploadLocalPathToRemoteMock.mockRejectedValue(tooLarge(2 * 1024 * 1024)) + + await renderRemoteAndDrop([ + "/outside/oversize.png", + "/outside/oversize.txt", + ]) + + await waitFor(() => expect(toastErrorMock).toHaveBeenCalledTimes(2)) + expect(toastErrorMock).toHaveBeenCalledWith( + "oversize.png exceeds the 20MB upload limit and was skipped." + ) + expect(toastErrorMock).toHaveBeenCalledWith( + "oversize.txt exceeds the 2MB upload limit and was skipped." + ) + }) +}) diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index a2c800728..04a1305bd 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -75,6 +75,7 @@ import { import { useShortcutSettings } from "@/hooks/use-shortcut-settings" import { readFileBase64, + readLocalImagePathForAttachment, quickMessagesList, uploadAttachment, uploadLocalPathToRemote, @@ -147,6 +148,11 @@ import { RichComposer, type RichComposerHandle, } from "@/components/chat/composer/rich-composer" +import { + mimeTypeFromPath, + partitionAttachmentFiles, + partitionAttachmentPaths, +} from "@/components/chat/attachment-routing" import { composerLeafText, docToPromptBlocks, @@ -249,45 +255,10 @@ interface MessageInputProps { onInjectConsumed?: () => void } -const MIME_BY_EXT: Record = { - txt: "text/plain", - md: "text/markdown", - json: "application/json", - yaml: "application/yaml", - yml: "application/yaml", - csv: "text/csv", - html: "text/html", - css: "text/css", - js: "text/javascript", - mjs: "text/javascript", - cjs: "text/javascript", - ts: "text/typescript", - tsx: "text/tsx", - jsx: "text/jsx", - py: "text/x-python", - rs: "text/rust", - go: "text/x-go", - java: "text/x-java-source", - xml: "application/xml", - toml: "application/toml", - pdf: "application/pdf", - png: "image/png", - jpg: "image/jpeg", - jpeg: "image/jpeg", - gif: "image/gif", - webp: "image/webp", - svg: "image/svg+xml", -} - function fileNameFromPath(path: string): string { return path.split(/[/\\]/).pop() || path } -function mimeTypeFromPath(path: string): string | null { - const ext = path.split(".").pop()?.toLowerCase() ?? "" - return MIME_BY_EXT[ext] ?? null -} - function hasDragFiles(dataTransfer: DataTransfer | null): boolean { if (!dataTransfer?.types) return false return Array.from(dataTransfer.types).includes("Files") @@ -372,7 +343,7 @@ const TEXT_LIKE_MIME_PREFIXES = [ "application/javascript", "application/typescript", ] -const DRAG_DROP_IMAGE_MAX_BYTES = 20_000_000 +const IMAGE_ATTACHMENT_MAX_BYTES = 20_000_000 function isTextLikeFile(file: File): boolean { const mime = file.type.toLowerCase() @@ -384,7 +355,7 @@ function isTextLikeFile(file: File): boolean { const ext = file.name.split(".").pop()?.toLowerCase() if (!ext) return false return Boolean( - MIME_BY_EXT[ext]?.startsWith("text/") || + mimeTypeFromPath(file.name)?.startsWith("text/") || ["json", "yaml", "yml", "xml", "toml", "md", "csv"].includes(ext) ) } @@ -1476,34 +1447,76 @@ export function MessageInput({ ] ) - const appendImageAttachments = useCallback(async (files: File[]) => { - if (files.length === 0) return - const parsed = await Promise.all( - files.map(async (file, index) => { - const mimeType = - file.type && file.type.startsWith("image/") - ? file.type - : (mimeTypeFromPath(file.name) ?? "image/png") - const base64Data = await blobToBase64(file) - return { - id: `image:${Date.now()}:${index}:${randomUUID()}`, - type: "image" as const, - data: base64Data, - uri: null, - name: file.name || `image-${Date.now()}-${index + 1}`, - mimeType, + const appendImageAttachments = useCallback( + async (files: File[]) => { + if (files.length === 0) return + const oversized = files.filter( + (file) => file.size > IMAGE_ATTACHMENT_MAX_BYTES + ) + const accepted = files.filter( + (file) => file.size <= IMAGE_ATTACHMENT_MAX_BYTES + ) + if (oversized.length > 0) { + toast.error( + tAttach("attachUploadTooLarge", { + limit: IMAGE_ATTACHMENT_MAX_BYTES / 1_000_000, + names: oversized.map((file) => file.name).join(", "), + }) + ) + } + if (accepted.length === 0) return + + const settled = await Promise.allSettled( + accepted.map(async (file, index) => { + const mimeType = + file.type && file.type.startsWith("image/") + ? file.type + : (mimeTypeFromPath(file.name) ?? "image/png") + const base64Data = await blobToBase64(file) + return { + id: `image:${Date.now()}:${index}:${randomUUID()}`, + type: "image" as const, + data: base64Data, + uri: null, + name: file.name || `image-${Date.now()}-${index + 1}`, + mimeType, + } + }) + ) + const parsed: ImageInputAttachment[] = [] + const failed: string[] = [] + settled.forEach((result, index) => { + if (result.status === "fulfilled") { + parsed.push(result.value) + return } + const name = accepted[index].name + failed.push(name) + console.error( + `[MessageInput] image attachment read failed (${name}):`, + result.reason + ) }) - ) - setAttachments((prev) => [...prev, ...parsed]) - }, []) + if (failed.length > 0) { + toast.error( + tAttach("attachUploadFailed", { + names: failed.join(", "), + }) + ) + } + if (parsed.length > 0) { + setAttachments((prev) => [...prev, ...parsed]) + } + }, + [tAttach] + ) const appendImagePathAttachments = useCallback( async (paths: string[]) => { if (paths.length === 0 || !canAttachImages) return const settled = await Promise.allSettled( paths.map(async (path, index) => { - const data = await readFileBase64(path, DRAG_DROP_IMAGE_MAX_BYTES) + const data = await readFileBase64(path, IMAGE_ATTACHMENT_MAX_BYTES) return { id: `image:${Date.now()}:${index}:${randomUUID()}`, type: "image" as const, @@ -1533,29 +1546,21 @@ export function MessageInput({ ) const appendPathsFromDrop = useCallback( - async (paths: string[]) => { + async (paths: string[], opts: { atCaret?: boolean } = {}) => { if (paths.length === 0) return const normalized = paths.filter( (path): path is string => typeof path === "string" && path.length > 0 ) if (normalized.length === 0) return - const imagePaths: string[] = [] - const resourcePaths: string[] = [] - for (const path of normalized) { - const mimeType = mimeTypeFromPath(path) ?? "" - if (canAttachImages && mimeType.startsWith("image/")) { - imagePaths.push(path) - } else { - resourcePaths.push(path) - } - } + const { images: imagePaths, resources: resourcePaths } = + partitionAttachmentPaths(normalized, canAttachImages) if (imagePaths.length > 0) { await appendImagePathAttachments(imagePaths) } if (resourcePaths.length > 0) { - appendResourceAttachments(resourcePaths) + appendResourceAttachments(resourcePaths, opts) } }, [appendImagePathAttachments, appendResourceAttachments, canAttachImages] @@ -1566,40 +1571,61 @@ export function MessageInput({ appendPathsFromDropRef.current = appendPathsFromDrop }, [appendPathsFromDrop]) - // Remote-workspace counterpart of `appendPathsFromDrop`. Reads each - // local path through Rust, ships the bytes via the upload proxy, then - // appends the resulting server-side paths as ResourceLinks. Failures - // (oversize, ENOENT, network) are reported in a single aggregated toast - // matching `uploadAndAppendFiles`. + // Remote-workspace counterpart of `appendPathsFromDrop`. Images stay as + // in-memory prompt blocks after the bounded local Rust read; resources use + // the existing upload proxy and become server-side ResourceLinks. const uploadPathsToRemote = useCallback( async (paths: string[]) => { const normalized = paths.filter( (p): p is string => typeof p === "string" && p.length > 0 ) if (normalized.length === 0) return - - const limitMb = Math.round(UPLOAD_MAX_BYTES / (1024 * 1024)) + const { images: imagePaths, resources: resourcePaths } = + partitionAttachmentPaths(normalized, canAttachImages) + const jobs = [ + ...imagePaths.map((path) => ({ path, image: true as const })), + ...resourcePaths.map((path) => ({ path, image: false as const })), + ] + + const uploadLimitMb = Math.round(UPLOAD_MAX_BYTES / (1024 * 1024)) const succeeded: string[] = [] + const parsedImages: ImageInputAttachment[] = [] const failed: Array<{ name: string; reason: unknown }> = [] - const oversize: string[] = [] + const oversizedImages: string[] = [] + const oversizedResources: string[] = [] const directories: string[] = [] const quotaRejected: string[] = [] const CONCURRENCY = 3 let cursor = 0 const workers = Array.from( - { length: Math.min(CONCURRENCY, normalized.length) }, + { length: Math.min(CONCURRENCY, jobs.length) }, async () => { - while (cursor < normalized.length) { + while (cursor < jobs.length) { const idx = cursor++ - const path = normalized[idx] + const { path, image } = jobs[idx] const name = path.split(/[/\\]/).pop() || path try { - const r = await uploadLocalPathToRemote( - path, - attachmentTabId ?? null - ) - succeeded.push(r.path) + if (image) { + const file = await readLocalImagePathForAttachment(path) + parsedImages.push({ + id: `image:${Date.now()}:${idx}:${randomUUID()}`, + type: "image", + data: file.dataBase64, + uri: buildFileUri(path), + name: file.fileName || name, + mimeType: + file.mimeType?.startsWith("image/") === true + ? file.mimeType + : (mimeTypeFromPath(path) ?? "image/png"), + }) + } else { + const r = await uploadLocalPathToRemote( + path, + attachmentTabId ?? null + ) + succeeded.push(r.path) + } } catch (error) { if (isEmptyAttachmentError(error)) { console.warn( @@ -1616,7 +1642,8 @@ export function MessageInput({ const appError = extractAppCommandError(error) const i18nKey = appError?.i18n_key ?? null if (i18nKey === UPLOAD_I18N_KEY_TOO_LARGE) { - oversize.push(name) + if (image) oversizedImages.push(name) + else oversizedResources.push(name) } else if (i18nKey === UPLOAD_I18N_KEY_NOT_A_FILE) { // Dragging a directory or a special file (FIFO, device // node) lands here. The Rust guard short-circuits before @@ -1634,11 +1661,19 @@ export function MessageInput({ ) await Promise.all(workers) - if (oversize.length > 0) { + if (oversizedImages.length > 0) { toast.error( tAttach("attachUploadTooLarge", { - limit: limitMb, - names: oversize.join(", "), + limit: IMAGE_ATTACHMENT_MAX_BYTES / 1_000_000, + names: oversizedImages.join(", "), + }) + ) + } + if (oversizedResources.length > 0) { + toast.error( + tAttach("attachUploadTooLarge", { + limit: uploadLimitMb, + names: oversizedResources.join(", "), }) ) } @@ -1672,8 +1707,11 @@ export function MessageInput({ if (succeeded.length > 0) { appendResourceAttachments(succeeded) } + if (parsedImages.length > 0) { + setAttachments((prev) => [...prev, ...parsedImages]) + } }, - [appendResourceAttachments, attachmentTabId, tAttach] + [appendResourceAttachments, attachmentTabId, canAttachImages, tAttach] ) const uploadPathsToRemoteRef = useRef(uploadPathsToRemote) @@ -1684,16 +1722,8 @@ export function MessageInput({ const appendFilesFromInput = useCallback( async (files: File[]) => { if (files.length === 0) return - const imageFiles: File[] = [] - const resourceFiles: File[] = [] - for (const file of files) { - const mimeType = file.type || mimeTypeFromPath(file.name) || "" - if (canAttachImages && mimeType.startsWith("image/")) { - imageFiles.push(file) - } else { - resourceFiles.push(file) - } - } + const { images: imageFiles, resources: resourceFiles } = + partitionAttachmentFiles(files, canAttachImages) if (imageFiles.length > 0) { await appendImageAttachments(imageFiles) @@ -2019,11 +2049,11 @@ export function MessageInput({ }) if (!selected) return const picked = Array.isArray(selected) ? selected : [selected] - appendResourceAttachments(picked.filter((item): item is string => !!item)) + await appendPathsFromDrop(picked.filter((item): item is string => !!item)) } catch (error) { console.error("[MessageInput] pick files failed:", error) } - }, [appendResourceAttachments, defaultPath, disabled]) + }, [appendPathsFromDrop, defaultPath, disabled]) const [serverFilePickerOpen, setServerFilePickerOpen] = useState(false) @@ -2031,25 +2061,25 @@ export function MessageInput({ if (disabled) return // Open a hidden to grab File objects (browsers and // Tauri webviews both produce blob-style File objects from this control, - // never raw OS paths), then upload each one — `uploadAttachment` picks - // the right transport (direct fetch in web mode, IPC-proxied multipart - // in remote-desktop mode). + // never raw OS paths), then route images inline and upload resources. const input = document.createElement("input") input.type = "file" input.multiple = true input.onchange = async () => { const all = input.files ? Array.from(input.files) : [] - await uploadAndAppendFiles(all) + await appendFilesFromInput(all) } input.click() - }, [disabled, uploadAndAppendFiles]) + }, [appendFilesFromInput, disabled]) const handleServerFilesSelected = useCallback( (paths: string[]) => { if (paths.length === 0) return - appendResourceAttachments(paths) + void appendPathsFromDrop(paths).catch((error) => { + console.error("[MessageInput] select server files failed:", error) + }) }, - [appendResourceAttachments] + [appendPathsFromDrop] ) const loadQuickMessages = useCallback(async () => { @@ -2227,7 +2257,9 @@ export function MessageInput({ if (range) { appendFileRangeAttachment(path, range, { atCaret: true }) } else { - appendResourceAttachments([path], { atCaret: true }) + void appendPathsFromDrop([path], { atCaret: true }).catch((error) => { + console.error("[MessageInput] attach session file failed:", error) + }) } } @@ -2235,7 +2267,7 @@ export function MessageInput({ return () => { window.removeEventListener(ATTACH_FILE_TO_SESSION_EVENT, handleAttachFile) } - }, [appendResourceAttachments, appendFileRangeAttachment, attachmentTabId]) + }, [appendPathsFromDrop, appendFileRangeAttachment, attachmentTabId]) useEffect(() => { if (!attachmentTabId) return diff --git a/src/lib/api-local-attachment.test.ts b/src/lib/api-local-attachment.test.ts new file mode 100644 index 000000000..22d02101e --- /dev/null +++ b/src/lib/api-local-attachment.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const shellCallMock = vi.hoisted(() => vi.fn()) + +vi.mock("./transport", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getActiveRemoteConnectionId: () => 7, + getShellTransport: () => ({ call: shellCallMock }), + } +}) + +import { + readLocalImagePathForAttachment, + readLocalPathForAttachment, +} from "./api" + +describe("local attachment path readers", () => { + beforeEach(() => { + shellCallMock.mockReset() + shellCallMock.mockResolvedValue({ + fileName: "attachment.bin", + mimeType: "application/octet-stream", + size: 3_000_000, + dataBase64: "AA==", + }) + }) + + it("uses the 20 MB Rust image reader for remote image paths", async () => { + await expect( + readLocalImagePathForAttachment("/outside/image.png") + ).resolves.toEqual(expect.objectContaining({ size: 3_000_000 })) + + expect(shellCallMock).toHaveBeenCalledWith( + "read_local_image_for_attachment", + { path: "/outside/image.png" } + ) + }) + + it("keeps ordinary remote uploads on the 2 MiB Rust reader", async () => { + await readLocalPathForAttachment("/outside/notes.txt") + + expect(shellCallMock).toHaveBeenCalledWith("read_local_file_for_upload", { + path: "/outside/notes.txt", + }) + }) +}) diff --git a/src/lib/api.ts b/src/lib/api.ts index 08c34cfbb..1c05ba3fc 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2483,12 +2483,50 @@ export async function uploadAttachment( return res.json() } +export interface LocalAttachmentFile { + fileName: string + mimeType: string | null + size: number + dataBase64: string +} + +async function readBoundedLocalAttachment( + path: string, + command: "read_local_file_for_upload" | "read_local_image_for_attachment" +): Promise { + if (getActiveRemoteConnectionId() === null) { + throw new Error( + "Reading a local attachment requires an active remote workspace" + ) + } + const shell = getShellTransport() + const file = await shell.call(command, { path }) + if (file.size === 0) { + throw new EmptyAttachmentError(file.fileName) + } + return file +} + +// Ordinary remote uploads stay on the 2 MiB Rust-enforced reader. +export async function readLocalPathForAttachment( + path: string +): Promise { + return readBoundedLocalAttachment(path, "read_local_file_for_upload") +} + +// Remote image paths use the separate 20,000,000-byte Rust-enforced reader. +export async function readLocalImagePathForAttachment( + path: string +): Promise { + return readBoundedLocalAttachment(path, "read_local_image_for_attachment") +} + // Upload a file picked from the desktop machine's filesystem to the remote // codeg-server bound to the current window. The Tauri-native drag-drop event // hands us OS paths (not `File` objects), so we read the bytes via Rust, // then reuse the same `remote_upload_attachment` channel. Only callable from // a window that has a remote workspace attached; non-remote callers should -// continue to use `appendResourceAttachments` with the local path directly. +// continue to use local path references directly. export async function uploadLocalPathToRemote( path: string, sessionId?: string | null @@ -2499,19 +2537,8 @@ export async function uploadLocalPathToRemote( "uploadLocalPathToRemote requires an active remote workspace" ) } + const file = await readLocalPathForAttachment(path) const shell = getShellTransport() - const file = await shell.call<{ - fileName: string - mimeType: string | null - size: number - dataBase64: string - }>("read_local_file_for_upload", { path }) - if (file.size === 0) { - // Mirror the `uploadAttachment` empty-file guard. The Rust side - // already read the bytes, so we've paid the cost — drop on the - // floor here rather than send a zero-byte multipart upstream. - throw new EmptyAttachmentError(file.fileName) - } return shell.call("remote_upload_attachment", { connectionId: remoteId, fileName: file.fileName,