From 38e52a2e6633fc979a5a72f58f664ee48bb0706f Mon Sep 17 00:00:00 2001 From: arkml Date: Fri, 19 Sep 2025 14:53:35 +0530 Subject: [PATCH 01/19] added upload button --- .../app/api/uploaded-images/[id]/route.ts | 75 +++++++++++++++++++ apps/rowboat/app/api/uploaded-images/route.ts | 67 +++++++++++++++++ .../common/compose-box-playground.tsx | 69 ++++++++++++++++- 3 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 apps/rowboat/app/api/uploaded-images/[id]/route.ts create mode 100644 apps/rowboat/app/api/uploaded-images/route.ts diff --git a/apps/rowboat/app/api/uploaded-images/[id]/route.ts b/apps/rowboat/app/api/uploaded-images/[id]/route.ts new file mode 100644 index 000000000..9f841ba93 --- /dev/null +++ b/apps/rowboat/app/api/uploaded-images/[id]/route.ts @@ -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 }); + } +} + diff --git a/apps/rowboat/app/api/uploaded-images/route.ts b/apps/rowboat/app/api/uploaded-images/route.ts new file mode 100644 index 000000000..13ec14d56 --- /dev/null +++ b/apps/rowboat/app/api/uploaded-images/route.ts @@ -0,0 +1,67 @@ +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'; + +// POST /api/uploaded-images +// Accepts an image file (multipart/form-data, field name: "file") +// Stores it either in S3 (if configured) under uploaded_images///. +// 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'; + + // 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 }); + } + + // 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 }); + } catch (e) { + console.error('upload image error', e); + return NextResponse.json({ error: 'Upload failed' }, { status: 500 }); + } +} + diff --git a/apps/rowboat/components/common/compose-box-playground.tsx b/apps/rowboat/components/common/compose-box-playground.tsx index 2c43a4554..e0c941bd4 100644 --- a/apps/rowboat/components/common/compose-box-playground.tsx +++ b/apps/rowboat/components/common/compose-box-playground.tsx @@ -22,6 +22,7 @@ export function ComposeBoxPlayground({ onCancel, }: ComposeBoxPlaygroundProps) { const [input, setInput] = useState(''); + const [uploading, setUploading] = useState(false); const [isFocused, setIsFocused] = useState(false); const textareaRef = useRef(null); const previousMessagesLength = useRef(messages.length); @@ -55,6 +56,31 @@ export function ComposeBoxPlayground({ onFocus?.(); }; + async function handleImagePicked(file: File) { + if (!file) return; + try { + setUploading(true); + const form = new FormData(); + form.append('file', file); + const res = await fetch('/api/uploaded-images', { + method: 'POST', + body: form, + }); + if (!res.ok) { + throw new Error(`Upload failed: ${res.status}`); + } + const data = await res.json(); + const url: string | undefined = data?.url; + if (!url) throw new Error('No URL returned'); + handleUserMessage(`The user uploaded an image. URL: ${url}`); + } catch (e) { + console.error('Image upload failed', e); + alert('Image upload failed. Please try again.'); + } finally { + setUploading(false); + } + } + return (
{/* Keyboard shortcut hint */} @@ -66,6 +92,25 @@ export function ComposeBoxPlayground({ {/* Outer container with padding */}
+ {/* Upload button */} + {/* Textarea */}