Spaces:
Sleeping
Sleeping
| "use client"; | |
| import { useEffect, useRef, useState } from "react"; | |
| import { | |
| ArrowUp, | |
| FileText, | |
| ListChecks, | |
| Quote, | |
| ScanSearch, | |
| Sparkles, | |
| SquareStack, | |
| StopCircle, | |
| } from "lucide-react"; | |
| import ReactMarkdown from "react-markdown"; | |
| import remarkGfm from "remark-gfm"; | |
| import { streamChat } from "@/lib/api"; | |
| import type { Citation, DocumentDetail } from "@/lib/types"; | |
| interface Msg { | |
| role: "user" | "assistant"; | |
| content: string; | |
| citations?: Citation[]; | |
| } | |
| const SUGGESTIONS = [ | |
| { icon: SquareStack, text: "Summarize this document" }, | |
| { icon: ScanSearch, text: "What type of document is this?" }, | |
| { icon: ListChecks, text: "Extract all the key fields" }, | |
| { icon: Sparkles, text: "Are there any missing fields or anomalies?" }, | |
| ]; | |
| export default function ChatPanel({ | |
| documentId, | |
| detail, | |
| provider, | |
| onCitationClick, | |
| }: { | |
| documentId: string | null; | |
| detail: DocumentDetail | null; | |
| provider: string | null; | |
| onCitationClick: (c: Citation) => void; | |
| }) { | |
| const [messages, setMessages] = useState<Msg[]>([]); | |
| const [input, setInput] = useState(""); | |
| const [busy, setBusy] = useState(false); | |
| const abortRef = useRef<AbortController | null>(null); | |
| const scrollRef = useRef<HTMLDivElement>(null); | |
| const taRef = useRef<HTMLTextAreaElement>(null); | |
| useEffect(() => setMessages([]), [documentId]); | |
| useEffect(() => { | |
| scrollRef.current?.scrollTo({ | |
| top: scrollRef.current.scrollHeight, | |
| behavior: "smooth", | |
| }); | |
| }, [messages, busy]); | |
| // autosize composer | |
| useEffect(() => { | |
| const ta = taRef.current; | |
| if (!ta) return; | |
| ta.style.height = "auto"; | |
| ta.style.height = Math.min(ta.scrollHeight, 140) + "px"; | |
| }, [input]); | |
| const send = async (text: string) => { | |
| const q = text.trim(); | |
| if (!q || busy) return; | |
| const history = [...messages, { role: "user" as const, content: q }]; | |
| setMessages([...history, { role: "assistant", content: "" }]); | |
| setInput(""); | |
| setBusy(true); | |
| abortRef.current = new AbortController(); | |
| await streamChat( | |
| { | |
| document_id: documentId, | |
| provider, | |
| messages: history.map((m) => ({ role: m.role, content: m.content })), | |
| }, | |
| { | |
| onToken: (t) => | |
| setMessages((prev) => { | |
| const next = [...prev]; | |
| const last = next[next.length - 1]; | |
| next[next.length - 1] = { ...last, content: last.content + t }; | |
| return next; | |
| }), | |
| onCitations: (c) => | |
| setMessages((prev) => { | |
| const next = [...prev]; | |
| next[next.length - 1] = { ...next[next.length - 1], citations: c }; | |
| return next; | |
| }), | |
| onDone: () => setBusy(false), | |
| onError: (msg) => | |
| setMessages((prev) => { | |
| const next = [...prev]; | |
| const last = next[next.length - 1]; | |
| next[next.length - 1] = { | |
| role: "assistant", | |
| content: (last.content || "") + `\n\n_⚠️ ${msg}_`, | |
| }; | |
| return next; | |
| }), | |
| }, | |
| abortRef.current.signal, | |
| ); | |
| setBusy(false); | |
| }; | |
| const stop = () => { | |
| abortRef.current?.abort(); | |
| setBusy(false); | |
| }; | |
| const ready = detail?.status === "ready"; | |
| const lastIsStreaming = busy; | |
| return ( | |
| <div className="flex h-full flex-col"> | |
| {/* messages */} | |
| <div ref={scrollRef} className="min-h-0 flex-1 space-y-5 overflow-y-auto px-4 py-5"> | |
| {messages.length === 0 && ( | |
| <div className="flex h-full flex-col items-center justify-center px-2 text-center"> | |
| <div className="relative mb-4"> | |
| <div className="absolute inset-0 animate-pulse-soft rounded-2xl bg-brand-500/20 blur-xl" /> | |
| <div className="relative flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-brand-400/30 to-brand-700/10 ring-1 ring-brand-500/30"> | |
| <Sparkles className="text-brand-200" size={24} /> | |
| </div> | |
| </div> | |
| <h2 className="text-[15px] font-semibold text-white"> | |
| {documentId ? "Ask about this document" : "Upload a document to begin"} | |
| </h2> | |
| <p className="mt-1.5 max-w-[280px] text-xs leading-relaxed text-white/45"> | |
| {documentId | |
| ? "I can summarize, classify, extract structured data, and answer questions — each grounded with clickable citations." | |
| : "Drop a PDF, Word file, or image in the left rail. I'll parse it, then you can chat about it."} | |
| </p> | |
| {documentId && ( | |
| <div className="mt-6 grid w-full max-w-[320px] gap-2"> | |
| {SUGGESTIONS.map((s) => { | |
| const Icon = s.icon; | |
| return ( | |
| <button | |
| key={s.text} | |
| onClick={() => send(s.text)} | |
| disabled={!ready} | |
| className="group flex items-center gap-2.5 rounded-xl border border-white/[0.06] bg-surface-850/60 px-3 py-2.5 text-left text-xs text-white/70 transition hover:border-brand-500/40 hover:bg-surface-800 hover:text-white disabled:opacity-40" | |
| > | |
| <Icon | |
| size={14} | |
| className="text-white/35 transition group-hover:text-brand-300" | |
| /> | |
| {s.text} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| {documentId && !ready && ( | |
| <p className="mt-5 flex items-center gap-1.5 text-[11px] text-brand-300"> | |
| <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-brand-400" /> | |
| Processing… you can chat once it's ready. | |
| </p> | |
| )} | |
| </div> | |
| )} | |
| {messages.map((m, i) => { | |
| const streaming = lastIsStreaming && i === messages.length - 1; | |
| return ( | |
| <div key={i} className="animate-fade-up"> | |
| {m.role === "user" ? ( | |
| <div className="flex justify-end"> | |
| <div className="max-w-[85%] rounded-2xl rounded-br-md bg-gradient-to-br from-brand-500 to-brand-600 px-3.5 py-2.5 text-sm text-white shadow-soft"> | |
| {m.content} | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="flex gap-2.5"> | |
| <div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-brand-400 to-brand-700 ring-1 ring-white/10"> | |
| <Sparkles size={13} className="text-white" /> | |
| </div> | |
| <div className="min-w-0 flex-1"> | |
| <div className="rounded-2xl rounded-tl-md border border-white/[0.06] bg-surface-850/80 px-3.5 py-2.5"> | |
| {!m.content && streaming ? ( | |
| <span className="flex gap-1 py-1.5"> | |
| <span className="typing-dot h-1.5 w-1.5 rounded-full bg-white/50" /> | |
| <span className="typing-dot h-1.5 w-1.5 rounded-full bg-white/50" /> | |
| <span className="typing-dot h-1.5 w-1.5 rounded-full bg-white/50" /> | |
| </span> | |
| ) : ( | |
| <div className={`prose-chat ${streaming ? "caret" : ""}`}> | |
| <ReactMarkdown remarkPlugins={[remarkGfm]}> | |
| {m.content} | |
| </ReactMarkdown> | |
| </div> | |
| )} | |
| </div> | |
| {m.citations && m.citations.length > 0 && ( | |
| <div className="mt-2 flex flex-wrap gap-1.5"> | |
| <span className="flex items-center gap-1 text-[10px] font-medium text-white/30"> | |
| <Quote size={9} /> Sources: | |
| </span> | |
| {m.citations.map((c, ci) => ( | |
| <button | |
| key={ci} | |
| onClick={() => onCitationClick(c)} | |
| title={c.text} | |
| className="flex items-center gap-1 rounded-md bg-brand-500/10 px-1.5 py-0.5 text-[10px] font-medium text-brand-200 ring-1 ring-brand-500/25 transition hover:bg-brand-500/20" | |
| > | |
| <FileText size={9} />p{c.page + 1} | |
| <span className="text-brand-300/60">[{ci + 1}]</span> | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| {/* composer */} | |
| <div className="px-3 pb-3 pt-1"> | |
| <div className="flex items-end gap-2 rounded-2xl border border-white/[0.08] bg-surface-850/80 p-2 shadow-soft transition focus-within:border-brand-500/50 focus-within:shadow-glow"> | |
| <textarea | |
| ref={taRef} | |
| rows={1} | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter" && !e.shiftKey) { | |
| e.preventDefault(); | |
| send(input); | |
| } | |
| }} | |
| placeholder={ | |
| documentId ? "Ask anything about this document…" : "Upload a document first…" | |
| } | |
| disabled={!documentId} | |
| className="max-h-36 min-h-[26px] flex-1 resize-none bg-transparent px-1.5 py-1 text-sm text-white placeholder:text-white/30 focus:outline-none disabled:opacity-50" | |
| /> | |
| {busy ? ( | |
| <button | |
| onClick={stop} | |
| className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-rose-500/20 text-rose-300 transition hover:bg-rose-500/30" | |
| title="Stop" | |
| > | |
| <StopCircle size={16} /> | |
| </button> | |
| ) : ( | |
| <button | |
| onClick={() => send(input)} | |
| disabled={!input.trim() || !documentId} | |
| className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-600 text-white transition hover:from-brand-400 hover:to-brand-500 disabled:cursor-not-allowed disabled:opacity-30" | |
| title="Send" | |
| > | |
| <ArrowUp size={16} /> | |
| </button> | |
| )} | |
| </div> | |
| <p className="mt-1.5 px-2 text-center text-[10px] text-white/25"> | |
| Grounded in your document · click a source to highlight it ·{" "} | |
| <kbd className="rounded bg-white/[0.06] px-1 text-white/40">Enter</kbd> to send | |
| </p> | |
| </div> | |
| </div> | |
| ); | |
| } | |