added upload button

This commit is contained in:
arkml 2025-09-19 14:53:35 +05:30
parent 109997ca2e
commit 38e52a2e66
3 changed files with 209 additions and 2 deletions

View file

@ -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 });
}
}

View file

@ -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/<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';
// 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 });
}
}

View file

@ -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<HTMLTextAreaElement>(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 (
<div className="relative group">
{/* Keyboard shortcut hint */}
@ -66,6 +92,25 @@ export function ComposeBoxPlayground({
{/* Outer container with padding */}
<div className="rounded-2xl border-[1.5px] border-gray-200 dark:border-[#2a2d31] p-3 relative
bg-white dark:bg-[#1e2023] flex items-end gap-2">
{/* Upload button */}
<label className={`
flex items-center justify-center w-9 h-9 rounded-lg cursor-pointer
${uploading ? 'bg-gray-100 dark:bg-gray-800 text-gray-400' : 'bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300'}
transition-colors
`}>
<input
type="file"
accept="image/*"
className="hidden"
disabled={disabled || loading || uploading}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleImagePicked(f);
e.currentTarget.value = '';
}}
/>
{uploading ? <Spinner size="sm" /> : <ImageIcon size={16} />}
</label>
{/* Textarea */}
<div className="flex-1">
<Textarea
@ -99,7 +144,7 @@ export function ComposeBoxPlayground({
<Button
size="sm"
isIconOnly
disabled={disabled || (loading ? false : !input.trim())}
disabled={disabled || uploading || (loading ? false : !input.trim())}
onPress={loading ? onCancel : handleInput}
className={`
transition-all duration-200
@ -163,4 +208,24 @@ function StopIcon({ size, className }: { size: number, className?: string }) {
<rect x="6" y="6" width="12" height="12" rx="1" />
</svg>
);
}
}
function ImageIcon({ size, className }: { size: number, className?: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<path d="M21 15l-5-5L5 21" />
</svg>
);
}