507 lines
19 KiB
TypeScript
507 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import * as Dialog from "@radix-ui/react-dialog";
|
|
import { useEffect, useState, useCallback, useRef } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import {
|
|
MessageSquare, X, Send, Plus, MessageSquareText, Lock,
|
|
Wrench, ArrowUpRight, ChevronDown, ChevronRight,
|
|
} from "lucide-react";
|
|
import Link from "next/link";
|
|
import { createClient, isSupabaseConfigured } from "@/lib/supabase/client";
|
|
import type { User } from "@supabase/supabase-js";
|
|
import { MarkdownBody } from "./markdown-body";
|
|
|
|
interface ChatBubbleProps {
|
|
context: { doc_id?: string; page_id?: string };
|
|
}
|
|
|
|
interface SessionRow {
|
|
id: string;
|
|
title: string | null;
|
|
context_doc_id: string | null;
|
|
context_page_id: string | null;
|
|
updated_at: string;
|
|
message_count: number;
|
|
}
|
|
|
|
interface ToolBlock {
|
|
id: string;
|
|
name: string;
|
|
args: Record<string, unknown>;
|
|
result?: unknown;
|
|
durationMs?: number;
|
|
state: "running" | "done";
|
|
}
|
|
|
|
interface NavOffer {
|
|
target: string;
|
|
label: string;
|
|
}
|
|
|
|
interface Msg {
|
|
id?: string;
|
|
role: "user" | "assistant" | "tool" | "system";
|
|
content: string;
|
|
tools?: ToolBlock[];
|
|
navs?: NavOffer[];
|
|
streaming?: boolean;
|
|
}
|
|
|
|
export function ChatBubble({ context }: ChatBubbleProps) {
|
|
const router = useRouter();
|
|
const [open, setOpen] = useState(false);
|
|
const [user, setUser] = useState<User | null>(null);
|
|
const [authReady, setAuthReady] = useState(false);
|
|
const [sessions, setSessions] = useState<SessionRow[]>([]);
|
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
const [messages, setMessages] = useState<Msg[]>([]);
|
|
const [input, setInput] = useState("");
|
|
const [sending, setSending] = useState(false);
|
|
const [view, setView] = useState<"list" | "chat">("chat");
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Auth
|
|
useEffect(() => {
|
|
if (!isSupabaseConfigured()) { setAuthReady(true); return; }
|
|
const supabase = createClient();
|
|
supabase.auth.getUser().then(({ data }) => { setUser(data.user); setAuthReady(true); });
|
|
const { data: sub } = supabase.auth.onAuthStateChange((_e, s) => setUser(s?.user ?? null));
|
|
return () => sub.subscription.unsubscribe();
|
|
}, []);
|
|
|
|
const loadSessions = useCallback(async () => {
|
|
if (!user) return;
|
|
const r = await fetch("/api/sessions");
|
|
if (!r.ok) return;
|
|
const d = (await r.json()) as { sessions: SessionRow[] };
|
|
setSessions(d.sessions);
|
|
}, [user]);
|
|
|
|
const loadMessages = useCallback(async (sessionId: string) => {
|
|
const r = await fetch(`/api/sessions/${sessionId}`);
|
|
if (!r.ok) return;
|
|
const d = (await r.json()) as { messages: Msg[] };
|
|
setMessages(d.messages);
|
|
}, []);
|
|
|
|
const newSession = useCallback(async () => {
|
|
const r = await fetch("/api/sessions", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
context_doc_id: context.doc_id ?? null,
|
|
context_page_id: context.page_id ?? null,
|
|
}),
|
|
});
|
|
if (!r.ok) return null;
|
|
const d = (await r.json()) as { session: SessionRow };
|
|
setActiveId(d.session.id);
|
|
setMessages([{
|
|
role: "assistant",
|
|
content: context.page_id
|
|
? `Investigando **${context.page_id}**. Pergunte qualquer coisa.`
|
|
: context.doc_id
|
|
? `Investigando documento **${context.doc_id}**. O que quer descobrir?`
|
|
: "Bem-vindo. Pergunte sobre qualquer documento, entidade ou caso.",
|
|
}]);
|
|
setView("chat");
|
|
return d.session;
|
|
}, [context]);
|
|
|
|
useEffect(() => {
|
|
if (open && user) {
|
|
loadSessions();
|
|
if (!activeId) newSession();
|
|
}
|
|
}, [open, user, activeId, loadSessions, newSession]);
|
|
|
|
// Auto-scroll on new content
|
|
useEffect(() => {
|
|
if (scrollRef.current) {
|
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
}
|
|
}, [messages]);
|
|
|
|
async function send() {
|
|
if (!input.trim() || sending || !activeId) return;
|
|
const sent = input;
|
|
setInput("");
|
|
setSending(true);
|
|
|
|
// Push user msg + placeholder assistant msg
|
|
setMessages((m) => [
|
|
...m,
|
|
{ role: "user", content: sent },
|
|
{ role: "assistant", content: "", tools: [], navs: [], streaming: true },
|
|
]);
|
|
|
|
try {
|
|
const r = await fetch(`/api/sessions/${activeId}/messages`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ content: sent }),
|
|
});
|
|
if (!r.ok || !r.body) {
|
|
const err = await r.json().catch(() => ({ error: r.statusText }));
|
|
throw new Error(err.error || `HTTP ${r.status}`);
|
|
}
|
|
await consumeSSE(r.body);
|
|
loadSessions();
|
|
} catch (e) {
|
|
setMessages((m) => {
|
|
const copy = [...m];
|
|
const last = copy[copy.length - 1];
|
|
if (last && last.role === "assistant") {
|
|
last.content = `⚠ ${e instanceof Error ? e.message : String(e)}`;
|
|
last.streaming = false;
|
|
}
|
|
return copy;
|
|
});
|
|
} finally {
|
|
setSending(false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read the SSE stream and apply each event to the last assistant message.
|
|
*/
|
|
async function consumeSSE(body: ReadableStream<Uint8Array>) {
|
|
const reader = body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buf = "";
|
|
|
|
const apply = (mutator: (msg: Msg) => void) => {
|
|
setMessages((curr) => {
|
|
const copy = [...curr];
|
|
const last = copy[copy.length - 1];
|
|
if (last && last.role === "assistant") mutator(last);
|
|
return copy;
|
|
});
|
|
};
|
|
|
|
for (;;) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
buf += decoder.decode(value, { stream: true });
|
|
let idx: number;
|
|
while ((idx = buf.indexOf("\n\n")) !== -1) {
|
|
const block = buf.slice(0, idx);
|
|
buf = buf.slice(idx + 2);
|
|
let evName: string | null = null;
|
|
let evData: string | null = null;
|
|
for (const line of block.split("\n")) {
|
|
if (line.startsWith("event: ")) evName = line.slice(7).trim();
|
|
else if (line.startsWith("data: ")) evData = line.slice(6);
|
|
}
|
|
if (!evName || evData == null) continue;
|
|
let payload: Record<string, unknown> = {};
|
|
try { payload = JSON.parse(evData); } catch { continue; }
|
|
|
|
if (evName === "text_delta") {
|
|
const d = String(payload.delta ?? "");
|
|
apply((m) => { m.content += d; });
|
|
} else if (evName === "tool_start") {
|
|
const block: ToolBlock = {
|
|
id: String(payload.id),
|
|
name: String(payload.name),
|
|
args: (payload.args as Record<string, unknown>) ?? {},
|
|
state: "running",
|
|
};
|
|
apply((m) => { (m.tools ??= []).push(block); });
|
|
} else if (evName === "tool_result") {
|
|
const id = String(payload.id);
|
|
apply((m) => {
|
|
const t = m.tools?.find((x) => x.id === id);
|
|
if (t) {
|
|
t.result = payload.result;
|
|
t.durationMs = typeof payload.durationMs === "number" ? payload.durationMs : undefined;
|
|
t.state = "done";
|
|
}
|
|
});
|
|
} else if (evName === "navigate") {
|
|
const offer: NavOffer = { target: String(payload.target), label: String(payload.label) };
|
|
apply((m) => { (m.navs ??= []).push(offer); });
|
|
} else if (evName === "done") {
|
|
apply((m) => { m.streaming = false; });
|
|
} else if (evName === "error") {
|
|
const msg = String(payload.message ?? "(unknown error)");
|
|
apply((m) => { m.content += `\n\n⚠ ${msg}`; m.streaming = false; });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
onClick={() => setOpen(true)}
|
|
className="fixed bottom-5 right-5 z-30 bg-[#00ff9c] text-black hover:bg-[#00d4a8]
|
|
rounded-full w-14 h-14 flex items-center justify-center
|
|
shadow-[0_0_20px_rgba(0,255,156,0.4)] transition-all"
|
|
aria-label="Open chat"
|
|
>
|
|
<MessageSquare size={22} />
|
|
</button>
|
|
|
|
<Dialog.Root open={open} onOpenChange={setOpen}>
|
|
<Dialog.Portal>
|
|
<Dialog.Overlay className="fixed inset-0 bg-black/40 backdrop-blur-sm z-40" />
|
|
<Dialog.Content
|
|
className="fixed bottom-5 right-5 z-50 w-[480px] max-w-[92vw] h-[78vh] max-h-[720px]
|
|
bg-[#0a121e] border border-[rgba(0,255,156,0.32)] rounded
|
|
flex flex-col text-[#c8d4e6] font-sans"
|
|
>
|
|
<header className="flex items-center justify-between p-3 border-b border-[rgba(0,255,156,0.32)]">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setView(view === "list" ? "chat" : "list")}
|
|
className="p-1 hover:bg-[rgba(0,255,156,0.08)] rounded"
|
|
disabled={!user}
|
|
>
|
|
<MessageSquareText size={16} className={user ? "text-[#7fdbff]" : "text-[#5a6678]"} />
|
|
</button>
|
|
<div>
|
|
<Dialog.Title className="font-mono text-sm text-[#00ff9c]">🕵 Sherlock</Dialog.Title>
|
|
<div className="font-mono text-[9px] text-[#5a6678] tracking-widest uppercase">
|
|
{context.page_id || context.doc_id || "browsing"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<Dialog.Close asChild>
|
|
<button className="p-1 hover:bg-[rgba(0,255,156,0.08)] rounded">
|
|
<X size={16} />
|
|
</button>
|
|
</Dialog.Close>
|
|
</header>
|
|
|
|
{!authReady ? (
|
|
<div className="flex-1 flex items-center justify-center text-[#5a6678] text-sm">Loading…</div>
|
|
) : !isSupabaseConfigured() ? (
|
|
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
|
|
<Lock size={28} className="text-[#5a6678] mb-3" />
|
|
<p className="text-sm text-[#8896aa]">Auth not configured.</p>
|
|
</div>
|
|
) : !user ? (
|
|
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
|
|
<Lock size={28} className="text-[#00ff9c] mb-3" />
|
|
<h3 className="font-mono text-[#00ff9c] mb-1">Sign in to chat</h3>
|
|
<p className="text-sm text-[#8896aa] mb-4">Sherlock's history is tied to your account.</p>
|
|
<Link
|
|
href="/auth/signin"
|
|
onClick={() => setOpen(false)}
|
|
className="px-4 py-2 bg-[#00ff9c] text-black font-mono uppercase tracking-widest text-xs hover:bg-[#00d4a8] rounded"
|
|
>
|
|
Sign in
|
|
</Link>
|
|
</div>
|
|
) : view === "list" ? (
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div className="p-3 border-b border-[rgba(0,255,156,0.12)]">
|
|
<button
|
|
onClick={() => { setActiveId(null); newSession(); }}
|
|
className="w-full px-3 py-2 bg-[rgba(0,255,156,0.08)] hover:bg-[rgba(0,255,156,0.16)]
|
|
text-[#00ff9c] font-mono text-xs uppercase tracking-widest rounded
|
|
flex items-center justify-center gap-2"
|
|
>
|
|
<Plus size={14} /> new investigation
|
|
</button>
|
|
</div>
|
|
<ul>
|
|
{sessions.map((s) => (
|
|
<li key={s.id}>
|
|
<button
|
|
onClick={() => { setActiveId(s.id); loadMessages(s.id); setView("chat"); }}
|
|
className={`w-full text-left px-3 py-2 border-b border-[rgba(0,255,156,0.08)]
|
|
hover:bg-[rgba(0,255,156,0.04)] ${s.id === activeId ? "bg-[rgba(0,255,156,0.08)]" : ""}`}
|
|
>
|
|
<div className="font-mono text-xs text-[#7fdbff] truncate">
|
|
{s.title || s.context_page_id || s.context_doc_id || "untitled"}
|
|
</div>
|
|
<div className="font-mono text-[9px] text-[#5a6678]">
|
|
{s.message_count} msgs · {new Date(s.updated_at).toLocaleDateString()}
|
|
</div>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3 space-y-3 text-sm">
|
|
{messages.map((m, i) => (
|
|
<MessageBubble key={m.id ?? i} msg={m} onNavigate={(t) => { setOpen(false); router.push(t); }} />
|
|
))}
|
|
{sending && messages[messages.length - 1]?.streaming === false ? null : null}
|
|
</div>
|
|
|
|
<div className="p-3 border-t border-[rgba(0,255,156,0.32)] flex gap-2">
|
|
<input
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && send()}
|
|
placeholder="Ask Sherlock…"
|
|
className="flex-1 bg-[#060a13] border border-[rgba(0,255,156,0.32)] rounded
|
|
px-3 py-2 text-sm focus:outline-none focus:border-[#00ff9c]"
|
|
/>
|
|
<button
|
|
onClick={send}
|
|
disabled={sending || !input.trim()}
|
|
className="px-3 py-2 bg-[#00ff9c] text-black rounded
|
|
hover:bg-[#00d4a8] disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
<Send size={16} />
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</Dialog.Content>
|
|
</Dialog.Portal>
|
|
</Dialog.Root>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function MessageBubble({ msg, onNavigate }: { msg: Msg; onNavigate: (t: string) => void }) {
|
|
const isUser = msg.role === "user";
|
|
return (
|
|
<div
|
|
className={
|
|
isUser
|
|
? "ml-8 px-3 py-2 bg-[rgba(127,219,255,0.08)] border-l-2 border-[#7fdbff] rounded whitespace-pre-wrap"
|
|
: "mr-8 px-3 py-2 bg-[rgba(0,255,156,0.05)] border-l-2 border-[#00ff9c] rounded"
|
|
}
|
|
>
|
|
{msg.tools && msg.tools.length > 0 && (
|
|
<div className="space-y-1 mb-2">
|
|
{msg.tools.map((t) => <ToolTrace key={t.id} t={t} />)}
|
|
</div>
|
|
)}
|
|
{msg.role === "user" ? (
|
|
<div className="whitespace-pre-wrap">{msg.content}</div>
|
|
) : msg.streaming && (!msg.content || msg.content.length < 8) ? (
|
|
<div className="whitespace-pre-wrap">{msg.content}<span className="text-[#5a6678]"> ▍</span></div>
|
|
) : (
|
|
<div className="chat-markdown">
|
|
<MarkdownBody>{msg.content}</MarkdownBody>
|
|
{msg.streaming && <span className="text-[#5a6678]"> ▍</span>}
|
|
</div>
|
|
)}
|
|
{msg.navs && msg.navs.length > 0 && (
|
|
<div className="mt-2 flex flex-wrap gap-1">
|
|
{msg.navs.map((n, i) => (
|
|
<button
|
|
key={i}
|
|
onClick={() => onNavigate(n.target)}
|
|
className="text-[10px] font-mono uppercase tracking-widest
|
|
bg-[rgba(0,255,156,0.12)] hover:bg-[rgba(0,255,156,0.24)]
|
|
border border-[#00ff9c] text-[#00ff9c] rounded
|
|
px-2 py-1 flex items-center gap-1"
|
|
>
|
|
<ArrowUpRight size={11} /> {n.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ChunkHit {
|
|
chunk_id?: string;
|
|
doc_id?: string;
|
|
page?: number;
|
|
type?: string;
|
|
bbox?: { x: number; y: number; w: number; h: number } | null;
|
|
classification?: string | null;
|
|
snippet?: string;
|
|
score?: number;
|
|
href?: string;
|
|
}
|
|
|
|
/** Render hybrid_search / list_anomalies results as citation cards with bbox crops. */
|
|
function ChunkHitCard({ hit }: { hit: ChunkHit }) {
|
|
if (!hit.doc_id || !hit.chunk_id) return null;
|
|
const cropUrl = hit.bbox
|
|
? `/api/crop?doc=${encodeURIComponent(hit.doc_id)}&page=${hit.page}` +
|
|
`&x=${hit.bbox.x}&y=${hit.bbox.y}&w=${hit.bbox.w}&h=${hit.bbox.h}&w_px=240`
|
|
: null;
|
|
return (
|
|
<a
|
|
href={hit.href ?? `/d/${hit.doc_id}/p${String(hit.page).padStart(3, "0")}#${hit.chunk_id}`}
|
|
target="_blank"
|
|
rel="noopener"
|
|
className="block my-1.5 p-2 bg-[#0a121e] border border-[rgba(127,219,255,0.20)] hover:border-[#00ff9c] rounded text-[11px] flex gap-2"
|
|
>
|
|
{cropUrl && (
|
|
<img
|
|
src={cropUrl}
|
|
alt=""
|
|
width={80}
|
|
height={50}
|
|
className="block w-20 h-12 object-cover bg-black rounded flex-shrink-0"
|
|
loading="lazy"
|
|
/>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2 text-[10px] font-mono mb-0.5">
|
|
<span className="text-[#7fdbff]">{hit.chunk_id}</span>
|
|
<span className="text-[#5a6678]">p{hit.page}</span>
|
|
<span className="text-[#5a6678]">{hit.type}</span>
|
|
{hit.classification && <span className="text-[#ff6b6b]">{hit.classification}</span>}
|
|
{hit.score !== undefined && (
|
|
<span className="text-[#8896aa] ml-auto">{hit.score.toFixed(2)}</span>
|
|
)}
|
|
</div>
|
|
<div className="text-[#c8d4e6] line-clamp-2">{hit.snippet}</div>
|
|
<div className="text-[10px] font-mono text-[#5a6678] truncate mt-0.5">{hit.doc_id}</div>
|
|
</div>
|
|
</a>
|
|
);
|
|
}
|
|
|
|
function ToolTrace({ t }: { t: ToolBlock }) {
|
|
const [open, setOpen] = useState(false);
|
|
// Rich render for retrieval tools — show citation cards inline
|
|
const richRender = (() => {
|
|
if (!t.result || typeof t.result !== "object") return null;
|
|
const r = t.result as { hits?: ChunkHit[]; anomalies?: ChunkHit[]; chunks?: ChunkHit[] };
|
|
const items = r.hits ?? r.anomalies ?? r.chunks ?? null;
|
|
if (!items || items.length === 0) return null;
|
|
return (
|
|
<div className="mt-1 ml-3 space-y-1">
|
|
{items.slice(0, 5).map((hit, i) => (
|
|
<ChunkHitCard key={hit.chunk_id ?? i} hit={hit} />
|
|
))}
|
|
{items.length > 5 && (
|
|
<div className="text-[10px] text-[#5a6678] font-mono">…and {items.length - 5} more</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})();
|
|
|
|
return (
|
|
<div className="text-[11px] font-mono">
|
|
<button
|
|
onClick={() => setOpen(!open)}
|
|
className="flex items-center gap-1 text-[#7fdbff] hover:text-[#00ff9c]"
|
|
>
|
|
{open ? <ChevronDown size={11} /> : <ChevronRight size={11} />}
|
|
<Wrench size={10} />
|
|
<span className="font-semibold">{t.name}</span>
|
|
{t.state === "running" ? (
|
|
<span className="text-[#5a6678] animate-pulse">·running…</span>
|
|
) : (
|
|
<span className="text-[#5a6678]">·done{t.durationMs ? ` (${t.durationMs}ms)` : ""}</span>
|
|
)}
|
|
</button>
|
|
{richRender}
|
|
{open && (
|
|
<pre className="mt-1 ml-3 p-2 bg-[#060a13] border border-[rgba(0,255,156,0.12)] rounded
|
|
text-[10px] text-[#8896aa] overflow-x-auto max-h-32">
|
|
{JSON.stringify({ args: t.args, result: t.result }, null, 2).slice(0, 1500)}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|