diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts new file mode 100644 index 0000000..e463d6d --- /dev/null +++ b/app/api/upload/route.ts @@ -0,0 +1,101 @@ +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { headers } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; +import { revalidatePath } from "next/cache"; + +const BACKEND_API_BASE = + process.env.BACKEND_API_BASE ?? "http://127.0.0.1:8000/api/v1"; + +/** + * Streaming upload proxy – authenticates the user, verifies chat + * ownership, then pipes the raw request body directly to the Python + * backend without buffering the file into Node.js memory. + */ +export async function POST(request: NextRequest) { + // 1. Authenticate + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // 2. Read chatId from query params + const chatId = request.nextUrl.searchParams.get("chatId"); + + if (!chatId) { + return NextResponse.json( + { error: "chatId query parameter is required" }, + { status: 400 }, + ); + } + + // 3. Verify chat ownership + const chat = await prisma.chat.findUnique({ + where: { id: chatId }, + }); + + if (!chat || chat.userId !== session.user.id) { + return NextResponse.json( + { error: "Chat not found or unauthorized" }, + { status: 403 }, + ); + } + + // 4. Stream the request body directly to the Python backend. + // The verified chatId is passed as a query param so the backend + // derives the target chat solely from the server-verified value. + const contentType = request.headers.get("content-type"); + const backendUrl = `${BACKEND_API_BASE}/ingest?chat_id=${encodeURIComponent(chatId)}`; + + let backendResponse: Response; + try { + backendResponse = await fetch(backendUrl, { + method: "POST", + body: request.body, + headers: { + ...(contentType ? { "Content-Type": contentType } : {}), + }, + // @ts-expect-error -- Node 18+ supports duplex for streaming request bodies + duplex: "half", + }); + } catch (error) { + console.error("Python ingestion transport error:", error); + return NextResponse.json({ error: "Backend unavailable" }, { status: 502 }); + } + + if (!backendResponse.ok) { + const errText = await backendResponse.text(); + console.error("Python ingestion error:", errText); + return NextResponse.json( + { error: "Backend processing failed", detail: errText }, + { status: backendResponse.status }, + ); + } + + let result; + try { + result = await backendResponse.json(); + } catch { + console.error("Failed to parse Python backend response as JSON"); + return NextResponse.json( + { error: "Invalid response from backend" }, + { status: 502 }, + ); + } + + // 5. Increment document count in Prisma + await prisma.chat.update({ + where: { id: chatId }, + data: { + documentCount: { increment: 1 }, + updatedAt: new Date(), + }, + }); + + revalidatePath("/chat"); + + return NextResponse.json({ success: true, result }); +} diff --git a/app/layout.tsx b/app/layout.tsx index 7dd0052..2f6e440 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -36,7 +36,7 @@ export default function RootLayout({ > {children} - + ); diff --git a/backend/api/v1/ingestion_routes.py b/backend/api/v1/ingestion_routes.py index 7e43408..7b0633b 100644 --- a/backend/api/v1/ingestion_routes.py +++ b/backend/api/v1/ingestion_routes.py @@ -4,7 +4,7 @@ """ import logging -from fastapi import APIRouter, HTTPException, UploadFile, File, Form, Depends +from fastapi import APIRouter, HTTPException, UploadFile, File, Query, Depends from datetime import datetime from ...schemas.ingestion import IngestResponse, IngestError @@ -44,14 +44,14 @@ def get_service() -> IngestionService: ), ) async def ingest_document( - chat_id: str = Form(..., description="UUID of the chat session"), + chat_id: str = Query(..., description="UUID of the chat session"), file: UploadFile = File(..., description="Document file to ingest"), service: IngestionService = Depends(get_service), ) -> IngestResponse: """ Ingest a document into a chat-specific collection. - - **chat_id**: UUID of the chat session (form field) + - **chat_id**: UUID of the chat session (query parameter) - **file**: Document file (PDF, TXT, or MD) The document will be: diff --git a/components/chat-area.tsx b/components/chat-area.tsx index 008eb6a..c80b054 100644 --- a/components/chat-area.tsx +++ b/components/chat-area.tsx @@ -3,7 +3,7 @@ import React, { useState, useRef, useEffect } from "react"; import { useRouter } from "next/navigation"; -import { Send, FileUp, Paperclip, File, X } from "lucide-react"; +import { Send, FileUp, Paperclip, File, X, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -19,11 +19,7 @@ import { } from "@/lib/date-format"; // Import shared types import type { Chat as ChatModel, Message } from "@/lib/types"; -import { - sendMessageAction, - uploadDocumentAction, - deleteDocumentAction, -} from "@/lib/actions/chat"; +import { sendMessageAction, deleteDocumentAction } from "@/lib/actions/chat"; import { toast } from "sonner"; import { Greeting } from "@/components/greeting"; @@ -41,6 +37,7 @@ interface UploadedDocument { name: string; size: string; filename: string; + uploading?: boolean; } export function ChatArea({ @@ -97,6 +94,9 @@ export function ChatArea({ } } catch (error) { console.error("Failed to send message:", error); + toast.error("Failed to send message", { + description: "Could not reach the server. Please try again.", + }); } finally { setIsGenerating(false); // Removed router.refresh() because the Server Action handles revalidation natively! @@ -120,14 +120,14 @@ export function ChatArea({ const files = e.target.files; if (!files || files.length === 0) return; - const MAX_SIZE_BYTES = 1 * 1024 * 1024; // 1 MB + const MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB // Filter out oversized files and show a toast for each rejected one const validFiles: File[] = []; for (const file of Array.from(files)) { if (file.size > MAX_SIZE_BYTES) { toast.error(`"${file.name}" is too large`, { - description: `Files must be under 1 MB. This file is ${(file.size / (1024 * 1024)).toFixed(2)} MB.`, + description: `Files must be under ${(MAX_SIZE_BYTES / (1024 * 1024)).toFixed(2)} MB. This file is ${(file.size / (1024 * 1024)).toFixed(2)} MB.`, }); } else { validFiles.push(file); @@ -148,6 +148,7 @@ export function ChatArea({ name: file.name, size: (file.size / 1024).toFixed(2) + " KB", filename: file.name, + uploading: true, } satisfies UploadedDocument, })); @@ -156,25 +157,45 @@ export function ChatArea({ ...uploadQueue.map((item) => item.optimisticDocument), ]); - // Upload to backend + // Upload to backend via streaming API route for (const { file, optimisticDocument } of uploadQueue) { const formData = new FormData(); formData.append("file", file); - formData.append("chat_id", currentChat?.id || "default-chat"); try { - const result = await uploadDocumentAction(formData); - console.log("File uploaded successfully:", result); + const uploadPromise = fetch( + `/api/upload?chatId=${encodeURIComponent(currentChat?.id || "default-chat")}`, + { method: "POST", body: formData }, + ).then(async (response) => { + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.error || "Upload failed"); + } + return response.json(); + }); - // Trigger navigation refresh to update document count in layout - router.refresh(); - } catch (error) { - console.error("Error uploading file:", error); - // Remove failed document from optimistic UI. - setDocuments((prev) => - prev.filter((d) => d.id !== optimisticDocument.id), - ); - toast.error(`Failed to upload "${file.name}"`); + toast.promise(uploadPromise, { + loading: `Uploading "${file.name}"...`, + success: () => { + setDocuments((prev) => + prev.map((d) => + d.id === optimisticDocument.id ? { ...d, uploading: false } : d, + ), + ); + router.refresh(); + return `"${file.name}" uploaded successfully`; + }, + error: (err) => { + setDocuments((prev) => + prev.filter((d) => d.id !== optimisticDocument.id), + ); + return `Failed to upload "${file.name}": ${err.message}`; + }, + }); + + await uploadPromise; + } catch { + console.error("Failed to upload file"); } } @@ -193,6 +214,9 @@ export function ChatArea({ router.refresh(); } catch (error) { console.error("Failed to delete document from collection:", error); + toast.error(`Failed to remove "${doc.name}"`, { + description: "Could not delete the document. Please try again.", + }); } } }; @@ -260,7 +284,11 @@ export function ChatArea({ key={doc.id} className="group flex items-center gap-2 rounded-lg bg-accent px-3 py-1.5 text-sm" > - + {doc.uploading ? ( + + ) : ( + + )} {doc.name} @@ -321,7 +349,10 @@ export function ChatArea({ {showDateDivider && ts !== null && (
- + {formatChatLongDate(ts)} @@ -378,7 +409,9 @@ export function ChatArea({ {message.role === "assistant" ? "AI" : "You"} {ts !== null ? ( - {formatChatTime(ts)} + + {formatChatTime(ts)} + ) : ( {message.timestamp} )} diff --git a/lib/actions/chat.ts b/lib/actions/chat.ts index 3947370..0a213a6 100644 --- a/lib/actions/chat.ts +++ b/lib/actions/chat.ts @@ -137,57 +137,8 @@ export async function sendMessageAction(chatId: string, content: string) { }; } -export async function uploadDocumentAction(formData: FormData) { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session) { - throw new Error("Unauthorized"); - } - - const chatId = formData.get("chat_id") as string; - const file = formData.get("file"); - - if (!chatId || !file) { - throw new Error("chat_id and file are required"); - } - - // Verify chat ownership - const chat = await prisma.chat.findUnique({ - where: { id: chatId }, - }); - - if (!chat || chat.userId !== session.user.id) { - throw new Error("Chat not found or unauthorized"); - } - // Forward the file to Python ML backend - const response = await fetch(`${BACKEND_API_BASE}/ingest`, { - method: "POST", - body: formData, - }); - if (!response.ok) { - const errText = await response.text(); - console.error("Python ingestion error:", errText); - throw new Error(`Backend processing failed: ${errText}`); - } - - const result = await response.json(); - - // Increment document count in Next.js Prisma - await prisma.chat.update({ - where: { id: chatId }, - data: { - documentCount: { increment: 1 }, - updatedAt: new Date(), - }, - }); - - revalidatePath("/chat"); - return { success: true, result }; -} export async function deleteDocumentAction(chatId: string, filename: string) { const session = await auth.api.getSession({