Adds the third AI detective in the Investigation Bureau runtime: C. Auguste
Dupin, who scans a corpus shortlist for pairs (or small groups) of chunks
that cannot both be true under any ordinary reading.
Runtime:
- prompts/dupin.md — discipline (no contradiction without ≥2 distinct
chunk_ids; reject same-vocabulary near-misses; FEW high-confidence
over MANY weak ones; emit `NO_CONTRADICTIONS` when corpus is silent)
- src/detectives/dupin.ts — hybridSearch with k=18 (more chunks than
Holmes because contradictions emerge from comparing dispersed
claims), strict JSON-array parsing, AT MOST 3 contradictions per call
- src/tools/write_contradiction.ts — validates topic + ≥2 positions
drawn from ≥2 distinct chunks, resolves chunk_pk via DB lookup
(rejects positions citing unknown chunks), INSERTs into
public.contradictions + writes case/contradictions/R-NNNN.md
- orchestrator: new `contradiction_scan` kind dispatching to runDupin;
payload { topic, doc_id?, lang?, context_chunks? }
Chat + UI:
- request_investigation gains kind=contradiction_scan + topic arg;
triggered detective auto-resolves to dupin
- chat-bubble inline card renders dupin in orange (#ff8a4d) to
distinguish from holmes (cyan) and locard (green)
- /jobs/[id] page swaps title + subtitle + tone per detective;
"Question" label becomes "Topic" for contradiction_scan
- /api/jobs/[id] hydrates public.contradictions when outputs[] surfaces
contradiction_ids
- job-status-poller renders ContradictionCard: topic + N positions
(verbatim statements quoted, stance label optional, link to source
chunk) + optional notes panel, with resolution_status badge
(open/resolved/irreconcilable)
R-NNNN shares the contradiction_id_seq slot with relation per
CLAUDE.md naming — same conceptual class (a connection between two
pieces of evidence in tension).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
758 lines
30 KiB
TypeScript
758 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'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" : "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 (
|
|
<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>
|
|
);
|
|
}
|