From 5f0b673a07b9ff131aaf7995f4d98eb3b31c01ba Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 14 Aug 2026 14:31:59 +0300 Subject: [PATCH] fix(ai): harden image attachment lifecycle - Prevent overlapping submissions while image analysis is running - Guard browser image APIs and object URL cleanup - Restore compound-control and keyboard-focus styling --- src/app/ai/attachment/image/prepare.ts | 26 +++++++++- src/app/ai/attachment/image/presentation.ts | 10 ++-- src/components/ChatPanel.vue | 54 ++++++++++++++------ src/components/chat/ChatInput.vue | 17 +++--- src/components/ui/InputGroup.vue | 2 +- src/theme/icon-button.ts | 6 +-- src/theme/input-group.ts | 3 +- tests/engine/app/ai/attachment/image.test.ts | 11 +++- 8 files changed, 94 insertions(+), 35 deletions(-) diff --git a/src/app/ai/attachment/image/prepare.ts b/src/app/ai/attachment/image/prepare.ts index 1753d253b..2295228ee 100644 --- a/src/app/ai/attachment/image/prepare.ts +++ b/src/app/ai/attachment/image/prepare.ts @@ -13,6 +13,19 @@ export function isImageAttachmentMediaType(value: string): value is ImageAttachm return IMAGE_ATTACHMENT_MEDIA_TYPES.some((mediaType) => mediaType === value) } +export function createImagePreviewURL(blob: Blob): string { + if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') { + throw new TypeError('Image attachments are unavailable in this environment.') + } + return URL.createObjectURL(blob) +} + +export function revokeImagePreviewURL(url: string): void { + if (typeof URL !== 'undefined' && typeof URL.revokeObjectURL === 'function') { + URL.revokeObjectURL(url) + } +} + export function validateImageAttachmentFile(file: File): string | null { if (!isImageAttachmentMediaType(file.type)) return 'Choose a PNG, JPEG, or WebP image.' if (file.size > MAX_IMAGE_FILE_BYTES) return 'Images must be 20 MB or smaller.' @@ -52,7 +65,16 @@ export async function prepareImageAttachment( const validationError = validateImageAttachmentFile(file) if (validationError) throw new Error(validationError) - const sourceURL = URL.createObjectURL(file) + if ( + typeof URL === 'undefined' || + typeof URL.createObjectURL !== 'function' || + typeof Image === 'undefined' || + typeof document === 'undefined' + ) { + throw new TypeError('Image attachments are unavailable in this environment.') + } + + const sourceURL = createImagePreviewURL(file) try { const image = await loadImage(sourceURL) if (image.naturalWidth * image.naturalHeight > MAX_IMAGE_PIXELS) { @@ -85,6 +107,6 @@ export async function prepareImageAttachment( height } } finally { - URL.revokeObjectURL(sourceURL) + revokeImagePreviewURL(sourceURL) } } diff --git a/src/app/ai/attachment/image/presentation.ts b/src/app/ai/attachment/image/presentation.ts index 5d8ab9b68..c26738cfc 100644 --- a/src/app/ai/attachment/image/presentation.ts +++ b/src/app/ai/attachment/image/presentation.ts @@ -1,5 +1,6 @@ import { computed, shallowReactive } from 'vue' +import { revokeImagePreviewURL } from '@/app/ai/attachment/image/prepare' import type { ImageAttachmentPresentation } from '@/app/ai/attachment/image/types' const attachments = shallowReactive(new Map()) @@ -21,8 +22,9 @@ export function setImageAttachmentPresentations( if (previous) { const retainedURLs = new Set(nextAttachments.map((attachment) => attachment.previewURL)) for (const staleAttachment of previous) { - if (!retainedURLs.has(staleAttachment.previewURL)) - URL.revokeObjectURL(staleAttachment.previewURL) + if (!retainedURLs.has(staleAttachment.previewURL)) { + revokeImagePreviewURL(staleAttachment.previewURL) + } } } attachments.set(messageId, nextAttachments) @@ -30,7 +32,9 @@ export function setImageAttachmentPresentations( export function clearImageAttachmentPresentations(): void { for (const messageAttachments of attachments.values()) { - for (const attachment of messageAttachments) URL.revokeObjectURL(attachment.previewURL) + for (const attachment of messageAttachments) { + revokeImagePreviewURL(attachment.previewURL) + } } attachments.clear() } diff --git a/src/components/ChatPanel.vue b/src/components/ChatPanel.vue index 447cb84de..5eab64a38 100644 --- a/src/components/ChatPanel.vue +++ b/src/components/ChatPanel.vue @@ -10,8 +10,10 @@ import { designMessageWithImageFindings } from '@/app/ai/attachment/image/analyze' import { + createImagePreviewURL, isImageAttachmentMediaType, - prepareImageAttachment + prepareImageAttachment, + revokeImagePreviewURL } from '@/app/ai/attachment/image/prepare' import { clearImageAttachmentPresentations, @@ -42,6 +44,8 @@ const { copy } = useClipboard() const { dialogs } = useI18n() const chat = ref | null>(null) +const isPreparingImages = ref(false) +let attachmentOperationVersion = 0 void ensureChat() .then((c) => { @@ -108,6 +112,8 @@ watch( watch( () => activeTab.value?.id, async () => { + attachmentOperationVersion += 1 + isPreparingImages.value = false clearImageAttachmentPresentations() const nextChat = await ensureChat() chat.value = nextChat ? markRaw(nextChat) : null @@ -115,30 +121,32 @@ watch( ) async function handleSubmit(text: string, images: ImageAttachmentDraft[] = []) { - if (status.value === 'streaming' || status.value === 'submitted') { - for (const image of images) URL.revokeObjectURL(image.previewURL) + if (status.value === 'streaming' || status.value === 'submitted' || isPreparingImages.value) { + for (const image of images) revokeImagePreviewURL(image.previewURL) + if (images.length > 0) toast.error(dialogs.value.chatRequestFailed) return } + + const operationVersion = ++attachmentOperationVersion + if (images.length > 0) isPreparingImages.value = true clearChatFailure() try { - const c = await ensureChat() - if (c) chat.value = markRaw(c) - if (!chat.value) { - for (const image of images) URL.revokeObjectURL(image.previewURL) - if (images.length > 0) { - toast.error('Chat is unavailable. The images were not sent.') - } + const currentChat = chat.value ?? (await ensureChat()) + if (currentChat) chat.value = markRaw(currentChat) + if (!currentChat || operationVersion !== attachmentOperationVersion) { + for (const image of images) revokeImagePreviewURL(image.previewURL) + if (images.length > 0) toast.error(dialogs.value.chatRequestFailed) return } if (images.length === 0) { - await chat.value.sendMessage({ text }) + await currentChat.sendMessage({ text }) return } const messageId = crypto.randomUUID() - chat.value.messages = [ - ...chat.value.messages, + currentChat.messages = [ + ...currentChat.messages, { id: messageId, role: 'user', parts: [{ type: 'text', text }] } ] setImageAttachmentPresentations( @@ -161,11 +169,13 @@ async function handleSubmit(text: string, images: ImageAttachmentDraft[] = []) { images.map((image) => prepareImageAttachment(image.file)) ) const findings = await analyzeAttachedImages(getActiveEditorStore(), text, preparedImages) + if (operationVersion !== attachmentOperationVersion || chat.value !== currentChat) return + setImageAttachmentPresentations( messageId, preparedImages.map((prepared, index) => { const image = images[index] - const previewURL = URL.createObjectURL(prepared.blob) + const previewURL = createImagePreviewURL(prepared.blob) return { id: crypto.randomUUID(), messageId, @@ -180,7 +190,7 @@ async function handleSubmit(text: string, images: ImageAttachmentDraft[] = []) { } }) ) - await chat.value.sendMessage({ + await currentChat.sendMessage({ messageId, text: designMessageWithImageFindings( text, @@ -190,7 +200,9 @@ async function handleSubmit(text: string, images: ImageAttachmentDraft[] = []) { }) } catch (e) { console.error('Chat error:', e) - toast.error(e instanceof Error ? e.message : String(e)) + toast.error(dialogs.value.chatRequestFailed) + } finally { + if (operationVersion === attachmentOperationVersion) isPreparingImages.value = false } } @@ -211,6 +223,8 @@ async function handleCopyACPLog() { } function handleClearChat() { + attachmentOperationVersion += 1 + isPreparingImages.value = false clearChatFailure() clearImageAttachmentPresentations() chat.value = null @@ -316,7 +330,13 @@ function handleClearChat() { - + diff --git a/src/components/chat/ChatInput.vue b/src/components/chat/ChatInput.vue index 989831ec3..ad79d66f8 100644 --- a/src/components/chat/ChatInput.vue +++ b/src/components/chat/ChatInput.vue @@ -9,7 +9,11 @@ import IconButton from '@/components/ui/IconButton.vue' import InputGroup from '@/components/ui/InputGroup.vue' import { useAIChat } from '@/app/ai/chat/use' import { designModelProfile, designModelProfiles } from '@/app/ai/models' -import { validateImageAttachmentFile } from '@/app/ai/attachment/image/prepare' +import { + createImagePreviewURL, + revokeImagePreviewURL, + validateImageAttachmentFile +} from '@/app/ai/attachment/image/prepare' import { MAX_IMAGE_ATTACHMENTS, type ImageAttachmentDraft } from '@/app/ai/attachment/image/types' import { openSettingsDialog } from '@/app/settings/dialog' import { useI18n } from '@open-pencil/vue' @@ -19,8 +23,9 @@ import { ACP_AGENTS } from '@open-pencil/core/constants' const { providerID, providerDef, modelID, customModelID } = useAIChat() const { dialogs } = useI18n() -const { status } = defineProps<{ +const { status, disabled = false } = defineProps<{ status: 'ready' | 'submitted' | 'streaming' | 'error' + disabled?: boolean }>() const emit = defineEmits<{ @@ -55,7 +60,7 @@ function addImageFiles(files: File[]) { emit('error', validationError) continue } - images.value.push({ file, previewURL: URL.createObjectURL(file) }) + images.value.push({ file, previewURL: createImagePreviewURL(file) }) } if (files.length > available) { emit('error', `You can attach up to ${MAX_IMAGE_ATTACHMENTS} images.`) @@ -65,12 +70,12 @@ function addImageFiles(files: File[]) { function removeImage(index: number) { const image = images.value[index] - if (image) URL.revokeObjectURL(image.previewURL) + if (image) revokeImagePreviewURL(image.previewURL) images.value.splice(index, 1) resetImageDialog() } -const isStreaming = computed(() => status === 'streaming' || status === 'submitted') +const isStreaming = computed(() => disabled || status === 'streaming' || status === 'submitted') const isACPProvider = computed(() => providerID.value.startsWith('acp:')) const acpAgentName = computed(() => { const agentId = providerID.value.replace('acp:', '') @@ -98,7 +103,7 @@ const selectedProfileName = computed( ) function clearImages() { - for (const image of images.value) URL.revokeObjectURL(image.previewURL) + for (const image of images.value) revokeImagePreviewURL(image.previewURL) images.value = [] resetImageDialog() } diff --git a/src/components/ui/InputGroup.vue b/src/components/ui/InputGroup.vue index e20b67ffb..7c5034eec 100644 --- a/src/components/ui/InputGroup.vue +++ b/src/components/ui/InputGroup.vue @@ -33,7 +33,7 @@ const cls = computed(() => inputGroup({ size, disabled }))
-
+
diff --git a/src/theme/icon-button.ts b/src/theme/icon-button.ts index 26a7b7190..637aa1eaf 100644 --- a/src/theme/icon-button.ts +++ b/src/theme/icon-button.ts @@ -2,9 +2,9 @@ export default { base: 'flex cursor-pointer items-center justify-center bg-transparent text-muted outline-none hover:bg-hover hover:text-surface focus-visible:border-panel-focus', variants: { size: { - xs: 'size-6 rounded border-none text-sm leading-none', - sm: 'size-7 rounded-md border-none text-sm leading-none', - md: 'size-8 rounded-md border-none text-base leading-none' + xs: 'size-6 rounded border border-transparent text-sm leading-none', + sm: 'size-7 rounded-md border border-transparent text-sm leading-none', + md: 'size-8 rounded-md border border-transparent text-base leading-none' }, active: { true: 'border-accent text-accent' diff --git a/src/theme/input-group.ts b/src/theme/input-group.ts index 8b4e6cee0..9539e5942 100644 --- a/src/theme/input-group.ts +++ b/src/theme/input-group.ts @@ -4,8 +4,7 @@ export default { slots: { root: 'min-w-0 rounded-xl border border-border bg-input transition-colors hover:border-muted/60 focus-within:border-panel-focus focus-within:ring-1 focus-within:ring-accent/30', attachment: 'px-2 pt-2', - control: - 'block min-h-12 w-full resize-none bg-transparent px-3 pt-2.5 pb-1 text-xs leading-relaxed text-surface outline-none placeholder:text-muted disabled:cursor-not-allowed disabled:opacity-60', + control: 'min-w-0', toolbar: 'flex min-w-0 items-center gap-1 px-1.5 pb-1.5', model: 'min-w-0 flex-1', actions: 'ml-auto flex shrink-0 items-center gap-1' diff --git a/tests/engine/app/ai/attachment/image.test.ts b/tests/engine/app/ai/attachment/image.test.ts index a4f550474..05163bd45 100644 --- a/tests/engine/app/ai/attachment/image.test.ts +++ b/tests/engine/app/ai/attachment/image.test.ts @@ -9,7 +9,10 @@ import { designMessageWithImageFindings, type ImageAnalysisDependencies } from '@/app/ai/attachment/image/analyze' -import { validateImageAttachmentFile } from '@/app/ai/attachment/image/prepare' +import { + prepareImageAttachment, + validateImageAttachmentFile +} from '@/app/ai/attachment/image/prepare' import type { PreparedImageAttachment } from '@/app/ai/attachment/image/types' import type { EditorStore } from '@/app/editor/session/create' @@ -42,6 +45,12 @@ describe('image attachment analysis', () => { ).toBe('Images must be 20 MB or smaller.') }) + test('fails with a controlled error without browser image APIs', async () => { + await expect( + prepareImageAttachment(new File(['png'], 'image.png', { type: 'image/png' })) + ).rejects.toThrow('Image attachments are unavailable in this environment.') + }) + test('sends all bounded images only to Vision and returns text findings', async () => { const graph = new SceneGraph() const page = graph.getPages()[0]