Skip to content

feat: implement image upload in chat input and image handling#501

Open
Bl3f wants to merge 6 commits intomainfrom
cursor/chat-input-image-handling-9a4f
Open

feat: implement image upload in chat input and image handling#501
Bl3f wants to merge 6 commits intomainfrom
cursor/chat-input-image-handling-9a4f

Conversation

@Bl3f
Copy link
Contributor

@Bl3f Bl3f commented Mar 20, 2026

Summary

Adds full image upload support to the chat input, including upload, storage, display, and passing images to the LLM for multimodal conversations.

Backend

  • Database: New message_image table (both SQLite and PostgreSQL) for storing uploaded image data as base64. Added mediaType and imageId columns to message_part for file type parts.
  • Image serving: New route at /i/:imageId serving stored images with immutable caching headers.
  • Agent request: Extended AgentRequestUserMessageSchema to accept an optional images array ({ mediaType, data }).
  • Handler: Processes uploaded images by saving to DB, building file type UI parts with server URLs.
  • Part mappings: convertUIPartToDBPart / convertDBPartToUIPart handle file type parts, mapping between image IDs and URLs.
  • Agent service: Before calling convertToModelMessages, resolves server image URLs (/i/{id}) to data URLs so the model provider receives actual image content.

Frontend

  • useImageUpload hook: Manages image state with file picker, clipboard paste, and drag-and-drop support. Limits to 4 images, 5MB each. Supports PNG, JPEG, GIF, WebP.
  • ChatInputImagePreview: Displays thumbnail previews with remove buttons above the text input.
  • Chat input: Integrated drag zone (with visual feedback), file input, paste handler, and "Upload image" option in the plus menu.
  • use-agent: Passes images as FileUIPart[] via the AI SDK's sendMessage({ text, files }), then extracts them in prepareSendMessagesRequest for the backend. Images display immediately in the chat bubble via data URLs, and persist via server URLs after reload.
  • User message display: Renders uploaded images in UserMessageBubble via getMessageImages helper.
  • Vite proxy: Added /i and /c proxy routes to dev server config.

Types

  • Updated SendMessageArgs, QueuedMessage, and NewQueuedMessage to include optional images.
  • Added getMessageImages and extractImagesFromMessage utilities.
Open in Web Open in Cursor 

cursoragent and others added 4 commits March 20, 2026 18:16
- Add message_image table to both SQLite and PostgreSQL schemas
- Add mediaType and imageId columns to message_part table
- Create image queries for saving and retrieving images
- Create image serving route at /i/:imageId
- Update agent handler to process image uploads
- Update chat-message-part-mappings for file type parts
- Resolve image URLs to data URLs for model messages in agent service
- Extend AgentRequestUserMessageSchema with optional images array

Co-authored-by: Christophe Blefari <christophe.blefari@gmail.com>
- Create useImageUpload hook with file picker, paste, drag-and-drop support
- Create ChatInputImagePreview component for thumbnail previews
- Integrate image upload in ChatInputBase with drag zone and file input
- Add 'Upload image' option to the plus menu
- Update SendMessageArgs and queue types to support images
- Update use-agent to pass images in prepareSendMessagesRequest
- Display images in UserMessageBubble
- Add getMessageImages helper to lib/ai

Co-authored-by: Christophe Blefari <christophe.blefari@gmail.com>
Co-authored-by: Christophe Blefari <christophe.blefari@gmail.com>
Co-authored-by: Christophe Blefari <christophe.blefari@gmail.com>
@github-actions
Copy link
Contributor

github-actions bot commented Mar 20, 2026

🚀 Preview Deployment

URL https://pr-501-9d4bdd2.preview.getnao.io
Commit 9d4bdd2

⚠️ No LLM API keys configured - you'll see the API key setup flow when trying to chat.


Preview will be automatically removed when this PR is closed.

cursoragent and others added 2 commits March 20, 2026 18:43
…ately

- Pass images as FileUIPart[] via sendMessage({text, files}) instead of a separate ref
- Extract images from message file parts in prepareSendMessagesRequest
- Add extractImagesFromMessage helper to lib/ai
- Images now appear in the chat bubble immediately after sending

Co-authored-by: Christophe Blefari <christophe.blefari@gmail.com>
Image and chart routes need to be proxied to the backend server
during development, otherwise the frontend can't access them.

Co-authored-by: Christophe Blefari <christophe.blefari@gmail.com>
@Bl3f Bl3f marked this pull request as ready for review March 20, 2026 22:47
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

9 issues found across 20 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/frontend/src/hooks/use-image-upload.ts">

<violation number="1" location="apps/frontend/src/hooks/use-image-upload.ts:51">
P2: Limit the loop to only files that can actually be kept; currently extra files are still decoded and then dropped by `.slice(0, MAX_IMAGES)`.</violation>
</file>

<file name="apps/backend/src/handlers/agent.ts">

<violation number="1" location="apps/backend/src/handlers/agent.ts:33">
P1: Images are saved before access control checks, allowing unauthorized requests to persist image data even when chat access is forbidden.</violation>
</file>

<file name="apps/backend/src/db/sqlite-schema.ts">

<violation number="1" location="apps/backend/src/db/sqlite-schema.ts:279">
P2: This schema can orphan uploaded image blobs after message deletion, causing unbounded storage growth.</violation>
</file>

<file name="apps/backend/src/routes/image.ts">

<violation number="1" location="apps/backend/src/routes/image.ts:22">
P1: Do not trust stored `mediaType` when setting `Content-Type`; restrict to `image/*` (or force a safe fallback) to prevent serving active content from `/i/:imageId`.</violation>
</file>

<file name="apps/backend/src/utils/chat-message-part-mappings.ts">

<violation number="1" location="apps/backend/src/utils/chat-message-part-mappings.ts:60">
P2: Avoid returning `file` UI parts with empty URLs. If `imageId` is missing, skip the part instead of emitting `url: ''`.</violation>
</file>

<file name="apps/frontend/src/components/chat-input.tsx">

<violation number="1" location="apps/frontend/src/components/chat-input.tsx:148">
P1: Document-level paste handling intercepts image paste outside this chat input and can trigger multiple handlers when inline chat inputs are mounted. Scope paste handling to this component's DOM subtree.</violation>

<violation number="2" location="apps/frontend/src/components/chat-input.tsx:177">
P2: The hardcoded fallback text is applied in a shared input component, so image-only submits in inline edit mode can overwrite message text with `Describe this image`.</violation>
</file>

<file name="apps/backend/src/types/chat.ts">

<violation number="1" location="apps/backend/src/types/chat.ts:119">
P1: Validate `mediaType` and `data` in `AgentRequestImageSchema`; accepting any string allows non-image content to be persisted and served with attacker-controlled `Content-Type`.</violation>
</file>

<file name="apps/backend/src/db/pg-schema.ts">

<violation number="1" location="apps/backend/src/db/pg-schema.ts:263">
P2: `message_part` lacks a constraint requiring `mediaType` and `imageId` when `type = 'file'`, allowing invalid file parts to be stored and later rendered with missing image data.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

);
}

const imageParts = await saveAndBuildImageParts(message.images);
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Images are saved before access control checks, allowing unauthorized requests to persist image data even when chat access is forbidden.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/src/handlers/agent.ts, line 33:

<comment>Images are saved before access control checks, allowing unauthorized requests to persist image data even when chat access is forbidden.</comment>

<file context>
@@ -28,16 +30,24 @@ export const handleAgentRoute = async (opts: HandleAgentMessageInput): Promise<H
 		);
 	}
 
+	const imageParts = await saveAndBuildImageParts(message.images);
+
 	let chatId = opts.chatId;
</file context>
Fix with Cubic


const buffer = Buffer.from(image.data, 'base64');
return reply
.header('Content-Type', image.mediaType)
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Do not trust stored mediaType when setting Content-Type; restrict to image/* (or force a safe fallback) to prevent serving active content from /i/:imageId.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/src/routes/image.ts, line 22:

<comment>Do not trust stored `mediaType` when setting `Content-Type`; restrict to `image/*` (or force a safe fallback) to prevent serving active content from `/i/:imageId`.</comment>

<file context>
@@ -0,0 +1,26 @@
+
+		const buffer = Buffer.from(image.data, 'base64');
+		return reply
+			.header('Content-Type', image.mediaType)
+			.header('Cache-Control', 'public, max-age=31536000, immutable')
+			.send(buffer);
</file context>
Fix with Cubic

}, [imageUpload.addFiles]); // eslint-disable-line

useEffect(() => {
const handler = (e: ClipboardEvent) => imageUpload.handlePaste(e);
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Document-level paste handling intercepts image paste outside this chat input and can trigger multiple handlers when inline chat inputs are mounted. Scope paste handling to this component's DOM subtree.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/frontend/src/components/chat-input.tsx, line 148:

<comment>Document-level paste handling intercepts image paste outside this chat input and can trigger multiple handlers when inline chat inputs are mounted. Scope paste handling to this component's DOM subtree.</comment>

<file context>
@@ -87,9 +90,66 @@ function ChatInputBase({
+	}, [imageUpload.addFiles]); // eslint-disable-line
+
+	useEffect(() => {
+		const handler = (e: ClipboardEvent) => imageUpload.handlePaste(e);
+		document.addEventListener('paste', handler);
+		return () => document.removeEventListener('paste', handler);
</file context>
Suggested change
const handler = (e: ClipboardEvent) => imageUpload.handlePaste(e);
const handler = (e: ClipboardEvent) => {
if (dropZoneRef.current?.contains(e.target as Node)) {
imageUpload.handlePaste(e);
}
};
Fix with Cubic

Comment on lines +119 to +120
mediaType: z.string(),
data: z.string(),
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Validate mediaType and data in AgentRequestImageSchema; accepting any string allows non-image content to be persisted and served with attacker-controlled Content-Type.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/src/types/chat.ts, line 119:

<comment>Validate `mediaType` and `data` in `AgentRequestImageSchema`; accepting any string allows non-image content to be persisted and served with attacker-controlled `Content-Type`.</comment>

<file context>
@@ -115,9 +115,16 @@ export const MentionSchema = z.object({
 });
 
+export const AgentRequestImageSchema = z.object({
+	mediaType: z.string(),
+	data: z.string(),
+});
</file context>
Suggested change
mediaType: z.string(),
data: z.string(),
mediaType: z.enum(['image/png', 'image/jpeg', 'image/gif', 'image/webp']),
data: z.string().min(1).regex(/^[A-Za-z0-9+/]+={0,2}$/),
Fix with Cubic

}

const newImages: UploadedImage[] = [];
for (const file of fileArray) {
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Limit the loop to only files that can actually be kept; currently extra files are still decoded and then dropped by .slice(0, MAX_IMAGES).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/frontend/src/hooks/use-image-upload.ts, line 51:

<comment>Limit the loop to only files that can actually be kept; currently extra files are still decoded and then dropped by `.slice(0, MAX_IMAGES)`.</comment>

<file context>
@@ -0,0 +1,131 @@
+		}
+
+		const newImages: UploadedImage[] = [];
+		for (const file of fileArray) {
+			const dataUrl = await readFileAsDataUrl(file);
+			newImages.push({
</file context>
Fix with Cubic


// file/image columns
mediaType: text('media_type'),
imageId: text('image_id').references(() => messageImage.id, { onDelete: 'set null' }),
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This schema can orphan uploaded image blobs after message deletion, causing unbounded storage growth.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/src/db/sqlite-schema.ts, line 279:

<comment>This schema can orphan uploaded image blobs after message deletion, causing unbounded storage growth.</comment>

<file context>
@@ -273,6 +273,10 @@ export const messagePart = sqliteTable(
+
+		// file/image columns
+		mediaType: text('media_type'),
+		imageId: text('image_id').references(() => messageImage.id, { onDelete: 'set null' }),
 	},
 	(t) => [
</file context>
Fix with Cubic

reasoningText: part.text,
providerMetadata: part.providerMetadata,
};
case 'file':
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Avoid returning file UI parts with empty URLs. If imageId is missing, skip the part instead of emitting url: ''.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/src/utils/chat-message-part-mappings.ts, line 60:

<comment>Avoid returning `file` UI parts with empty URLs. If `imageId` is missing, skip the part instead of emitting `url: ''`.</comment>

<file context>
@@ -56,6 +57,14 @@ export const convertUIPartToDBPart = (
 				reasoningText: part.text,
 				providerMetadata: part.providerMetadata,
 			};
+		case 'file':
+			return {
+				messageId,
</file context>
Fix with Cubic

imageUpload.clearImages();

await onSubmitMessage({
text: trimmedInput || (images.length > 0 ? 'Describe this image' : ''),
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The hardcoded fallback text is applied in a shared input component, so image-only submits in inline edit mode can overwrite message text with Describe this image.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/frontend/src/components/chat-input.tsx, line 177:

<comment>The hardcoded fallback text is applied in a shared input component, so image-only submits in inline edit mode can overwrite message text with `Describe this image`.</comment>

<file context>
@@ -99,17 +159,26 @@ function ChatInputBase({
+			imageUpload.clearImages();
+
+			await onSubmitMessage({
+				text: trimmedInput || (images.length > 0 ? 'Describe this image' : ''),
+				images: images.length > 0 ? images : undefined,
+			});
</file context>
Fix with Cubic

providerMetadata: jsonb('provider_metadata').$type<ProviderMetadata>(),

// file/image columns
mediaType: text('media_type'),
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: message_part lacks a constraint requiring mediaType and imageId when type = 'file', allowing invalid file parts to be stored and later rendered with missing image data.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/src/db/pg-schema.ts, line 263:

<comment>`message_part` lacks a constraint requiring `mediaType` and `imageId` when `type = 'file'`, allowing invalid file parts to be stored and later rendered with missing image data.</comment>

<file context>
@@ -258,6 +258,10 @@ export const messagePart = pgTable(
 		providerMetadata: jsonb('provider_metadata').$type<ProviderMetadata>(),
+
+		// file/image columns
+		mediaType: text('media_type'),
+		imageId: text('image_id').references(() => messageImage.id, { onDelete: 'set null' }),
 	},
</file context>
Fix with Cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants