Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions apps/rowboat/app/api/uploaded-images/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from 'next/server';
import { S3Client, GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { Readable } from 'stream';

// Serves uploaded images from S3 by UUID-only path: /api/uploaded-images/{id}
// Reconstructs the S3 key using the same sharding logic as image upload.
export async function GET(request: NextRequest, props: { params: Promise<{ id: string }> }) {
const params = await props.params;
const id = params.id;
if (!id) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
}

const bucket = process.env.RAG_UPLOADS_S3_BUCKET || '';
if (!bucket) {
return NextResponse.json({ error: 'S3 bucket not configured' }, { status: 500 });
}

const region = process.env.RAG_UPLOADS_S3_REGION || 'us-east-1';
const s3 = new S3Client({
region,
credentials: process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY ? {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
} as any : undefined,
});

// Reconstruct directory sharding from last two characters of UUID
const last2 = id.slice(-2).padStart(2, '0');
const dirA = last2.charAt(0);
const dirB = last2.charAt(1);
const baseKey = `uploaded_images/${dirA}/${dirB}/${id}`;

// Try known extensions in order
const exts = ['.png', '.jpg', '.webp', '.bin'];
let foundExt: string | null = null;
for (const ext of exts) {
try {
await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: `${baseKey}${ext}` }));
foundExt = ext;
break;
} catch {
// continue
}
}

if (!foundExt) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}

const key = `${baseKey}${foundExt}`;
const filename = `${id}${foundExt}`;
try {
const resp = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
const contentType = resp.ContentType || 'application/octet-stream';
const body = resp.Body as any;
const webStream = body?.transformToWebStream
? body.transformToWebStream()
: (Readable as any)?.toWeb
? (Readable as any).toWeb(body)
: body;
return new NextResponse(webStream, {
status: 200,
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
'Content-Disposition': `inline; filename="${filename}"`,
},
});
} catch (e) {
console.error('S3 get error', e);
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
}

85 changes: 85 additions & 0 deletions apps/rowboat/app/api/uploaded-images/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from 'next/server';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import crypto from 'crypto';
import { tempBinaryCache } from '@/src/application/services/temp-binary-cache';
import { GoogleGenerativeAI } from '@google/generative-ai';

// POST /api/uploaded-images
// Accepts an image file (multipart/form-data, field name: "file")
// Stores it either in S3 (if configured) under uploaded_images/<a>/<b>/<uuid>.<ext>
// or in the in-memory temp cache. Returns a JSON with a URL that the agent can fetch.
export async function POST(request: NextRequest) {
try {
const contentType = request.headers.get('content-type') || '';
if (!contentType.includes('multipart/form-data')) {
return NextResponse.json({ error: 'Expected multipart/form-data' }, { status: 400 });
}

const form = await request.formData();
const file = form.get('file') as File | null;
if (!file) {
return NextResponse.json({ error: 'Missing file' }, { status: 400 });
}

const arrayBuf = await file.arrayBuffer();
const buf = Buffer.from(arrayBuf);
const mime = file.type || 'application/octet-stream';

// Optionally describe image with Gemini
let descriptionMarkdown: string | null = null;
try {
const apiKey = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY || '';
if (apiKey) {
const genAI = new GoogleGenerativeAI(apiKey);
const model = genAI.getGenerativeModel({ model: 'gemini-2.5-flash' });
const prompt = 'Describe this image in concise, high-quality Markdown. Focus on key objects, text, layout, style, colors, and any notable details. Do not include extra commentary or instructions.';
const result = await model.generateContent([
{ inlineData: { data: buf.toString('base64'), mimeType: mime } },
prompt,
]);
descriptionMarkdown = result.response?.text?.() || null;
}
} catch (e) {
console.warn('Gemini description failed', e);
}

// If S3 configured, upload there
const s3Bucket = process.env.RAG_UPLOADS_S3_BUCKET || '';
if (s3Bucket) {
const s3Region = process.env.RAG_UPLOADS_S3_REGION || 'us-east-1';
const s3 = new S3Client({
region: s3Region,
credentials: process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY ? {
accessKeyId: process.env.AWS_ACCESS_KEY_ID as string,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY as string,
} : undefined,
});

const ext = mime === 'image/jpeg' ? '.jpg' : mime === 'image/webp' ? '.webp' : mime === 'image/png' ? '.png' : '.bin';
const imageId = crypto.randomUUID();
const last2 = imageId.slice(-2).padStart(2, '0');
const dirA = last2.charAt(0);
const dirB = last2.charAt(1);
const key = `uploaded_images/${dirA}/${dirB}/${imageId}${ext}`;

await s3.send(new PutObjectCommand({
Bucket: s3Bucket,
Key: key,
Body: buf,
ContentType: mime,
}));

const url = `/api/uploaded-images/${imageId}`;
return NextResponse.json({ url, storage: 's3', id: imageId, mimeType: mime, description: descriptionMarkdown });
}

// Otherwise store in temp cache and return temp URL
const ttlSec = 10 * 60; // 10 minutes
const id = tempBinaryCache.put(buf, mime, ttlSec * 1000);
const url = `/api/tmp-images/${id}`;
return NextResponse.json({ url, storage: 'temp', id, mimeType: mime, expiresInSec: ttlSec, description: descriptionMarkdown });
} catch (e) {
console.error('upload image error', e);
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,24 @@ export function Chat({
}
}, []);

function handleUserMessage(prompt: string) {
const updatedMessages: z.infer<typeof Message>[] = [...messages, {
role: 'user',
content: prompt,
}];
function handleUserMessage(prompt: string, imageDebug?: { url: string; description?: string | null }) {
// Insert an internal-only debug message with image URL/markdown (if provided),
// then the actual user message last so streaming triggers correctly.
const debugMessages: z.infer<typeof Message>[] = imageDebug ? [{
role: 'assistant',
content: `Image Description\n\nURL: ${imageDebug.url}\n\n${imageDebug.description ? imageDebug.description : ''}`.trim(),
agentName: 'Image Description',
responseType: 'internal',
} as any] : [];

const updatedMessages: z.infer<typeof Message>[] = [
...messages,
...debugMessages,
{
role: 'user',
content: prompt,
} as any,
];
setMessages(updatedMessages);
setError(null);
setIsLastInteracted(true);
Expand Down Expand Up @@ -229,9 +242,46 @@ export function Chat({
}

// set up a cached turn
// Merge-at-send: if the immediately preceding message is our internal
// Image Description debug message, append its details (URL/markdown)
// to the outgoing user message content, without changing the UI.
const last = messages[messages.length - 1];
let mergedContent = (typeof last?.content === 'string' ? last.content : '') || '';
if (messages.length >= 2) {
const prev = messages[messages.length - 2] as any;
const isImageDebug = prev && prev.role === 'assistant' && prev.responseType === 'internal' && prev.agentName === 'Image Description' && typeof prev.content === 'string';
if (isImageDebug) {
// Expect prev.content to have: "Image Description\n\nURL: <url>\n\n<markdown>"
// Extract URL and markdown blocks for a clean append
const content = prev.content as string;
let url: string | undefined;
let markdown: string | undefined;
const urlMatch = content.match(/URL:\s*(\S+)/i);
if (urlMatch) url = urlMatch[1];
// markdown is whatever comes after the blank line following URL
const parts = content.split(/\n\n/);
if (parts.length >= 3) {
markdown = parts.slice(2).join('\n\n').trim();
}
const appendSections: string[] = [];
if (url) appendSections.push(`The user uploaded an image. URL: ${url}`);
if (markdown) appendSections.push(`Image description (markdown):\n\n${markdown}`);
if (appendSections.length > 0) {
mergedContent = [mergedContent, appendSections.join('\n\n')]
.filter(Boolean)
.join('\n\n');
}
}
}

const messagesToSend: z.infer<typeof Message>[] = [{
role: 'user',
content: mergedContent,
} as any];

const response = await createCachedTurn({
conversationId: conversationId.current,
messages: messages.slice(-1), // only send the last message
messages: messagesToSend, // send merged content only
});
if (ignore) {
return;
Expand Down Expand Up @@ -500,4 +550,4 @@ export function Chat({
/>
</div>
);
}
}
Loading