From 32524ebd4c0249cc29fcc5011e5cd05385ae776d Mon Sep 17 00:00:00 2001 From: Darshan Poudel Date: Fri, 29 May 2026 00:12:24 +0545 Subject: [PATCH] feat(chat): add image attachment support to composer (#2676) Co-authored-by: Srinivas Vaddi <38348871+vaddisrinivas@users.noreply.github.com> Co-authored-by: Steven Enamakel Co-authored-by: Ghost Scripter Co-authored-by: sanil-23 Co-authored-by: Claude --- app/src/chat/chatSendError.ts | 3 +- app/src/components/chat/AttachmentPreview.tsx | 55 +++ .../chat/__tests__/AttachmentPreview.test.tsx | 63 +++ app/src/lib/attachments.test.ts | 188 ++++++++ app/src/lib/attachments.ts | 115 +++++ app/src/lib/i18n/chunks/ar-2.ts | 6 + app/src/lib/i18n/chunks/bn-2.ts | 6 + app/src/lib/i18n/chunks/de-2.ts | 6 + app/src/lib/i18n/chunks/en-2.ts | 6 + app/src/lib/i18n/chunks/es-2.ts | 6 + app/src/lib/i18n/chunks/fr-2.ts | 6 + app/src/lib/i18n/chunks/hi-2.ts | 6 + app/src/lib/i18n/chunks/id-2.ts | 6 + app/src/lib/i18n/chunks/it-2.ts | 6 + app/src/lib/i18n/chunks/ko-2.ts | 6 + app/src/lib/i18n/chunks/pt-2.ts | 6 + app/src/lib/i18n/chunks/ru-2.ts | 6 + app/src/lib/i18n/chunks/zh-CN-2.ts | 6 + app/src/lib/i18n/en.ts | 6 + app/src/pages/Conversations.tsx | 219 ++++++++-- .../Conversations.attachments.test.tsx | 402 ++++++++++++++++++ src/openhuman/tools/generated.rs | 393 +++++++++++++++++ 22 files changed, 1485 insertions(+), 37 deletions(-) create mode 100644 app/src/components/chat/AttachmentPreview.tsx create mode 100644 app/src/components/chat/__tests__/AttachmentPreview.test.tsx create mode 100644 app/src/lib/attachments.test.ts create mode 100644 app/src/lib/attachments.ts create mode 100644 app/src/pages/__tests__/Conversations.attachments.test.tsx diff --git a/app/src/chat/chatSendError.ts b/app/src/chat/chatSendError.ts index 1c1b5bec8..96b1bd390 100644 --- a/app/src/chat/chatSendError.ts +++ b/app/src/chat/chatSendError.ts @@ -15,7 +15,8 @@ export type ChatSendErrorCode = | 'safety_timeout' | 'usage_limit_reached' | 'prompt_blocked' - | 'prompt_review'; + | 'prompt_review' + | 'attachment_invalid'; export interface ChatSendError { code: ChatSendErrorCode; diff --git a/app/src/components/chat/AttachmentPreview.tsx b/app/src/components/chat/AttachmentPreview.tsx new file mode 100644 index 000000000..969bffff6 --- /dev/null +++ b/app/src/components/chat/AttachmentPreview.tsx @@ -0,0 +1,55 @@ +import { type Attachment, formatFileSize } from '../../lib/attachments'; +import { useT } from '../../lib/i18n/I18nContext'; + +interface AttachmentPreviewProps { + attachments: Attachment[]; + onRemove: (id: string) => void; + disabled?: boolean; +} + +export default function AttachmentPreview({ + attachments, + onRemove, + disabled, +}: AttachmentPreviewProps) { + const { t } = useT(); + + if (attachments.length === 0) return null; + + return ( +
+ {attachments.map(attachment => ( +
+ {attachment.file.name} +
+ {attachment.file.name} + + {formatFileSize(attachment.file.size)} + +
+ +
+ ))} +
+ ); +} diff --git a/app/src/components/chat/__tests__/AttachmentPreview.test.tsx b/app/src/components/chat/__tests__/AttachmentPreview.test.tsx new file mode 100644 index 000000000..61c3fc100 --- /dev/null +++ b/app/src/components/chat/__tests__/AttachmentPreview.test.tsx @@ -0,0 +1,63 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { Attachment } from '../../../lib/attachments'; +import AttachmentPreview from '../AttachmentPreview'; + +function makeAttachment(overrides: Partial = {}): Attachment { + const blob = new Blob([new Uint8Array(512)], { type: 'image/png' }); + return { + id: 'att-1', + file: new File([blob], 'test.png', { type: 'image/png' }), + dataUri: 'data:image/png;base64,abc', + mimeType: 'image/png', + ...overrides, + }; +} + +describe('AttachmentPreview', () => { + it('renders nothing when attachments list is empty', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders a chip with filename and file size for each attachment', () => { + const att = makeAttachment(); + render(); + expect(screen.getByText('test.png')).toBeInTheDocument(); + expect(screen.getByText('512 B')).toBeInTheDocument(); + }); + + it('renders a thumbnail image with the dataUri as src', () => { + const att = makeAttachment({ dataUri: 'data:image/png;base64,xyz' }); + render(); + const img = screen.getByAltText('test.png') as HTMLImageElement; + expect(img.src).toBe('data:image/png;base64,xyz'); + }); + + it('calls onRemove with the attachment id when × is clicked', () => { + const onRemove = vi.fn(); + const att = makeAttachment({ id: 'att-42' }); + render(); + fireEvent.click(screen.getByRole('button', { name: /remove test\.png/i })); + expect(onRemove).toHaveBeenCalledWith('att-42'); + }); + + it('disables the remove button when disabled prop is true', () => { + const att = makeAttachment(); + render(); + expect(screen.getByRole('button', { name: /remove test\.png/i })).toBeDisabled(); + }); + + it('renders multiple chips', () => { + const a1 = makeAttachment({ id: '1', file: new File([], 'a.png', { type: 'image/png' }) }); + const a2 = makeAttachment({ + id: '2', + file: new File([], 'b.jpg', { type: 'image/jpeg' }), + mimeType: 'image/jpeg', + }); + render(); + expect(screen.getByText('a.png')).toBeInTheDocument(); + expect(screen.getByText('b.jpg')).toBeInTheDocument(); + }); +}); diff --git a/app/src/lib/attachments.test.ts b/app/src/lib/attachments.test.ts new file mode 100644 index 000000000..c16870722 --- /dev/null +++ b/app/src/lib/attachments.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from 'vitest'; + +import { + type Attachment, + ATTACHMENT_MAX_IMAGES, + ATTACHMENT_MAX_SIZE_BYTES, + buildMessageWithAttachments, + fileToDataUri, + formatFileSize, + isAllowedMimeType, + parseMessageImages, + validateAndReadFile, +} from './attachments'; + +function makeFile(name: string, type: string, size = 1024): File { + const blob = new Blob([new Uint8Array(size)], { type }); + return new File([blob], name, { type }); +} + +function makeAttachment(overrides: Partial = {}): Attachment { + return { + id: 'test-id', + file: makeFile('test.png', 'image/png'), + dataUri: 'data:image/png;base64,abc', + mimeType: 'image/png', + ...overrides, + }; +} + +describe('isAllowedMimeType', () => { + it('allows supported image types', () => { + expect(isAllowedMimeType('image/png')).toBe(true); + expect(isAllowedMimeType('image/jpeg')).toBe(true); + expect(isAllowedMimeType('image/webp')).toBe(true); + expect(isAllowedMimeType('image/gif')).toBe(true); + expect(isAllowedMimeType('image/bmp')).toBe(true); + }); + + it('rejects unsupported types', () => { + expect(isAllowedMimeType('application/pdf')).toBe(false); + expect(isAllowedMimeType('text/plain')).toBe(false); + expect(isAllowedMimeType('image/svg+xml')).toBe(false); + expect(isAllowedMimeType('')).toBe(false); + }); +}); + +describe('fileToDataUri', () => { + it('reads a file as a data URI', async () => { + const file = makeFile('photo.png', 'image/png', 4); + const uri = await fileToDataUri(file); + expect(uri).toMatch(/^data:image\/png;base64,/); + }); +}); + +describe('validateAndReadFile', () => { + it('rejects when at max image count', async () => { + const file = makeFile('x.png', 'image/png'); + const result = await validateAndReadFile(file, ATTACHMENT_MAX_IMAGES); + expect('error' in result).toBe(true); + if ('error' in result) { + expect(result.error.code).toBe('too_many'); + } + }); + + it('rejects unsupported MIME types', async () => { + const file = makeFile('doc.pdf', 'application/pdf'); + const result = await validateAndReadFile(file, 0); + expect('error' in result).toBe(true); + if ('error' in result) { + expect(result.error.code).toBe('unsupported_type'); + } + }); + + it('rejects files that exceed the size limit', async () => { + const oversizedFile = makeFile('big.png', 'image/png', ATTACHMENT_MAX_SIZE_BYTES + 1); + const result = await validateAndReadFile(oversizedFile, 0); + expect('error' in result).toBe(true); + if ('error' in result) { + expect(result.error.code).toBe('too_large'); + } + }); + + it('returns an attachment for a valid image', async () => { + const file = makeFile('ok.png', 'image/png', 512); + const result = await validateAndReadFile(file, 0); + expect('attachment' in result).toBe(true); + if ('attachment' in result) { + expect(result.attachment.mimeType).toBe('image/png'); + expect(result.attachment.dataUri).toMatch(/^data:image\/png;base64,/); + expect(result.attachment.file).toBe(file); + } + }); + + it('returns read_failed when FileReader errors', async () => { + const file = makeFile('bad.png', 'image/png', 1); + const origFileReader = globalThis.FileReader; + class FailingReader { + onload: (() => void) | null = null; + onerror: ((e: unknown) => void) | null = null; + readAsDataURL() { + setTimeout(() => this.onerror?.(new Error('read error')), 0); + } + } + globalThis.FileReader = FailingReader as unknown as typeof FileReader; + try { + const result = await validateAndReadFile(file, 0); + expect('error' in result).toBe(true); + if ('error' in result) { + expect(result.error.code).toBe('read_failed'); + } + } finally { + globalThis.FileReader = origFileReader; + } + }); +}); + +describe('buildMessageWithAttachments', () => { + it('returns text unchanged when no attachments', () => { + expect(buildMessageWithAttachments('hello', [])).toBe('hello'); + }); + + it('appends IMAGE markers after the text', () => { + const a = makeAttachment({ dataUri: 'data:image/png;base64,abc' }); + const result = buildMessageWithAttachments('describe this', [a]); + expect(result).toBe('describe this [IMAGE:data:image/png;base64,abc]'); + }); + + it('emits only markers when text is empty', () => { + const a = makeAttachment({ dataUri: 'data:image/png;base64,abc' }); + const result = buildMessageWithAttachments('', [a]); + expect(result).toBe('[IMAGE:data:image/png;base64,abc]'); + }); + + it('handles multiple attachments', () => { + const a1 = makeAttachment({ id: '1', dataUri: 'data:image/png;base64,a' }); + const a2 = makeAttachment({ id: '2', dataUri: 'data:image/jpeg;base64,b' }); + const result = buildMessageWithAttachments('look', [a1, a2]); + expect(result).toBe('look [IMAGE:data:image/png;base64,a] [IMAGE:data:image/jpeg;base64,b]'); + }); + + it('trims leading/trailing whitespace from text', () => { + const a = makeAttachment({ dataUri: 'data:image/png;base64,x' }); + const result = buildMessageWithAttachments(' hi ', [a]); + expect(result).toBe('hi [IMAGE:data:image/png;base64,x]'); + }); +}); + +describe('parseMessageImages', () => { + it('returns empty dataUris and original text when no markers', () => { + const result = parseMessageImages('hello world'); + expect(result.text).toBe('hello world'); + expect(result.dataUris).toEqual([]); + }); + + it('extracts a single image marker and strips it from text', () => { + const result = parseMessageImages('describe this [IMAGE:data:image/png;base64,abc]'); + expect(result.text).toBe('describe this'); + expect(result.dataUris).toEqual(['data:image/png;base64,abc']); + }); + + it('extracts multiple image markers', () => { + const result = parseMessageImages( + '[IMAGE:data:image/png;base64,a] [IMAGE:data:image/jpeg;base64,b]' + ); + expect(result.text).toBe(''); + expect(result.dataUris).toEqual(['data:image/png;base64,a', 'data:image/jpeg;base64,b']); + }); + + it('returns empty text when message is marker-only', () => { + const result = parseMessageImages('[IMAGE:data:image/png;base64,xyz]'); + expect(result.text).toBe(''); + expect(result.dataUris).toHaveLength(1); + }); +}); + +describe('formatFileSize', () => { + it('formats bytes', () => { + expect(formatFileSize(500)).toBe('500 B'); + }); + + it('formats kilobytes', () => { + expect(formatFileSize(1536)).toBe('1.5 KB'); + }); + + it('formats megabytes', () => { + expect(formatFileSize(2 * 1024 * 1024)).toBe('2.0 MB'); + }); +}); diff --git a/app/src/lib/attachments.ts b/app/src/lib/attachments.ts new file mode 100644 index 000000000..d8ace8d55 --- /dev/null +++ b/app/src/lib/attachments.ts @@ -0,0 +1,115 @@ +/** + * Utilities for multimodal chat attachments. + * + * Images are embedded in the message text as `[IMAGE:]` markers, + * which the Rust agent harness (`agent/multimodal.rs`) parses and forwards to + * the inference provider. The backend supports up to 4 images per message at + * 8 MB each by default (governed by `MultimodalConfig`). + */ + +export const ATTACHMENT_MAX_IMAGES = 4; +export const ATTACHMENT_MAX_SIZE_BYTES = 8 * 1024 * 1024; // 8 MB + +export const ALLOWED_IMAGE_MIME_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/gif', + 'image/bmp', +] as const; + +export type AllowedImageMimeType = (typeof ALLOWED_IMAGE_MIME_TYPES)[number]; + +export interface Attachment { + id: string; + file: File; + dataUri: string; + mimeType: AllowedImageMimeType; +} + +export type AttachmentError = + | { code: 'unsupported_type'; mimeType: string } + | { code: 'too_large'; sizeBytes: number; maxBytes: number } + | { code: 'too_many'; max: number } + | { code: 'read_failed'; reason: string }; + +export function isAllowedMimeType(mime: string): mime is AllowedImageMimeType { + return (ALLOWED_IMAGE_MIME_TYPES as readonly string[]).includes(mime); +} + +export function fileToDataUri(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error(`Failed to read file: ${file.name}`)); + reader.readAsDataURL(file); + }); +} + +export async function validateAndReadFile( + file: File, + existingCount: number +): Promise<{ attachment: Attachment } | { error: AttachmentError }> { + if (existingCount >= ATTACHMENT_MAX_IMAGES) { + return { error: { code: 'too_many', max: ATTACHMENT_MAX_IMAGES } }; + } + + if (!isAllowedMimeType(file.type)) { + return { error: { code: 'unsupported_type', mimeType: file.type || 'unknown' } }; + } + + if (file.size > ATTACHMENT_MAX_SIZE_BYTES) { + return { + error: { code: 'too_large', sizeBytes: file.size, maxBytes: ATTACHMENT_MAX_SIZE_BYTES }, + }; + } + + try { + const dataUri = await fileToDataUri(file); + return { + attachment: { + id: globalThis.crypto.randomUUID(), + file, + dataUri, + mimeType: file.type as AllowedImageMimeType, + }, + }; + } catch (err) { + return { + error: { code: 'read_failed', reason: err instanceof Error ? err.message : String(err) }, + }; + } +} + +/** + * Compose the final message string by appending `[IMAGE:]` markers + * for each attachment after the user's text. The Rust backend parses these + * markers in `parse_image_markers` and strips them from the visible message + * before forwarding clean text + image payload to the inference provider. + */ +export function buildMessageWithAttachments(text: string, attachments: Attachment[]): string { + if (attachments.length === 0) return text; + const markers = attachments.map(a => `[IMAGE:${a.dataUri}]`).join(' '); + return text.trim() ? `${text.trim()} ${markers}` : markers; +} + +/** + * Parse `[IMAGE:]` markers out of a stored message string. + * Returns the clean text (markers removed) and the list of data URIs found. + */ +export function parseMessageImages(content: string): { text: string; dataUris: string[] } { + const dataUris: string[] = []; + const text = content + .replace(/\[IMAGE:([^\]]+)\]/g, (_match, uri: string) => { + dataUris.push(uri); + return ''; + }) + .trim(); + return { text, dataUris }; +} + +export function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/app/src/lib/i18n/chunks/ar-2.ts b/app/src/lib/i18n/chunks/ar-2.ts index 1a9093407..cf8abe4bb 100644 --- a/app/src/lib/i18n/chunks/ar-2.ts +++ b/app/src/lib/i18n/chunks/ar-2.ts @@ -303,6 +303,12 @@ const ar2: TranslationMap = { 'chat.turn': 'دورة', 'chat.turns': 'دورات', 'chat.openWorkerThread': 'فتح محادثة العامل', + 'chat.attachment.attach': 'Attach image', + 'chat.attachment.remove': 'Remove {name}', + 'chat.attachment.tooMany': 'Maximum {max} images per message', + 'chat.attachment.tooLarge': 'Image exceeds {max} size limit', + 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.', + 'chat.attachment.readFailed': 'Could not read file', 'memory.searchAria': 'البحث في الذاكرة', 'memory.searchPlaceholder': 'البحث في إدخالات الذاكرة...', 'memory.sourceFilter.all': 'جميع المصادر', diff --git a/app/src/lib/i18n/chunks/bn-2.ts b/app/src/lib/i18n/chunks/bn-2.ts index cff1106f8..faf4d12d1 100644 --- a/app/src/lib/i18n/chunks/bn-2.ts +++ b/app/src/lib/i18n/chunks/bn-2.ts @@ -313,6 +313,12 @@ const bn2: TranslationMap = { 'chat.turn': 'টার্ন', 'chat.turns': 'টার্ন', 'chat.openWorkerThread': 'ওয়ার্কার থ্রেড খুলুন', + 'chat.attachment.attach': 'Attach image', + 'chat.attachment.remove': 'Remove {name}', + 'chat.attachment.tooMany': 'Maximum {max} images per message', + 'chat.attachment.tooLarge': 'Image exceeds {max} size limit', + 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.', + 'chat.attachment.readFailed': 'Could not read file', 'memory.searchAria': 'মেমোরি খুঁজুন', 'memory.searchPlaceholder': 'মেমোরি এন্ট্রি খুঁজুন...', 'memory.sourceFilter.all': 'সব উৎস', diff --git a/app/src/lib/i18n/chunks/de-2.ts b/app/src/lib/i18n/chunks/de-2.ts index b56e70c15..7571f1283 100644 --- a/app/src/lib/i18n/chunks/de-2.ts +++ b/app/src/lib/i18n/chunks/de-2.ts @@ -319,6 +319,12 @@ const de2: TranslationMap = { 'chat.turn': 'drehen', 'chat.turns': 'dreht sich', 'chat.openWorkerThread': 'Arbeitsthread öffnen', + 'chat.attachment.attach': 'Attach image', + 'chat.attachment.remove': 'Remove {name}', + 'chat.attachment.tooMany': 'Maximum {max} images per message', + 'chat.attachment.tooLarge': 'Image exceeds {max} size limit', + 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.', + 'chat.attachment.readFailed': 'Could not read file', 'memory.searchAria': 'Speicher durchsuchen', 'memory.searchPlaceholder': 'Speichereinträge durchsuchen...', 'memory.sourceFilter.all': 'Alle Quellen', diff --git a/app/src/lib/i18n/chunks/en-2.ts b/app/src/lib/i18n/chunks/en-2.ts index 7339fd849..934997367 100644 --- a/app/src/lib/i18n/chunks/en-2.ts +++ b/app/src/lib/i18n/chunks/en-2.ts @@ -309,6 +309,12 @@ const en2: TranslationMap = { 'chat.turn': 'turn', 'chat.turns': 'turns', 'chat.openWorkerThread': 'Open worker thread', + 'chat.attachment.attach': 'Attach image', + 'chat.attachment.remove': 'Remove {name}', + 'chat.attachment.tooMany': 'Maximum {max} images per message', + 'chat.attachment.tooLarge': 'Image exceeds {max} size limit', + 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.', + 'chat.attachment.readFailed': 'Could not read file', 'memory.searchAria': 'Search memory', 'memory.searchPlaceholder': 'Search memory entries...', 'memory.sourceFilter.all': 'All sources', diff --git a/app/src/lib/i18n/chunks/es-2.ts b/app/src/lib/i18n/chunks/es-2.ts index 8f8f1eff9..0458b62ce 100644 --- a/app/src/lib/i18n/chunks/es-2.ts +++ b/app/src/lib/i18n/chunks/es-2.ts @@ -316,6 +316,12 @@ const es2: TranslationMap = { 'chat.turn': 'turno', 'chat.turns': 'turnos', 'chat.openWorkerThread': 'Abrir hilo de worker', + 'chat.attachment.attach': 'Attach image', + 'chat.attachment.remove': 'Remove {name}', + 'chat.attachment.tooMany': 'Maximum {max} images per message', + 'chat.attachment.tooLarge': 'Image exceeds {max} size limit', + 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.', + 'chat.attachment.readFailed': 'Could not read file', 'memory.searchAria': 'Buscar en memoria', 'memory.searchPlaceholder': 'Buscar entradas de memoria...', 'memory.sourceFilter.all': 'Todas las fuentes', diff --git a/app/src/lib/i18n/chunks/fr-2.ts b/app/src/lib/i18n/chunks/fr-2.ts index 2b35ff7b9..e5d525f85 100644 --- a/app/src/lib/i18n/chunks/fr-2.ts +++ b/app/src/lib/i18n/chunks/fr-2.ts @@ -318,6 +318,12 @@ const fr2: TranslationMap = { 'chat.turn': 'tour', 'chat.turns': 'tours', 'chat.openWorkerThread': 'Ouvrir le fil worker', + 'chat.attachment.attach': 'Attach image', + 'chat.attachment.remove': 'Remove {name}', + 'chat.attachment.tooMany': 'Maximum {max} images per message', + 'chat.attachment.tooLarge': 'Image exceeds {max} size limit', + 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.', + 'chat.attachment.readFailed': 'Could not read file', 'memory.searchAria': 'Rechercher dans la mémoire', 'memory.searchPlaceholder': 'Rechercher des entrées de mémoire…', 'memory.sourceFilter.all': 'Toutes les sources', diff --git a/app/src/lib/i18n/chunks/hi-2.ts b/app/src/lib/i18n/chunks/hi-2.ts index 7f1d800f9..a87a9c101 100644 --- a/app/src/lib/i18n/chunks/hi-2.ts +++ b/app/src/lib/i18n/chunks/hi-2.ts @@ -310,6 +310,12 @@ const hi2: TranslationMap = { 'chat.turn': 'टर्न', 'chat.turns': 'टर्न्स', 'chat.openWorkerThread': 'वर्कर थ्रेड खोलें', + 'chat.attachment.attach': 'Attach image', + 'chat.attachment.remove': 'Remove {name}', + 'chat.attachment.tooMany': 'Maximum {max} images per message', + 'chat.attachment.tooLarge': 'Image exceeds {max} size limit', + 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.', + 'chat.attachment.readFailed': 'Could not read file', 'memory.searchAria': 'मेमोरी सर्च करें', 'memory.searchPlaceholder': 'मेमोरी एंट्रीज़ सर्च करें...', 'memory.sourceFilter.all': 'सभी सोर्स', diff --git a/app/src/lib/i18n/chunks/id-2.ts b/app/src/lib/i18n/chunks/id-2.ts index fc96dc229..5324d2fe8 100644 --- a/app/src/lib/i18n/chunks/id-2.ts +++ b/app/src/lib/i18n/chunks/id-2.ts @@ -310,6 +310,12 @@ const id2: TranslationMap = { 'chat.turn': 'giliran', 'chat.turns': 'giliran', 'chat.openWorkerThread': 'Buka thread worker', + 'chat.attachment.attach': 'Attach image', + 'chat.attachment.remove': 'Remove {name}', + 'chat.attachment.tooMany': 'Maximum {max} images per message', + 'chat.attachment.tooLarge': 'Image exceeds {max} size limit', + 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.', + 'chat.attachment.readFailed': 'Could not read file', 'memory.searchAria': 'Cari memori', 'memory.searchPlaceholder': 'Cari entri memori...', 'memory.sourceFilter.all': 'Semua sumber', diff --git a/app/src/lib/i18n/chunks/it-2.ts b/app/src/lib/i18n/chunks/it-2.ts index dae8dcd8e..fff3dedb7 100644 --- a/app/src/lib/i18n/chunks/it-2.ts +++ b/app/src/lib/i18n/chunks/it-2.ts @@ -313,6 +313,12 @@ const it2: TranslationMap = { 'chat.turn': 'turno', 'chat.turns': 'turni', 'chat.openWorkerThread': 'Apri thread worker', + 'chat.attachment.attach': 'Attach image', + 'chat.attachment.remove': 'Remove {name}', + 'chat.attachment.tooMany': 'Maximum {max} images per message', + 'chat.attachment.tooLarge': 'Image exceeds {max} size limit', + 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.', + 'chat.attachment.readFailed': 'Could not read file', 'memory.searchAria': 'Cerca memoria', 'memory.searchPlaceholder': 'Cerca voci di memoria...', 'memory.sourceFilter.all': 'Tutte le origini', diff --git a/app/src/lib/i18n/chunks/ko-2.ts b/app/src/lib/i18n/chunks/ko-2.ts index a56c0e13b..317f3ec10 100644 --- a/app/src/lib/i18n/chunks/ko-2.ts +++ b/app/src/lib/i18n/chunks/ko-2.ts @@ -294,6 +294,12 @@ const ko2: TranslationMap = { 'chat.turn': '턴', 'chat.turns': '턴', 'chat.openWorkerThread': '워커 스레드 열기', + 'chat.attachment.attach': 'Attach image', + 'chat.attachment.remove': 'Remove {name}', + 'chat.attachment.tooMany': 'Maximum {max} images per message', + 'chat.attachment.tooLarge': 'Image exceeds {max} size limit', + 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.', + 'chat.attachment.readFailed': 'Could not read file', 'memory.searchAria': '메모리 검색', 'memory.searchPlaceholder': '메모리 항목 검색...', 'memory.sourceFilter.all': '모든 소스', diff --git a/app/src/lib/i18n/chunks/pt-2.ts b/app/src/lib/i18n/chunks/pt-2.ts index 5681471c0..8646f2cb9 100644 --- a/app/src/lib/i18n/chunks/pt-2.ts +++ b/app/src/lib/i18n/chunks/pt-2.ts @@ -316,6 +316,12 @@ const pt2: TranslationMap = { 'chat.turn': 'turno', 'chat.turns': 'turnos', 'chat.openWorkerThread': 'Abrir thread de worker', + 'chat.attachment.attach': 'Attach image', + 'chat.attachment.remove': 'Remove {name}', + 'chat.attachment.tooMany': 'Maximum {max} images per message', + 'chat.attachment.tooLarge': 'Image exceeds {max} size limit', + 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.', + 'chat.attachment.readFailed': 'Could not read file', 'memory.searchAria': 'Pesquisar memória', 'memory.searchPlaceholder': 'Pesquisar entradas de memória...', 'memory.sourceFilter.all': 'Todas as fontes', diff --git a/app/src/lib/i18n/chunks/ru-2.ts b/app/src/lib/i18n/chunks/ru-2.ts index 575b2a15d..a38e02c4b 100644 --- a/app/src/lib/i18n/chunks/ru-2.ts +++ b/app/src/lib/i18n/chunks/ru-2.ts @@ -312,6 +312,12 @@ const ru2: TranslationMap = { 'chat.turn': 'ход', 'chat.turns': 'ходов', 'chat.openWorkerThread': 'Открыть чат воркера', + 'chat.attachment.attach': 'Attach image', + 'chat.attachment.remove': 'Remove {name}', + 'chat.attachment.tooMany': 'Maximum {max} images per message', + 'chat.attachment.tooLarge': 'Image exceeds {max} size limit', + 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.', + 'chat.attachment.readFailed': 'Could not read file', 'memory.searchAria': 'Поиск в памяти', 'memory.searchPlaceholder': 'Поиск записей памяти...', 'memory.sourceFilter.all': 'Все источники', diff --git a/app/src/lib/i18n/chunks/zh-CN-2.ts b/app/src/lib/i18n/chunks/zh-CN-2.ts index 3b7938aa9..41cde91da 100644 --- a/app/src/lib/i18n/chunks/zh-CN-2.ts +++ b/app/src/lib/i18n/chunks/zh-CN-2.ts @@ -293,6 +293,12 @@ const zhCN2: TranslationMap = { 'chat.turn': '轮', 'chat.turns': '轮', 'chat.openWorkerThread': '打开工作线程', + 'chat.attachment.attach': 'Attach image', + 'chat.attachment.remove': 'Remove {name}', + 'chat.attachment.tooMany': 'Maximum {max} images per message', + 'chat.attachment.tooLarge': 'Image exceeds {max} size limit', + 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.', + 'chat.attachment.readFailed': 'Could not read file', 'memory.searchAria': '搜索记忆', 'memory.searchPlaceholder': '搜索记忆条目...', 'memory.sourceFilter.all': '所有来源', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index f9448e073..a37792c68 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1605,6 +1605,12 @@ const en: TranslationMap = { 'chat.turn': 'turn', 'chat.turns': 'turns', 'chat.openWorkerThread': 'Open worker thread', + 'chat.attachment.attach': 'Attach image', + 'chat.attachment.remove': 'Remove {name}', + 'chat.attachment.tooMany': 'Maximum {max} images per message', + 'chat.attachment.tooLarge': 'Image exceeds {max} size limit', + 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.', + 'chat.attachment.readFailed': 'Could not read file', // Memory (additional) 'memory.searchAria': 'Search memory', diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index 4b7dfed9c..7ff61b9c2 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -6,6 +6,7 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { type ChatSendError, chatSendError } from '../chat/chatSendError'; import { checkPromptInjection, promptGuardMessage } from '../chat/promptInjectionGuard'; import ApprovalRequestCard from '../components/chat/ApprovalRequestCard'; +import AttachmentPreview from '../components/chat/AttachmentPreview'; import TokenUsagePill from '../components/chat/TokenUsagePill'; import { ConfirmationModal } from '../components/intelligence/ConfirmationModal'; import PillTabBar from '../components/PillTabBar'; @@ -14,6 +15,15 @@ import { dismissBanner, shouldShowBanner } from '../components/upsell/upsellDism import MicComposer from '../features/human/MicComposer'; import { useStickToBottom } from '../hooks/useStickToBottom'; import { useUsageState } from '../hooks/useUsageState'; +import { + ALLOWED_IMAGE_MIME_TYPES, + type Attachment, + ATTACHMENT_MAX_IMAGES, + ATTACHMENT_MAX_SIZE_BYTES, + buildMessageWithAttachments, + parseMessageImages, + validateAndReadFile, +} from '../lib/attachments'; import { useT } from '../lib/i18n/I18nContext'; import { trackEvent } from '../services/analytics'; import { threadApi } from '../services/api/threadApi'; @@ -186,6 +196,8 @@ const Conversations = ({ const [showSidebar, setShowSidebar] = useState(false); const [inputValue, setInputValue] = useState(''); + const [attachments, setAttachments] = useState([]); + const fileInputRef = useRef(null); const [copiedMessageId, setCopiedMessageId] = useState(null); const [inputMode, setInputMode] = useState('text'); const [replyMode, setReplyMode] = useState('text'); @@ -196,6 +208,7 @@ const Conversations = ({ const [selectedLabel, setSelectedLabel] = useState('all'); const [inlineSuggestionValue, setInlineSuggestionValue] = useState(''); const [sendError, setSendError] = useState(null); + const [attachError, setAttachError] = useState(null); const [sendAdvisory, setSendAdvisory] = useState(null); const [pendingSendingThreadId, setPendingSendingThreadId] = useState(null); const [profileDraftOpen, setProfileDraftOpen] = useState(false); @@ -604,6 +617,40 @@ const Conversations = ({ return true; }; + const handleAttachFiles = async (files: FileList | null) => { + if (!files) return; + let acceptedCount = attachments.length; + for (const file of Array.from(files)) { + const result = await validateAndReadFile(file, acceptedCount); + if ('error' in result) { + const { error } = result; + if (error.code === 'too_many') { + setAttachError( + chatSendError( + 'attachment_invalid', + t('chat.attachment.tooMany').replace('{max}', String(ATTACHMENT_MAX_IMAGES)) + ) + ); + } else if (error.code === 'too_large') { + const maxMb = (ATTACHMENT_MAX_SIZE_BYTES / (1024 * 1024)).toFixed(0); + setAttachError( + chatSendError( + 'attachment_invalid', + t('chat.attachment.tooLarge').replace('{max}', `${maxMb} MB`) + ) + ); + } else if (error.code === 'unsupported_type') { + setAttachError(chatSendError('attachment_invalid', t('chat.attachment.unsupportedType'))); + } else { + setAttachError(chatSendError('attachment_invalid', t('chat.attachment.readFailed'))); + } + return; + } + acceptedCount++; + setAttachments(prev => [...prev, result.attachment]); + } + }; + const handleSendMessage = async (text?: string) => { if (pendingSendRef.current) return; @@ -622,7 +669,7 @@ const Conversations = ({ const trimmed = sendDecision.trimmedText; if ( - sendDecision.blockReason === 'empty_input' || + (sendDecision.blockReason === 'empty_input' && attachments.length === 0) || sendDecision.blockReason === 'missing_thread' || sendDecision.blockReason === 'composer_blocked' ) { @@ -636,7 +683,10 @@ const Conversations = ({ setSendAdvisory(null); } - if (!sendDecision.shouldSend) { + if ( + !sendDecision.shouldSend && + !(sendDecision.blockReason === 'empty_input' && attachments.length > 0) + ) { const blockedFeedback = getComposerBlockedSendFeedback(sendDecision.blockReason); if (blockedFeedback) { setSendError(chatSendError(blockedFeedback.error.code, blockedFeedback.error.message)); @@ -648,11 +698,20 @@ const Conversations = ({ if (!sendingThreadId) return; pendingSendRef.current = sendingThreadId; setPendingSendingThreadId(sendingThreadId); + const pendingAttachments = attachments.slice(); + const messageText = buildMessageWithAttachments(trimmed, pendingAttachments); const userMessage: ThreadMessage = { id: `msg_${globalThis.crypto.randomUUID()}`, content: trimmed, type: 'text', - extraMetadata: {}, + extraMetadata: + pendingAttachments.length > 0 + ? { + attachmentCount: pendingAttachments.length, + attachmentNames: pendingAttachments.map(a => a.file.name), + attachmentDataUris: pendingAttachments.map(a => a.dataUri), + } + : {}, sender: 'user', createdAt: new Date().toISOString(), }; @@ -677,7 +736,9 @@ const Conversations = ({ return; } setInputValue(''); + setAttachments([]); setSendError(null); + setAttachError(null); // Silence timer: fires only if 600s pass without ANY inference progress // (tool call, tool result, iteration start, subagent event, text delta). // The effect below rearms this timer whenever `inferenceStatusByThread` @@ -696,7 +757,7 @@ const Conversations = ({ try { await chatSend({ threadId: sendingThreadId, - message: trimmed, + message: messageText, model: CHAT_MODEL_ID, profileId: selectedAgentProfileId, locale: uiLocale, @@ -1548,13 +1609,43 @@ const Conversations = ({ )} ) : ( -
- - {latestVisibleMessage?.id === msg.id && ( -

- {formatRelativeTime(msg.createdAt)} -

- )} +
+ {(() => { + const dataUris = Array.isArray(msg.extraMetadata?.attachmentDataUris) + ? (msg.extraMetadata.attachmentDataUris as string[]) + : parseMessageImages(msg.content ?? '').dataUris; + const hasImages = dataUris.length > 0; + const showTime = latestVisibleMessage?.id === msg.id; + return ( + <> + {hasImages && ( +
+ {dataUris.map((uri, i) => ( + + ))} +
+ )} + {(msg.content || showTime) && ( +
+ {msg.content && ( + + )} + {showTime && ( +

+ {formatRelativeTime(msg.createdAt)} +

+ )} +
+ )} + + ); + })()}
)} +
+ )} + {sendError && (

@@ -1944,33 +2048,72 @@ const Conversations = ({

) : inputMode === 'text' ? (
-
-
- {inputValue} - - {inlineCompletionSuffix} - -
-