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
This commit is contained in:
Danila Poyarkov 2026-08-14 14:31:59 +03:00
parent 7b67bd8658
commit 5f0b673a07
8 changed files with 94 additions and 35 deletions

View file

@ -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)
}
}

View file

@ -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<string, ImageAttachmentPresentation[]>())
@ -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()
}

View file

@ -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<Chat<UIMessage> | 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() {
</AppTextButton>
</div>
<ChatInput :status="status" @submit="handleSubmit" @stop="handleStop" @error="toast.error" />
<ChatInput
:status="status"
:disabled="isPreparingImages"
@submit="handleSubmit"
@stop="handleStop"
@error="toast.error"
/>
<ACPPermissionDialog />
</template>

View file

@ -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()
}

View file

@ -33,7 +33,7 @@ const cls = computed(() => inputGroup({ size, disabled }))
<div v-if="$slots.attachment" data-slot="input-group-attachment" :class="ui?.attachment">
<slot name="attachment" />
</div>
<div data-slot="input-group-control" :class="ui?.control">
<div data-slot="input-group-control" :class="cls.control({ class: ui?.control })">
<slot />
</div>
<div data-slot="input-group-toolbar" :class="cls.toolbar({ class: ui?.toolbar })">

View file

@ -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'

View file

@ -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'

View file

@ -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]