disclosure-bureau/web/components/chat-bubble.tsx
Luiz Gustavo dd75a67964
Some checks failed
CI / Web — typecheck + lint + build (push) Failing after 45s
CI / Scripts — Python smoke (push) Failing after 5s
CI / Web — npm audit (push) Failing after 40s
CI / Retrieval — golden set (Recall@5 + MRR) (push) Failing after 3s
W3.8: Investigation Bureau complete — Poirot, Taleb, Tetlock, Case-Writer
Brings the bureau from 4 → 8 detectives. All eight run as Bun + claude-CLI
subprocesses against the same Supabase + investigation_jobs LISTEN/NOTIFY
queue, sharing search.ts hybridSearch and writer-side validators that
gate writes against schema + FK.

New detectives:

  Poirot (witness_analysis)
    - prompts/poirot.md — credibility / access / bias / corroboration /
      verdict; uses entity_mentions JOIN chunks to pull 12 chunks per
      person; resolves corroboration_refs chunk_ids defensively (accepts
      bare cNNNN even when the model emits pNNN/cNNNN).
    - INSERT into public.witnesses with W-NNNN naming.
    - Tone: purple (#9b5de5).

  Taleb (outlier_scan)
    - prompts/taleb.md — "surprise is relative to a model"; at most 3
      outliers; each requires explicit dominant_model + why_surprising +
      what_it_implies; fan-out into public.gaps with scope.kind="outlier".
    - Same unscoped-fallback as Dupin (Pass 1 with doc_id, Pass 2 widens
      to corpus if hits < 3).
    - Tone: yellow (#ffd23f).

  Tetlock (calibrate_hypothesis)
    - prompts/tetlock.md — honest Bayesian update; emits new_posterior +
      Δ + recommended_action ∈ {keep, downgrade, upgrade, supersede}.
    - write_calibration UPDATEs public.hypotheses + APPENDS a
      "## Calibration history" section to the H-NNNN.md case file
      (calibration is append-only — each datapoint matters). Posterior
      band auto-corrected to match Tetlock thresholds.
    - NO_NEW_EVIDENCE sentinel handled; pure 'keep' with |Δ|<0.005 only
      touches updated_at + reviewed_by.
    - Tone: teal (#26d4cc).

  Case-Writer (case_report)
    - prompts/case-writer.md — Dr. Watson assembles all artefacts
      (E-NNNN, H-NNNN, R-NNNN, W-NNNN, G-NNNN) into a five-act narrative.
      ILIKE filter on topic; doc_id optional scope.
    - Larger budget cap (≥ $0.50) + longer timeout for prose generation.
    - Writes case/reports/<slug>.md with frontmatter (topic + counts);
      no DB table for v0.
    - New page /c/[slug] renders the report via MarkdownBody + stat chips.
    - Tone: gold (#e0c080).

Hardening across the bureau:
  - Sentinel parsing now accepts backticked AND prose-trailing forms
    (Holmes NO_HYPOTHESES, Dupin NO_CONTRADICTIONS, Schneier
    INSUFFICIENT_HYPOTHESIS, Poirot INSUFFICIENT_TESTIMONY, Taleb
    NO_OUTLIERS, Tetlock NO_NEW_EVIDENCE, Case-Writer
    INSUFFICIENT_ARTEFACTS). Avoids the failure mode where the model
    refuses honestly but the runtime treated it as a parse error
    (observed live with Poirot+Hoover identifying the DIRECTOR
    false-positive disambiguation issue in entity_mentions).

Chat tool extensions (web/lib/chat/tools.ts):
  - request_investigation now accepts 7 kinds. Each routes to its
    detective with appropriate validation (hypothesis_id regex,
    person_id kebab-case, topic non-empty, doc_id for evidence_chain).
  - ETA per kind: Holmes/Dupin 60s, Poirot 45s, Schneier/Tetlock 30s,
    Taleb 50s, Case-Writer 180s (longer prose), Locard 30×n_chunks.

UI integration:
  - chat-bubble inline card paints each detective in its tone color.
  - /jobs/[id] page header swaps name/subtitle/tone per detective;
    question label adapts ("Topic" / "Hypothesis under attack" /
    "Witness under analysis" / "Topic to outlier-scan" / "Hypothesis
    under recalibration" / "Case to assemble").
  - job-status-poller renders: case-report link card (gold), outlier
    cards (yellow), witness cards (purple) — alongside existing
    hypothesis, evidence, contradiction cards.
  - /api/jobs/[id] hydrates witnesses (JOIN entities for canonical_name)
    + gaps (with scope JSONB).
  - /c/[slug] page reads /data/ufo/case/reports/<slug>.md and renders
    with MarkdownBody, frontmatter parsed for stat chips.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:11:39 -03:00

768 lines
30 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";
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<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[];
artifacts?: Artifact[];
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]);
// 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<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 === "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 (
<>
<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&apos;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} className="group flex items-stretch border-b border-[rgba(0,255,156,0.08)]">
<button
onClick={() => selectSession(s.id)}
className={`flex-1 text-left px-3 py-2
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>
<button
onClick={async () => {
const next = window.prompt("Renomear conversa para:", s.title ?? "");
if (next === null) return;
const r = await fetch(`/api/sessions/${s.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: next.trim() || null }),
});
if (r.ok) loadSessions();
}}
title="renomear"
className="opacity-0 group-hover:opacity-100 px-2 text-[#5a6678] hover:text-[#7fdbff]"
></button>
<button
onClick={async () => {
if (!window.confirm("Arquivar esta conversa?")) return;
const r = await fetch(`/api/sessions/${s.id}`, { method: "DELETE" });
if (!r.ok) return;
if (s.id === activeId) {
setActiveId(null);
setMessages([]);
try { localStorage.removeItem("chat:lastSessionId"); } catch { /* ignore */ }
}
loadSessions();
}}
title="arquivar"
className="opacity-0 group-hover:opacity-100 px-2 text-[#5a6678] hover:text-[#ff6b6b]"
>🗑</button>
</li>
))}
{sessions.length === 0 && (
<li className="px-3 py-6 text-center font-mono text-xs text-[#5a6678]">
sem conversas ainda crie uma nova
</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.artifacts && msg.artifacts.length > 0 && (
<div className="mt-2 grid grid-cols-1 sm:grid-cols-2 gap-2">
{msg.artifacts.map((a, i) => (
<ArtifactCard key={i} a={a} onNavigate={onNavigate} />
))}
</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>
);
}
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 (
<button
onClick={() => onNavigate(href)}
className="text-left p-2 border border-[rgba(127,219,255,0.20)] hover:border-[#00ff9c]
bg-[#060a13] rounded transition"
>
<div className="flex items-center gap-1.5 mb-1 font-mono text-[9px] uppercase tracking-widest">
{cls && <span className="text-[#ff8a4d]">{cls}</span>}
<span className="text-[#7fdbff]">{a.chunk_id}</span>
<span className="text-[#5a6678]">· p{a.page} · {a.type}</span>
{typeof a.score === "number" && (
<span className="ml-auto text-[#00ff9c]">{a.score.toFixed(3)}</span>
)}
</div>
{a.snippet && (
<div className="text-[12px] text-[#c8d4e6] leading-snug line-clamp-3">{a.snippet}</div>
)}
<div className="font-mono text-[9px] text-[#5a6678] mt-1 truncate"> {a.doc_id}</div>
</button>
);
}
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 (
<button
onClick={() => onNavigate(href)}
className="block p-1 border border-[rgba(0,255,156,0.20)] hover:border-[#00ff9c]
bg-[#060a13] rounded transition overflow-hidden"
>
<Image
src={a.src}
alt={a.alt_pt || a.alt_en || a.chunk_id || "crop"}
width={320}
height={200}
sizes="(max-width:640px) 90vw, 280px"
className="block w-full h-auto rounded-sm"
/>
<div className="font-mono text-[9px] text-[#5a6678] mt-1 px-1 truncate">
{a.doc_id} · p{a.page}{a.chunk_id ? ` · ${a.chunk_id}` : ""}
</div>
</button>
);
}
if (a.kind === "entity_card") {
const folder: Record<string, string> = {
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 (
<button
onClick={() => onNavigate(href)}
className="text-left p-2 border border-[rgba(167,139,250,0.30)] hover:border-[#a78bfa]
bg-[#060a13] rounded transition"
>
<div className="font-mono text-[9px] uppercase tracking-widest text-[#a78bfa] mb-1">
{a.entity_class}
</div>
<div className="text-[13px] text-[#c8d4e6] font-bold leading-snug">{a.canonical_name}</div>
<div className="font-mono text-[9px] text-[#5a6678] mt-1">
{a.total_mentions ?? 0} menções · {a.documents_count ?? 0} docs
</div>
</button>
);
}
if (a.kind === "navigation_offer") {
return (
<button
onClick={() => onNavigate(a.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} /> {a.label_pt || a.label_en}
</button>
);
}
// 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 (
<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;
// 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 (
<div className="mt-1 ml-3 p-2 rounded border border-[#ff3344]/40 bg-[rgba(255,51,68,0.06)] text-[11px] font-mono text-[#ff6ec7]">
investigation_unavailable: {r.error || "unknown"}
</div>
);
}
const detective = r.detective ?? (
r.kind === "hypothesis_tournament" ? "holmes" :
r.kind === "contradiction_scan" ? "dupin" :
r.kind === "red_team_review" ? "schneier" :
r.kind === "witness_analysis" ? "poirot" :
r.kind === "outlier_scan" ? "taleb" :
r.kind === "calibrate_hypothesis" ? "tetlock" :
r.kind === "case_report" ? "case-writer" : "locard"
);
const tone =
detective === "holmes" ? { text: "text-[#7fdbff]", border: "border-[#7fdbff]", label: "Holmes" } :
detective === "dupin" ? { text: "text-[#ff8a4d]", border: "border-[#ff8a4d]", label: "Dupin" } :
detective === "schneier" ? { text: "text-[#ff3344]", border: "border-[#ff3344]", label: "Schneier" } :
detective === "poirot" ? { text: "text-[#9b5de5]", border: "border-[#9b5de5]", label: "Poirot" } :
detective === "taleb" ? { text: "text-[#ffd23f]", border: "border-[#ffd23f]", label: "Taleb" } :
detective === "tetlock" ? { text: "text-[#26d4cc]", border: "border-[#26d4cc]", label: "Tetlock" } :
detective === "case-writer" ? { text: "text-[#e0c080]", border: "border-[#e0c080]", label: "Case-Writer" } :
{ text: "text-[#06d6a0]", border: "border-[#06d6a0]", label: "Locard" };
return (
<div className={`mt-1 ml-3 p-3 rounded border ${tone.border} bg-[#060a13]`}>
<div className="flex items-baseline justify-between mb-1">
<div className={`font-mono text-[11px] font-bold ${tone.text}`}>
🔎 {tone.label} · {r.kind}
</div>
<span className="text-[10px] text-[#5a6678] font-mono uppercase">{r.status}</span>
</div>
<div className="text-[10px] text-[#9aa6b8] font-mono">
job <span className="text-[#e7ecf3]">{r.job_id.slice(0, 8)}</span>
{r.eta_seconds ? <span className="ml-2">· ETA ~{r.eta_seconds}s</span> : null}
</div>
{r.status_url && (
<Link
href={r.status_url}
target="_blank"
className={`mt-2 inline-flex items-center gap-1 text-[11px] font-mono ${tone.text} hover:underline`}
>
acompanhar a investigação <ArrowUpRight size={11} />
</Link>
)}
</div>
);
}
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>
);
}