diff --git a/app/(dashboard)/document-chat/[id]/page.tsx b/app/(dashboard)/document-chat/[id]/page.tsx new file mode 100644 index 0000000..dd92b72 --- /dev/null +++ b/app/(dashboard)/document-chat/[id]/page.tsx @@ -0,0 +1,48 @@ +import { createServerClient } from "@supabase/ssr" +import { cookies } from "next/headers" +import { notFound } from "next/navigation" +import { DocumentChatUI } from "@/components/dashboard/document-chat-ui" + +export default async function DocumentChatDetailPage({ + params +}: { + params: { id: string } +}) { + const cookieStore = await cookies() + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + get(name) { return cookieStore.get(name)?.value }, + }, + } + ) + + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return notFound() + } + + const { data: doc, error } = await supabase + .from('documents') + .select('*') + .eq('id', params.id) + .eq('user_id', user.id) + .single() + + if (error || !doc) { + return notFound() + } + + return ( +
+ +
+ ) +} diff --git a/app/(dashboard)/document-chat/page.tsx b/app/(dashboard)/document-chat/page.tsx new file mode 100644 index 0000000..cf5fc64 --- /dev/null +++ b/app/(dashboard)/document-chat/page.tsx @@ -0,0 +1,146 @@ +"use client" + +import { useState, useEffect } from "react" +import { useRouter } from "next/navigation" +import { createBrowserClient } from "@supabase/ssr" +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Loader2, FileText, Upload, Plus } from "lucide-react" + +export default function DocumentChatPage() { + const router = useRouter() + const [documents, setDocuments] = useState([]) + const [loading, setLoading] = useState(true) + const [uploading, setUploading] = useState(false) + const [file, setFile] = useState(null) + + const supabase = createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ) + + useEffect(() => { + fetchDocuments() + }, []) + + async function fetchDocuments() { + setLoading(true) + const { data: { user } } = await supabase.auth.getUser() + if (user) { + const { data, error } = await supabase + .from('documents') + .select('*') + .eq('user_id', user.id) + .order('created_at', { ascending: false }) + + if (!error && data) { + setDocuments(data) + } + } + setLoading(false) + } + + async function handleUpload(e: React.FormEvent) { + e.preventDefault() + if (!file) return + + setUploading(true) + const formData = new FormData() + formData.append("file", file) + + try { + const res = await fetch("/api/documents/upload", { + method: "POST", + body: formData, + }) + + if (res.ok) { + const data = await res.json() + if (data.documentId) { + router.push(`/document-chat/${data.documentId}`) + } + } else { + console.error("Upload failed") + } + } catch (err) { + console.error(err) + } finally { + setUploading(false) + setFile(null) + } + } + + return ( +
+
+
+

Document Chat

+

+ Upload a PDF and chat with it to extract insights, summaries, and answers. +

+
+
+ + + + Upload New Document + Select a PDF file to analyze. + + +
+ setFile(e.target.files?.[0] || null)} + className="max-w-sm" + disabled={uploading} + /> + +
+
+
+ +
+

Your Documents

+ {loading ? ( +
+ +
+ ) : documents.length === 0 ? ( + + + +

No documents yet

+

+ Upload your first PDF document above to start chatting with it. +

+
+
+ ) : ( +
+ {documents.map((doc) => ( + router.push(`/document-chat/${doc.id}`)}> + + + {doc.title} + + + + {new Date(doc.created_at).toLocaleDateString()} + + + + ))} +
+ )} +
+
+ ) +} diff --git a/app/api/documents/chat/route.ts b/app/api/documents/chat/route.ts new file mode 100644 index 0000000..82da7c3 --- /dev/null +++ b/app/api/documents/chat/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from "next/server" +import { createServerClient } from "@supabase/ssr" +import { generateEmbedding, generateAIResponse } from "@/lib/ai" + +export const maxDuration = 60 + +export async function POST(req: NextRequest) { + try { + const { documentId, message } = await req.json() + + if (!documentId || !message) { + return NextResponse.json({ error: "Missing documentId or message" }, { status: 400 }) + } + + const res = NextResponse.next() + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + get(name) { return req.cookies.get(name)?.value }, + set(name, value, options) { res.cookies.set(name, value, options) }, + remove(name, options) { res.cookies.set(name, "", { ...options, maxAge: 0 }) }, + }, + } + ) + + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + // Verify document belongs to user + const { data: doc, error: docError } = await supabase + .from('documents') + .select('id') + .eq('id', documentId) + .eq('user_id', user.id) + .single() + + if (docError || !doc) { + return NextResponse.json({ error: "Document not found or unauthorized" }, { status: 404 }) + } + + // Embed the user's question + const queryEmbedding = await generateEmbedding(message) + + // Call match_document_chunks RPC + const { data: matchData, error: matchError } = await supabase.rpc('match_document_chunks', { + query_embedding: queryEmbedding, + match_threshold: 0.5, + match_count: 5, + doc_id: documentId + }) + + if (matchError) { + console.error("Match error:", matchError) + return NextResponse.json({ error: "Failed to search document" }, { status: 500 }) + } + + const contextText = matchData && matchData.length > 0 + ? matchData.map((m: any) => m.content).join("\n\n---\n\n") + : "No highly relevant context found in this document for the query." + + // Generate AI Response with the retrieved context + const aiPrompt = `Based on the following excerpts from a document, answer the user's question. +If the answer is not in the context, say "I couldn't find the answer in this document." but try to be helpful if possible. + +Document Context: +${contextText} + +User's Question: ${message}` + + const aiResponse = await generateAIResponse(aiPrompt, { mode: 'chat' }) + + return NextResponse.json({ reply: aiResponse }) + } catch (err) { + console.error("Chat error:", err) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} diff --git a/app/api/documents/upload/route.ts b/app/api/documents/upload/route.ts new file mode 100644 index 0000000..92be12a --- /dev/null +++ b/app/api/documents/upload/route.ts @@ -0,0 +1,122 @@ +import { NextRequest, NextResponse } from "next/server" +import { createServerClient } from "@supabase/ssr" +import pdfParse from "pdf-parse/lib/pdf-parse" +import { generateEmbedding } from "@/lib/ai" + +export const maxDuration = 60 + +export async function POST(req: NextRequest) { + try { + const formData = await req.formData() + const file = formData.get("file") as File | null + if (!file) { + return NextResponse.json({ error: "No file provided" }, { status: 400 }) + } + + const res = NextResponse.next() + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + get(name) { return req.cookies.get(name)?.value }, + set(name, value, options) { res.cookies.set(name, value, options) }, + remove(name, options) { res.cookies.set(name, "", { ...options, maxAge: 0 }) }, + }, + } + ) + + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + // 1. Upload file to Supabase Storage + const fileExt = file.name.split('.').pop() + const fileName = `${user.id}/${Date.now()}.${fileExt}` + + // Ensure you have a 'documents' bucket in Supabase! + const { data: uploadData, error: uploadError } = await supabase.storage + .from('documents') + .upload(fileName, file) + + if (uploadError) { + console.error("Storage upload error:", uploadError) + return NextResponse.json({ error: "Failed to upload file to storage" }, { status: 500 }) + } + + const file_url = supabase.storage.from('documents').getPublicUrl(fileName).data.publicUrl + + // 2. Parse PDF Text + const arrayBuffer = await file.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + const pdfData = await pdfParse(buffer) + const text = pdfData.text + + // 3. Create document record in DB + const { data: doc, error: docError } = await supabase + .from('documents') + .insert({ + user_id: user.id, + title: file.name, + file_url: file_url + }) + .select('id') + .single() + + if (docError || !doc) { + console.error("Database insert error:", docError) + return NextResponse.json({ error: "Failed to create document record" }, { status: 500 }) + } + + // 4. Chunk text and generate embeddings + // Simple chunking by paragraphs or length + const chunkSize = 1000 + const chunks: string[] = [] + let currentChunk = "" + + const paragraphs = text.split('\n\n') + for (const p of paragraphs) { + if (currentChunk.length + p.length > chunkSize && currentChunk.length > 0) { + chunks.push(currentChunk) + currentChunk = "" + } + currentChunk += p + "\n\n" + } + if (currentChunk.trim().length > 0) { + chunks.push(currentChunk) + } + + // 5. Insert chunks into DB + const chunkPromises = chunks.filter(c => c.trim().length > 0).map(async (chunkContent) => { + try { + const embedding = await generateEmbedding(chunkContent) + return { + document_id: doc.id, + content: chunkContent, + embedding + } + } catch (err) { + console.error("Failed to generate embedding for chunk:", err) + return null + } + }) + + const resolvedChunks = (await Promise.all(chunkPromises)).filter(c => c !== null) + + if (resolvedChunks.length > 0) { + const { error: chunkError } = await supabase + .from('document_chunks') + .insert(resolvedChunks) + + if (chunkError) { + console.error("Failed to insert chunks:", chunkError) + } + } + + return NextResponse.json({ success: true, documentId: doc.id, fileUrl: file_url }) + } catch (err) { + console.error("Upload error:", err) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} diff --git a/components/dashboard/document-chat-ui.tsx b/components/dashboard/document-chat-ui.tsx new file mode 100644 index 0000000..d08fd57 --- /dev/null +++ b/components/dashboard/document-chat-ui.tsx @@ -0,0 +1,154 @@ +"use client" + +import { useState, useRef, useEffect } from "react" +import { Send, User, Bot, Loader2, ArrowLeft } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Avatar, AvatarFallback } from "@/components/ui/avatar" +import Link from "next/link" + +interface DocumentChatUIProps { + documentId: string + fileUrl: string + title: string +} + +interface Message { + id: string + role: "user" | "ai" + content: string +} + +export function DocumentChatUI({ documentId, fileUrl, title }: DocumentChatUIProps) { + const [messages, setMessages] = useState([ + { id: "1", role: "ai", content: `Hello! I've analyzed "${title}". What would you like to know?` } + ]) + const [input, setInput] = useState("") + const [loading, setLoading] = useState(false) + const scrollRef = useRef(null) + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollIntoView({ behavior: "smooth" }) + } + }, [messages]) + + async function handleSend(e: React.FormEvent) { + e.preventDefault() + if (!input.trim() || loading) return + + const userMessage = input.trim() + setInput("") + + setMessages(prev => [...prev, { id: Date.now().toString(), role: "user", content: userMessage }]) + setLoading(true) + + try { + const res = await fetch("/api/documents/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ documentId, message: userMessage }), + }) + + if (res.ok) { + const data = await res.json() + setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: "ai", content: data.reply }]) + } else { + setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: "ai", content: "Sorry, I encountered an error searching the document." }]) + } + } catch (err) { + console.error(err) + setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: "ai", content: "Connection error." }]) + } finally { + setLoading(false) + } + } + + return ( +
+ {/* Left side: PDF Viewer */} +
+
+ + + +
+