"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"; import type { Artifact } from "@/lib/chat/agui"; import Image from "next/image"; 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; 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[]; artifacts?: Artifact[]; streaming?: boolean; } export function ChatBubble({ context }: ChatBubbleProps) { const router = useRouter(); const [open, setOpen] = useState(false); const [user, setUser] = useState(null); const [authReady, setAuthReady] = useState(false); const [sessions, setSessions] = useState([]); const [activeId, setActiveId] = useState(null); const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [sending, setSending] = useState(false); const [view, setView] = useState<"list" | "chat">("chat"); const scrollRef = useRef(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]); // Hydrate a session's full transcript from the server. Maps persisted // tool_calls + citations back into the inline `tools` / `artifacts` shape // the renderer uses, so reopening an old session shows the same cards as // when it was streamed live. const loadMessages = useCallback(async (sessionId: string) => { const r = await fetch(`/api/sessions/${sessionId}`); if (!r.ok) { // Stale local activeId (e.g. session deleted server-side) — clear so // the next interaction creates a fresh one. if (r.status === 404) { setActiveId(null); try { localStorage.removeItem("chat:lastSessionId"); } catch { /* ignore */ } } return; } const d = (await r.json()) as { messages: Array<{ id?: string; role: Msg["role"]; content: string; tool_calls?: ToolBlock[] | null; citations?: Artifact[] | null; }>; }; const hydrated: Msg[] = (d.messages ?? []).map((m) => ({ id: m.id, role: m.role, content: m.content || "", tools: Array.isArray(m.tool_calls) ? m.tool_calls : undefined, artifacts: Array.isArray(m.citations) ? m.citations : undefined, })); setMessages(hydrated); }, []); 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); try { localStorage.setItem("chat:lastSessionId", d.session.id); } catch { /* ignore */ } 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]); // Wrap setActiveId so we also persist + hydrate on switch. const selectSession = useCallback((id: string) => { setActiveId(id); try { localStorage.setItem("chat:lastSessionId", id); } catch { /* ignore */ } loadMessages(id); setView("chat"); }, [loadMessages]); // On open: list sessions, then either resume the persisted activeId // (if it still belongs to the user) or pick the most recent one. // Only spawn a brand-new session when the user has zero sessions yet. useEffect(() => { if (!open || !user) return; let cancelled = false; (async () => { const r = await fetch("/api/sessions"); if (!r.ok) return; const d = (await r.json()) as { sessions: SessionRow[] }; if (cancelled) return; setSessions(d.sessions); // Resume the most-recently-active session this user had in this browser let resumeId: string | null = null; try { resumeId = localStorage.getItem("chat:lastSessionId"); } catch { /* ignore */ } const knownIds = new Set(d.sessions.map((s) => s.id)); const stillValid = resumeId && knownIds.has(resumeId) ? resumeId : null; if (stillValid) { if (activeId !== stillValid) { setActiveId(stillValid); loadMessages(stillValid); } } else if (d.sessions.length > 0) { // No persisted choice but the user has sessions — open the latest. const latest = d.sessions[0].id; setActiveId(latest); loadMessages(latest); try { localStorage.setItem("chat:lastSessionId", latest); } catch { /* ignore */ } } else if (!activeId) { // First-ever session for this user — create it. await newSession(); } })(); return () => { cancelled = true; }; }, [open, user, activeId, loadMessages, 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) { 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 = {}; 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) ?? {}, 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 === "artifact") { const a = payload.artifact as Artifact | undefined; if (a && typeof a === "object" && a.kind) { apply((m) => { (m.artifacts ??= []).push(a); }); } } 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 ( <>
🕵 Sherlock
{context.page_id || context.doc_id || "browsing"}
{!authReady ? (
Loading…
) : !isSupabaseConfigured() ? (

Auth not configured.

) : !user ? (

Sign in to chat

Sherlock's history is tied to your account.

setOpen(false)} className="px-4 py-2 bg-[#00ff9c] text-black font-mono uppercase tracking-widest text-xs hover:bg-[#00d4a8] rounded" > Sign in
) : view === "list" ? (
    {sessions.map((s) => (
  • ))} {sessions.length === 0 && (
  • sem conversas ainda — crie uma nova
  • )}
) : ( <>
{messages.map((m, i) => ( { setOpen(false); router.push(t); }} /> ))} {sending && messages[messages.length - 1]?.streaming === false ? null : null}
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]" />
)}
); } function MessageBubble({ msg, onNavigate }: { msg: Msg; onNavigate: (t: string) => void }) { const isUser = msg.role === "user"; return (
{msg.tools && msg.tools.length > 0 && (
{msg.tools.map((t) => )}
)} {msg.role === "user" ? (
{msg.content}
) : msg.streaming && (!msg.content || msg.content.length < 8) ? (
{msg.content}
) : (
{msg.content} {msg.streaming && }
)} {msg.artifacts && msg.artifacts.length > 0 && (
{msg.artifacts.map((a, i) => ( ))}
)} {msg.navs && msg.navs.length > 0 && (
{msg.navs.map((n, i) => ( ))}
)}
); } function ArtifactCard({ a, onNavigate }: { a: Artifact; onNavigate: (t: string) => void }) { if (a.kind === "citation") { const href = `/d/${a.doc_id}#${a.chunk_id}`; const cls = (a.classification ?? "").split("//")[0].trim(); return ( ); } if (a.kind === "crop_image") { const href = a.chunk_id ? `/d/${a.doc_id}#${a.chunk_id}` : `/d/${a.doc_id}/p${String(a.page).padStart(3, "0")}`; return ( ); } if (a.kind === "entity_card") { const folder: Record = { person: "people", organization: "organizations", location: "locations", event: "events", uap_object: "uap-objects", vehicle: "vehicles", operation: "operations", concept: "concepts", }; const href = `/e/${folder[a.entity_class]}/${a.entity_id}`; return ( ); } if (a.kind === "navigation_offer") { return ( ); } // evidence_card / hypothesis_card / case_card — render as placeholder until // those routes exist in Fase 2. return null; } 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 ( {cropUrl && ( )}
{hit.chunk_id} p{hit.page} {hit.type} {hit.classification && {hit.classification}} {hit.score !== undefined && ( {hit.score.toFixed(2)} )}
{hit.snippet}
{hit.doc_id}
); } 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; // request_investigation: render a detective status card with a link to /jobs/[id] if (t.name === "request_investigation") { const r = t.result as { job_id?: string; kind?: string; status?: string; status_url?: string; eta_seconds?: number; detective?: string; error?: string; }; if (r.error || !r.job_id) { return (
⚠ investigation_unavailable: {r.error || "unknown"}
); } const detective = r.detective ?? ( r.kind === "hypothesis_tournament" ? "holmes" : r.kind === "contradiction_scan" ? "dupin" : "locard" ); const tone = detective === "holmes" ? { text: "text-[#7fdbff]", border: "border-[#7fdbff]", label: "Holmes" } : detective === "dupin" ? { text: "text-[#ff8a4d]", border: "border-[#ff8a4d]", label: "Dupin" } : { text: "text-[#06d6a0]", border: "border-[#06d6a0]", label: "Locard" }; return (
🔎 {tone.label} · {r.kind}
{r.status}
job {r.job_id.slice(0, 8)}… {r.eta_seconds ? · ETA ~{r.eta_seconds}s : null}
{r.status_url && ( acompanhar a investigação )}
); } 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 (
{items.slice(0, 5).map((hit, i) => ( ))} {items.length > 5 && (
…and {items.length - 5} more
)}
); })(); return (
{richRender} {open && (
{JSON.stringify({ args: t.args, result: t.result }, null, 2).slice(0, 1500)}
        
)}
); }