Jai-rathore29's picture
Deploy: DocAgent backend (deterministic date-anomaly fix)
f65e025
Raw
History Blame Contribute Delete
10.9 kB
"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>
);
}