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:
parent
7b67bd8658
commit
5f0b673a07
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })">
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue