-
-
Notifications
You must be signed in to change notification settings - Fork 8
Fix Image Pipeline Diagnostics and Type Safety #642
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,7 +6,7 @@ import { | |
| getAIState, | ||
| getMutableAIState | ||
| } from 'ai/rsc' | ||
| import { CoreMessage, ToolResultPart } from 'ai' | ||
| import { CoreMessage, ToolResultPart, TextPart, ImagePart } from 'ai' | ||
| import { nanoid } from '@/lib/utils' | ||
| import type { FeatureCollection } from 'geojson' | ||
| import { Spinner } from '@/components/ui/spinner' | ||
|
|
@@ -242,6 +242,12 @@ async function submit(formData?: FormData, skip?: boolean) { | |
| } | ||
|
|
||
| const file = !skip ? (formData?.get('file') as File) : undefined | ||
| console.log('File extraction:', { | ||
| exists: !!file, | ||
| name: file?.name, | ||
| type: file?.type, | ||
| size: file?.size | ||
| }) | ||
| const userInput = skip | ||
| ? `{"action": "skip"}` | ||
| : ((formData?.get('related_query') as string) || | ||
|
|
@@ -328,67 +334,118 @@ async function submit(formData?: FormData, skip?: boolean) { | |
| } | ||
| } | ||
|
|
||
| const messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter( | ||
| (message: any) => | ||
| message.role !== 'tool' && | ||
| message.type !== 'followup' && | ||
| message.type !== 'related' && | ||
| message.type !== 'end' && | ||
| message.type !== 'resolution_search_result' | ||
| ).map((m: any) => { | ||
| if (Array.isArray(m.content)) { | ||
| return { | ||
| ...m, | ||
| content: m.content.filter((part: any) => | ||
| part.type !== "image" || (typeof part.image === "string" && part.image.startsWith("data:")) | ||
| ) | ||
| } as any | ||
| } | ||
| return m | ||
| let filteredImagesCount = 0 | ||
| let retainedImagesCount = 0 | ||
| const messages: CoreMessage[] = [...(aiState.get().messages as any[])] | ||
| .filter( | ||
| (message: any) => | ||
| message.role !== 'tool' && | ||
| message.type !== 'followup' && | ||
| message.type !== 'related' && | ||
| message.type !== 'end' && | ||
| message.type !== 'resolution_search_result' | ||
| ) | ||
| .map((m: any) => { | ||
| if (Array.isArray(m.content)) { | ||
| const filteredContent = m.content.filter((part: any) => { | ||
| if (part.type === 'image') { | ||
| const isValid = | ||
| typeof part.image === 'string' && | ||
| (part.image.startsWith('data:') || | ||
| part.image === 'IMAGE_PROCESSED') | ||
| if (isValid) { | ||
| retainedImagesCount++ | ||
| } else { | ||
| filteredImagesCount++ | ||
| } | ||
| return isValid | ||
| } | ||
| return true | ||
| }) | ||
| return { | ||
| ...m, | ||
| content: filteredContent | ||
| } as any | ||
| } | ||
| return m | ||
| }) | ||
| console.log('Historical messages image filter:', { | ||
| filteredImagesCount, | ||
| retainedImagesCount, | ||
| totalMessages: messages.length | ||
| }) | ||
|
|
||
| const groupeId = nanoid() | ||
| const useSpecificAPI = process.env.USE_SPECIFIC_API_FOR_WRITER === 'true' | ||
| const maxMessages = useSpecificAPI ? 5 : 10 | ||
| messages.splice(0, Math.max(messages.length - maxMessages, 0)) | ||
|
|
||
| const messageParts: { | ||
| type: 'text' | 'image' | ||
| text?: string | ||
| image?: string | ||
| mimeType?: string | ||
| }[] = [] | ||
| const messageParts: (TextPart | ImagePart)[] = [] | ||
|
|
||
| if (userInput) { | ||
| messageParts.push({ type: 'text', text: userInput }) | ||
| } | ||
|
|
||
| if (file) { | ||
| const buffer = await file.arrayBuffer() | ||
| if (file.type.startsWith('image/')) { | ||
| const dataUrl = `data:${file.type};base64,${Buffer.from( | ||
| buffer | ||
| ).toString('base64')}` | ||
| messageParts.push({ | ||
| type: 'image', | ||
| image: dataUrl, | ||
| mimeType: file.type | ||
| }) | ||
| } else if (file.type === 'text/plain') { | ||
| const textContent = Buffer.from(buffer).toString('utf-8') | ||
| const existingTextPart = messageParts.find(p => p.type === 'text') | ||
| if (existingTextPart) { | ||
| existingTextPart.text = `${textContent}\n\n${existingTextPart.text}` | ||
| } else { | ||
| messageParts.push({ type: 'text', text: textContent }) | ||
| const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB | ||
| if (file.size > MAX_FILE_SIZE) { | ||
| console.error('File size exceeds 10MB limit:', file.size) | ||
| } else { | ||
| try { | ||
| const buffer = await file.arrayBuffer() | ||
| console.log('File buffer loaded:', { size: buffer.byteLength }) | ||
| if (file.type.startsWith('image/')) { | ||
| const dataUrl = `data:${file.type};base64,${Buffer.from( | ||
| buffer | ||
| ).toString('base64')}` | ||
| console.log('Image processed:', { | ||
| dataUrlPrefix: dataUrl.substring(0, 50), | ||
| totalLength: dataUrl.length | ||
| }) | ||
| const imagePart: ImagePart = { | ||
| type: 'image', | ||
| image: dataUrl, | ||
| mimeType: file.type | ||
| } | ||
| console.log('Pushing image part (debug shape):', { | ||
| ...imagePart, | ||
| image: dataUrl.substring(0, 50) + '...' | ||
| }) | ||
|
Comment on lines
+401
to
+413
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don’t log raw base64 image bytes. The Safer logging console.log('Image processed:', {
- dataUrlPrefix: dataUrl.substring(0, 50),
- totalLength: dataUrl.length
+ mimeType: file.type,
+ payloadBytes: buffer.byteLength,
+ totalLength: dataUrl.length
})
const imagePart: ImagePart = {
type: 'image',
image: dataUrl,
mimeType: file.type
}
console.log('Pushing image part (debug shape):', {
- ...imagePart,
- image: imagePart.image.substring(0, 50) + '...'
+ type: imagePart.type,
+ mimeType: imagePart.mimeType,
+ totalLength: imagePart.image.length
})🤖 Prompt for AI Agents |
||
| messageParts.push(imagePart) | ||
|
Comment on lines
+405
to
+414
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don’t persist raw data URLs in chat history. When an image is attached, the full base64 data URL is pushed into Also applies to: 458-475 🤖 Prompt for AI Agents |
||
| } else if (file.type === 'text/plain') { | ||
| const textContent = Buffer.from(buffer).toString('utf-8') | ||
| const existingTextPart = messageParts.find( | ||
| (p): p is TextPart => p.type === 'text' | ||
| ) | ||
| if (existingTextPart) { | ||
| existingTextPart.text = `${textContent}\n\n${existingTextPart.text}` | ||
| } else { | ||
| messageParts.push({ type: 'text', text: textContent }) | ||
| } | ||
| } | ||
| } catch (error) { | ||
| console.error('Error processing file:', error) | ||
| } | ||
|
Comment on lines
389
to
428
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fail fast when attachment processing yields no usable part. Oversize files, unsupported MIME types, and Suggested direction if (file) {
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
if (file.size > MAX_FILE_SIZE) {
- console.error('File size exceeds 10MB limit:', file.size)
+ throw new Error('File size must be less than 10MB')
} else {
try {
const buffer = await file.arrayBuffer()
...
- }
+ } else {
+ throw new Error(`Unsupported file type: ${file.type || 'unknown'}`)
+ }
} catch (error) {
- console.error('Error processing file:', error)
+ throw error
}
}
}Also applies to: 458-475 🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
|
|
||
| const hasImage = messageParts.some(part => part.type === 'image') | ||
| console.log('messageParts structure:', { | ||
| parts: messageParts.map(p => ({ | ||
| type: p.type, | ||
| length: p.type === 'text' ? p.text.length : undefined | ||
| })), | ||
| hasImage | ||
| }) | ||
| const content: CoreMessage['content'] = hasImage | ||
| ? messageParts as CoreMessage['content'] | ||
| : messageParts.map(part => part.text).join('\n') | ||
| ? messageParts | ||
| : messageParts.map(part => (part.type === 'text' ? part.text : '')).join('\n') | ||
| console.log('Final content structure:', { | ||
| hasImage, | ||
| contentType: typeof content, | ||
| isArray: Array.isArray(content), | ||
| partsCount: Array.isArray(content) ? content.length : 'N/A' | ||
| }) | ||
|
|
||
| const type = skip | ||
| ? undefined | ||
|
Comment on lines
385
to
451
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1. File-only upload dropped In submit(), if the user submits only a file (no text) and the file is >10MB or processing throws, the code only logs and continues, leaving messageParts empty; this makes content an empty string and the if (content) guard skips appending the user message. The pipeline then runs taskManager/researcher using only historical messages, so the response ignores the user’s upload while still consuming tokens. Agent Prompt
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
For AI SDK version 4.3.19, what values are valid forImagePart.imageinsideCoreMessage['content']? Is an arbitrary sentinel string likeIMAGE_PROCESSEDsupported in provider requests, or must it be an actual data URL/URL/binary payload?💡 Result:
For AI SDK version 4.3.19, the
ImagePart.imageproperty is designed to hold actual image data or a reference to it, and it does not support arbitrary sentinel strings likeIMAGE_PROCESSED[1][2]. The valid types forImagePart.imageare restricted to the following [1][3][4]: - Data payloads: base64-encoded strings, Uint8Array, ArrayBuffer, or Buffer [1][3][4]. - URLs: a URL object or string pointing to an image (e.g., http(s) URLs or base64 data URLs) [1][3][4]. Providing an arbitrary string that does not conform to these formats (i.e., is not a valid data representation or a valid URL) will generally cause the SDK or the underlying model provider to fail, as they expect valid image content to process [3][4]. Note that in recent versions of the AI SDK,ImageParthas been deprecated in favor ofFilePart(using{ type: 'file', mediaType: 'image', data: ... }) [5][6]. If you need to include metadata or indicate that an image has already been processed, this should be handled through other fields, such asproviderOptions, or by managing the file state outside of theImagePartcontent array [7][6].Citations:
🏁 Script executed:
Repository: QueueLab/QCX
Length of output: 180
🏁 Script executed:
Repository: QueueLab/QCX
Length of output: 21615
Stop preserving
IMAGE_PROCESSEDas anImagePart.imagesent to the SDKapp/actions.tsx’s historical message filter keeps{ type: 'image', image: 'IMAGE_PROCESSED' }, andlib/agents/researcher.tsxforwardsmessagesunchanged intononexperimental_streamText(and enables the “has image” path whenever anytype: 'image'part exists).ImagePart.imagemust be real image data/URL (or binary payload), not an arbitrary sentinel—strip/replace this placeholder when building theCoreMessage[]passed to the model, while allowing the sentinel only for persisted/UI history.🤖 Prompt for AI Agents