disclosure-bureau/web/components/chat-bubble.tsx

753 lines
29 KiB
TypeScript
Raw Normal View History

"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";
ship: synthesize 158 entities, AG-UI artifacts, chat persistence, auth flow Fase 3 onda 2 — entity synthesis at scale: - scripts/synthesize/20_entity_summary.py: queries DB for entities with total_mentions ≥ threshold + top-K verbatim chunk snippets via entity_mentions JOIN, prompts Sonnet (Holmes-Watson voice, bilingual), writes narrative_summary EN+PT-BR + summary_status=synthesized. Ran on 187 candidates (mentions ≥ 20) → 158 OK · 1 err · 29 skipped (no snippets). Combined with anchor curation: 20 curated + 158 synthesized = 178 entities with real narrative (vs 0 a day ago). Fase 4 — chat with typed artifacts + persistence: - lib/chat/agui.ts: AG-UI v1 typed Artifact union (citation, crop_image, entity_card, evidence_card, hypothesis_card, case_card, navigation_offer) alongside the existing event types. - lib/chat/tools.ts + openrouter.ts: hybrid_search emits up to 6 citation + crop_image artifacts per query. Provider collects them and returns in done.artifacts so the route can persist. - api/sessions/[id]/messages: persist artifacts to messages.citations. - components/chat-bubble.tsx: ArtifactCard renders inline cards (citation, crop_image, entity_card, navigation_offer) for streamed and persisted messages. activeId now persisted in localStorage so navigation between pages keeps the same conversation. New sessions are lazy (only when user has zero). loadMessages hydrates tools + artifacts from server. CRUD UI: rename (✎) + archive (🗑) buttons per session in the list. Home search: - doc-list-filters: input now fires hybrid_search (rerank=0 for speed) in parallel with the local title filter; chunk hits render above the doc grid with snippet + score + classification. - api/search/hybrid: accept ?rerank=0 to skip the cross-encoder (1.3s vs 60s). Auth flow: - infra: SMTP_HOST=mail.spacemail.com:587 + DMARC published; mail now lands in inbox. GOTRUE_MAILER_AUTOCONFIRM=false (real email verification). - kong.yml: proxy /auth/callback on api.disclosure.top → web:3000 so PKCE email links don't 404 at the gateway. - web/app/auth/callback: handle both ?code= (OAuth) and ?token=&type= (PKCE); redirect to the public site host before verifyOtp so the session cookie lands on the right domain. Audit deliverables: - .nirvana/outputs/disclosure-bureau/.../systems-atelier/: 5 docs (code analysis, tech debt, discovery brief, system arch, 5 ADRs) authored by sa-principal that produced this roadmap. Kept in-tree for traceability.
2026-05-18 06:52:59 +00:00
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[];
ship: synthesize 158 entities, AG-UI artifacts, chat persistence, auth flow Fase 3 onda 2 — entity synthesis at scale: - scripts/synthesize/20_entity_summary.py: queries DB for entities with total_mentions ≥ threshold + top-K verbatim chunk snippets via entity_mentions JOIN, prompts Sonnet (Holmes-Watson voice, bilingual), writes narrative_summary EN+PT-BR + summary_status=synthesized. Ran on 187 candidates (mentions ≥ 20) → 158 OK · 1 err · 29 skipped (no snippets). Combined with anchor curation: 20 curated + 158 synthesized = 178 entities with real narrative (vs 0 a day ago). Fase 4 — chat with typed artifacts + persistence: - lib/chat/agui.ts: AG-UI v1 typed Artifact union (citation, crop_image, entity_card, evidence_card, hypothesis_card, case_card, navigation_offer) alongside the existing event types. - lib/chat/tools.ts + openrouter.ts: hybrid_search emits up to 6 citation + crop_image artifacts per query. Provider collects them and returns in done.artifacts so the route can persist. - api/sessions/[id]/messages: persist artifacts to messages.citations. - components/chat-bubble.tsx: ArtifactCard renders inline cards (citation, crop_image, entity_card, navigation_offer) for streamed and persisted messages. activeId now persisted in localStorage so navigation between pages keeps the same conversation. New sessions are lazy (only when user has zero). loadMessages hydrates tools + artifacts from server. CRUD UI: rename (✎) + archive (🗑) buttons per session in the list. Home search: - doc-list-filters: input now fires hybrid_search (rerank=0 for speed) in parallel with the local title filter; chunk hits render above the doc grid with snippet + score + classification. - api/search/hybrid: accept ?rerank=0 to skip the cross-encoder (1.3s vs 60s). Auth flow: - infra: SMTP_HOST=mail.spacemail.com:587 + DMARC published; mail now lands in inbox. GOTRUE_MAILER_AUTOCONFIRM=false (real email verification). - kong.yml: proxy /auth/callback on api.disclosure.top → web:3000 so PKCE email links don't 404 at the gateway. - web/app/auth/callback: handle both ?code= (OAuth) and ?token=&type= (PKCE); redirect to the public site host before verifyOtp so the session cookie lands on the right domain. Audit deliverables: - .nirvana/outputs/disclosure-bureau/.../systems-atelier/: 5 docs (code analysis, tech debt, discovery brief, system arch, 5 ADRs) authored by sa-principal that produced this roadmap. Kept in-tree for traceability.
2026-05-18 06:52:59 +00:00
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]);
ship: synthesize 158 entities, AG-UI artifacts, chat persistence, auth flow Fase 3 onda 2 — entity synthesis at scale: - scripts/synthesize/20_entity_summary.py: queries DB for entities with total_mentions ≥ threshold + top-K verbatim chunk snippets via entity_mentions JOIN, prompts Sonnet (Holmes-Watson voice, bilingual), writes narrative_summary EN+PT-BR + summary_status=synthesized. Ran on 187 candidates (mentions ≥ 20) → 158 OK · 1 err · 29 skipped (no snippets). Combined with anchor curation: 20 curated + 158 synthesized = 178 entities with real narrative (vs 0 a day ago). Fase 4 — chat with typed artifacts + persistence: - lib/chat/agui.ts: AG-UI v1 typed Artifact union (citation, crop_image, entity_card, evidence_card, hypothesis_card, case_card, navigation_offer) alongside the existing event types. - lib/chat/tools.ts + openrouter.ts: hybrid_search emits up to 6 citation + crop_image artifacts per query. Provider collects them and returns in done.artifacts so the route can persist. - api/sessions/[id]/messages: persist artifacts to messages.citations. - components/chat-bubble.tsx: ArtifactCard renders inline cards (citation, crop_image, entity_card, navigation_offer) for streamed and persisted messages. activeId now persisted in localStorage so navigation between pages keeps the same conversation. New sessions are lazy (only when user has zero). loadMessages hydrates tools + artifacts from server. CRUD UI: rename (✎) + archive (🗑) buttons per session in the list. Home search: - doc-list-filters: input now fires hybrid_search (rerank=0 for speed) in parallel with the local title filter; chunk hits render above the doc grid with snippet + score + classification. - api/search/hybrid: accept ?rerank=0 to skip the cross-encoder (1.3s vs 60s). Auth flow: - infra: SMTP_HOST=mail.spacemail.com:587 + DMARC published; mail now lands in inbox. GOTRUE_MAILER_AUTOCONFIRM=false (real email verification). - kong.yml: proxy /auth/callback on api.disclosure.top → web:3000 so PKCE email links don't 404 at the gateway. - web/app/auth/callback: handle both ?code= (OAuth) and ?token=&type= (PKCE); redirect to the public site host before verifyOtp so the session cookie lands on the right domain. Audit deliverables: - .nirvana/outputs/disclosure-bureau/.../systems-atelier/: 5 docs (code analysis, tech debt, discovery brief, system arch, 5 ADRs) authored by sa-principal that produced this roadmap. Kept in-tree for traceability.
2026-05-18 06:52:59 +00:00
// 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}`);
ship: synthesize 158 entities, AG-UI artifacts, chat persistence, auth flow Fase 3 onda 2 — entity synthesis at scale: - scripts/synthesize/20_entity_summary.py: queries DB for entities with total_mentions ≥ threshold + top-K verbatim chunk snippets via entity_mentions JOIN, prompts Sonnet (Holmes-Watson voice, bilingual), writes narrative_summary EN+PT-BR + summary_status=synthesized. Ran on 187 candidates (mentions ≥ 20) → 158 OK · 1 err · 29 skipped (no snippets). Combined with anchor curation: 20 curated + 158 synthesized = 178 entities with real narrative (vs 0 a day ago). Fase 4 — chat with typed artifacts + persistence: - lib/chat/agui.ts: AG-UI v1 typed Artifact union (citation, crop_image, entity_card, evidence_card, hypothesis_card, case_card, navigation_offer) alongside the existing event types. - lib/chat/tools.ts + openrouter.ts: hybrid_search emits up to 6 citation + crop_image artifacts per query. Provider collects them and returns in done.artifacts so the route can persist. - api/sessions/[id]/messages: persist artifacts to messages.citations. - components/chat-bubble.tsx: ArtifactCard renders inline cards (citation, crop_image, entity_card, navigation_offer) for streamed and persisted messages. activeId now persisted in localStorage so navigation between pages keeps the same conversation. New sessions are lazy (only when user has zero). loadMessages hydrates tools + artifacts from server. CRUD UI: rename (✎) + archive (🗑) buttons per session in the list. Home search: - doc-list-filters: input now fires hybrid_search (rerank=0 for speed) in parallel with the local title filter; chunk hits render above the doc grid with snippet + score + classification. - api/search/hybrid: accept ?rerank=0 to skip the cross-encoder (1.3s vs 60s). Auth flow: - infra: SMTP_HOST=mail.spacemail.com:587 + DMARC published; mail now lands in inbox. GOTRUE_MAILER_AUTOCONFIRM=false (real email verification). - kong.yml: proxy /auth/callback on api.disclosure.top → web:3000 so PKCE email links don't 404 at the gateway. - web/app/auth/callback: handle both ?code= (OAuth) and ?token=&type= (PKCE); redirect to the public site host before verifyOtp so the session cookie lands on the right domain. Audit deliverables: - .nirvana/outputs/disclosure-bureau/.../systems-atelier/: 5 docs (code analysis, tech debt, discovery brief, system arch, 5 ADRs) authored by sa-principal that produced this roadmap. Kept in-tree for traceability.
2026-05-18 06:52:59 +00:00
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);
ship: synthesize 158 entities, AG-UI artifacts, chat persistence, auth flow Fase 3 onda 2 — entity synthesis at scale: - scripts/synthesize/20_entity_summary.py: queries DB for entities with total_mentions ≥ threshold + top-K verbatim chunk snippets via entity_mentions JOIN, prompts Sonnet (Holmes-Watson voice, bilingual), writes narrative_summary EN+PT-BR + summary_status=synthesized. Ran on 187 candidates (mentions ≥ 20) → 158 OK · 1 err · 29 skipped (no snippets). Combined with anchor curation: 20 curated + 158 synthesized = 178 entities with real narrative (vs 0 a day ago). Fase 4 — chat with typed artifacts + persistence: - lib/chat/agui.ts: AG-UI v1 typed Artifact union (citation, crop_image, entity_card, evidence_card, hypothesis_card, case_card, navigation_offer) alongside the existing event types. - lib/chat/tools.ts + openrouter.ts: hybrid_search emits up to 6 citation + crop_image artifacts per query. Provider collects them and returns in done.artifacts so the route can persist. - api/sessions/[id]/messages: persist artifacts to messages.citations. - components/chat-bubble.tsx: ArtifactCard renders inline cards (citation, crop_image, entity_card, navigation_offer) for streamed and persisted messages. activeId now persisted in localStorage so navigation between pages keeps the same conversation. New sessions are lazy (only when user has zero). loadMessages hydrates tools + artifacts from server. CRUD UI: rename (✎) + archive (🗑) buttons per session in the list. Home search: - doc-list-filters: input now fires hybrid_search (rerank=0 for speed) in parallel with the local title filter; chunk hits render above the doc grid with snippet + score + classification. - api/search/hybrid: accept ?rerank=0 to skip the cross-encoder (1.3s vs 60s). Auth flow: - infra: SMTP_HOST=mail.spacemail.com:587 + DMARC published; mail now lands in inbox. GOTRUE_MAILER_AUTOCONFIRM=false (real email verification). - kong.yml: proxy /auth/callback on api.disclosure.top → web:3000 so PKCE email links don't 404 at the gateway. - web/app/auth/callback: handle both ?code= (OAuth) and ?token=&type= (PKCE); redirect to the public site host before verifyOtp so the session cookie lands on the right domain. Audit deliverables: - .nirvana/outputs/disclosure-bureau/.../systems-atelier/: 5 docs (code analysis, tech debt, discovery brief, system arch, 5 ADRs) authored by sa-principal that produced this roadmap. Kept in-tree for traceability.
2026-05-18 06:52:59 +00:00
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]);
ship: synthesize 158 entities, AG-UI artifacts, chat persistence, auth flow Fase 3 onda 2 — entity synthesis at scale: - scripts/synthesize/20_entity_summary.py: queries DB for entities with total_mentions ≥ threshold + top-K verbatim chunk snippets via entity_mentions JOIN, prompts Sonnet (Holmes-Watson voice, bilingual), writes narrative_summary EN+PT-BR + summary_status=synthesized. Ran on 187 candidates (mentions ≥ 20) → 158 OK · 1 err · 29 skipped (no snippets). Combined with anchor curation: 20 curated + 158 synthesized = 178 entities with real narrative (vs 0 a day ago). Fase 4 — chat with typed artifacts + persistence: - lib/chat/agui.ts: AG-UI v1 typed Artifact union (citation, crop_image, entity_card, evidence_card, hypothesis_card, case_card, navigation_offer) alongside the existing event types. - lib/chat/tools.ts + openrouter.ts: hybrid_search emits up to 6 citation + crop_image artifacts per query. Provider collects them and returns in done.artifacts so the route can persist. - api/sessions/[id]/messages: persist artifacts to messages.citations. - components/chat-bubble.tsx: ArtifactCard renders inline cards (citation, crop_image, entity_card, navigation_offer) for streamed and persisted messages. activeId now persisted in localStorage so navigation between pages keeps the same conversation. New sessions are lazy (only when user has zero). loadMessages hydrates tools + artifacts from server. CRUD UI: rename (✎) + archive (🗑) buttons per session in the list. Home search: - doc-list-filters: input now fires hybrid_search (rerank=0 for speed) in parallel with the local title filter; chunk hits render above the doc grid with snippet + score + classification. - api/search/hybrid: accept ?rerank=0 to skip the cross-encoder (1.3s vs 60s). Auth flow: - infra: SMTP_HOST=mail.spacemail.com:587 + DMARC published; mail now lands in inbox. GOTRUE_MAILER_AUTOCONFIRM=false (real email verification). - kong.yml: proxy /auth/callback on api.disclosure.top → web:3000 so PKCE email links don't 404 at the gateway. - web/app/auth/callback: handle both ?code= (OAuth) and ?token=&type= (PKCE); redirect to the public site host before verifyOtp so the session cookie lands on the right domain. Audit deliverables: - .nirvana/outputs/disclosure-bureau/.../systems-atelier/: 5 docs (code analysis, tech debt, discovery brief, system arch, 5 ADRs) authored by sa-principal that produced this roadmap. Kept in-tree for traceability.
2026-05-18 06:52:59 +00:00
// 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(() => {
ship: synthesize 158 entities, AG-UI artifacts, chat persistence, auth flow Fase 3 onda 2 — entity synthesis at scale: - scripts/synthesize/20_entity_summary.py: queries DB for entities with total_mentions ≥ threshold + top-K verbatim chunk snippets via entity_mentions JOIN, prompts Sonnet (Holmes-Watson voice, bilingual), writes narrative_summary EN+PT-BR + summary_status=synthesized. Ran on 187 candidates (mentions ≥ 20) → 158 OK · 1 err · 29 skipped (no snippets). Combined with anchor curation: 20 curated + 158 synthesized = 178 entities with real narrative (vs 0 a day ago). Fase 4 — chat with typed artifacts + persistence: - lib/chat/agui.ts: AG-UI v1 typed Artifact union (citation, crop_image, entity_card, evidence_card, hypothesis_card, case_card, navigation_offer) alongside the existing event types. - lib/chat/tools.ts + openrouter.ts: hybrid_search emits up to 6 citation + crop_image artifacts per query. Provider collects them and returns in done.artifacts so the route can persist. - api/sessions/[id]/messages: persist artifacts to messages.citations. - components/chat-bubble.tsx: ArtifactCard renders inline cards (citation, crop_image, entity_card, navigation_offer) for streamed and persisted messages. activeId now persisted in localStorage so navigation between pages keeps the same conversation. New sessions are lazy (only when user has zero). loadMessages hydrates tools + artifacts from server. CRUD UI: rename (✎) + archive (🗑) buttons per session in the list. Home search: - doc-list-filters: input now fires hybrid_search (rerank=0 for speed) in parallel with the local title filter; chunk hits render above the doc grid with snippet + score + classification. - api/search/hybrid: accept ?rerank=0 to skip the cross-encoder (1.3s vs 60s). Auth flow: - infra: SMTP_HOST=mail.spacemail.com:587 + DMARC published; mail now lands in inbox. GOTRUE_MAILER_AUTOCONFIRM=false (real email verification). - kong.yml: proxy /auth/callback on api.disclosure.top → web:3000 so PKCE email links don't 404 at the gateway. - web/app/auth/callback: handle both ?code= (OAuth) and ?token=&type= (PKCE); redirect to the public site host before verifyOtp so the session cookie lands on the right domain. Audit deliverables: - .nirvana/outputs/disclosure-bureau/.../systems-atelier/: 5 docs (code analysis, tech debt, discovery brief, system arch, 5 ADRs) authored by sa-principal that produced this roadmap. Kept in-tree for traceability.
2026-05-18 06:52:59 +00:00
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); });
ship: synthesize 158 entities, AG-UI artifacts, chat persistence, auth flow Fase 3 onda 2 — entity synthesis at scale: - scripts/synthesize/20_entity_summary.py: queries DB for entities with total_mentions ≥ threshold + top-K verbatim chunk snippets via entity_mentions JOIN, prompts Sonnet (Holmes-Watson voice, bilingual), writes narrative_summary EN+PT-BR + summary_status=synthesized. Ran on 187 candidates (mentions ≥ 20) → 158 OK · 1 err · 29 skipped (no snippets). Combined with anchor curation: 20 curated + 158 synthesized = 178 entities with real narrative (vs 0 a day ago). Fase 4 — chat with typed artifacts + persistence: - lib/chat/agui.ts: AG-UI v1 typed Artifact union (citation, crop_image, entity_card, evidence_card, hypothesis_card, case_card, navigation_offer) alongside the existing event types. - lib/chat/tools.ts + openrouter.ts: hybrid_search emits up to 6 citation + crop_image artifacts per query. Provider collects them and returns in done.artifacts so the route can persist. - api/sessions/[id]/messages: persist artifacts to messages.citations. - components/chat-bubble.tsx: ArtifactCard renders inline cards (citation, crop_image, entity_card, navigation_offer) for streamed and persisted messages. activeId now persisted in localStorage so navigation between pages keeps the same conversation. New sessions are lazy (only when user has zero). loadMessages hydrates tools + artifacts from server. CRUD UI: rename (✎) + archive (🗑) buttons per session in the list. Home search: - doc-list-filters: input now fires hybrid_search (rerank=0 for speed) in parallel with the local title filter; chunk hits render above the doc grid with snippet + score + classification. - api/search/hybrid: accept ?rerank=0 to skip the cross-encoder (1.3s vs 60s). Auth flow: - infra: SMTP_HOST=mail.spacemail.com:587 + DMARC published; mail now lands in inbox. GOTRUE_MAILER_AUTOCONFIRM=false (real email verification). - kong.yml: proxy /auth/callback on api.disclosure.top → web:3000 so PKCE email links don't 404 at the gateway. - web/app/auth/callback: handle both ?code= (OAuth) and ?token=&type= (PKCE); redirect to the public site host before verifyOtp so the session cookie lands on the right domain. Audit deliverables: - .nirvana/outputs/disclosure-bureau/.../systems-atelier/: 5 docs (code analysis, tech debt, discovery brief, system arch, 5 ADRs) authored by sa-principal that produced this roadmap. Kept in-tree for traceability.
2026-05-18 06:52:59 +00:00
} 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) => (
ship: synthesize 158 entities, AG-UI artifacts, chat persistence, auth flow Fase 3 onda 2 — entity synthesis at scale: - scripts/synthesize/20_entity_summary.py: queries DB for entities with total_mentions ≥ threshold + top-K verbatim chunk snippets via entity_mentions JOIN, prompts Sonnet (Holmes-Watson voice, bilingual), writes narrative_summary EN+PT-BR + summary_status=synthesized. Ran on 187 candidates (mentions ≥ 20) → 158 OK · 1 err · 29 skipped (no snippets). Combined with anchor curation: 20 curated + 158 synthesized = 178 entities with real narrative (vs 0 a day ago). Fase 4 — chat with typed artifacts + persistence: - lib/chat/agui.ts: AG-UI v1 typed Artifact union (citation, crop_image, entity_card, evidence_card, hypothesis_card, case_card, navigation_offer) alongside the existing event types. - lib/chat/tools.ts + openrouter.ts: hybrid_search emits up to 6 citation + crop_image artifacts per query. Provider collects them and returns in done.artifacts so the route can persist. - api/sessions/[id]/messages: persist artifacts to messages.citations. - components/chat-bubble.tsx: ArtifactCard renders inline cards (citation, crop_image, entity_card, navigation_offer) for streamed and persisted messages. activeId now persisted in localStorage so navigation between pages keeps the same conversation. New sessions are lazy (only when user has zero). loadMessages hydrates tools + artifacts from server. CRUD UI: rename (✎) + archive (🗑) buttons per session in the list. Home search: - doc-list-filters: input now fires hybrid_search (rerank=0 for speed) in parallel with the local title filter; chunk hits render above the doc grid with snippet + score + classification. - api/search/hybrid: accept ?rerank=0 to skip the cross-encoder (1.3s vs 60s). Auth flow: - infra: SMTP_HOST=mail.spacemail.com:587 + DMARC published; mail now lands in inbox. GOTRUE_MAILER_AUTOCONFIRM=false (real email verification). - kong.yml: proxy /auth/callback on api.disclosure.top → web:3000 so PKCE email links don't 404 at the gateway. - web/app/auth/callback: handle both ?code= (OAuth) and ?token=&type= (PKCE); redirect to the public site host before verifyOtp so the session cookie lands on the right domain. Audit deliverables: - .nirvana/outputs/disclosure-bureau/.../systems-atelier/: 5 docs (code analysis, tech debt, discovery brief, system arch, 5 ADRs) authored by sa-principal that produced this roadmap. Kept in-tree for traceability.
2026-05-18 06:52:59 +00:00
<li key={s.id} className="group flex items-stretch border-b border-[rgba(0,255,156,0.08)]">
<button
ship: synthesize 158 entities, AG-UI artifacts, chat persistence, auth flow Fase 3 onda 2 — entity synthesis at scale: - scripts/synthesize/20_entity_summary.py: queries DB for entities with total_mentions ≥ threshold + top-K verbatim chunk snippets via entity_mentions JOIN, prompts Sonnet (Holmes-Watson voice, bilingual), writes narrative_summary EN+PT-BR + summary_status=synthesized. Ran on 187 candidates (mentions ≥ 20) → 158 OK · 1 err · 29 skipped (no snippets). Combined with anchor curation: 20 curated + 158 synthesized = 178 entities with real narrative (vs 0 a day ago). Fase 4 — chat with typed artifacts + persistence: - lib/chat/agui.ts: AG-UI v1 typed Artifact union (citation, crop_image, entity_card, evidence_card, hypothesis_card, case_card, navigation_offer) alongside the existing event types. - lib/chat/tools.ts + openrouter.ts: hybrid_search emits up to 6 citation + crop_image artifacts per query. Provider collects them and returns in done.artifacts so the route can persist. - api/sessions/[id]/messages: persist artifacts to messages.citations. - components/chat-bubble.tsx: ArtifactCard renders inline cards (citation, crop_image, entity_card, navigation_offer) for streamed and persisted messages. activeId now persisted in localStorage so navigation between pages keeps the same conversation. New sessions are lazy (only when user has zero). loadMessages hydrates tools + artifacts from server. CRUD UI: rename (✎) + archive (🗑) buttons per session in the list. Home search: - doc-list-filters: input now fires hybrid_search (rerank=0 for speed) in parallel with the local title filter; chunk hits render above the doc grid with snippet + score + classification. - api/search/hybrid: accept ?rerank=0 to skip the cross-encoder (1.3s vs 60s). Auth flow: - infra: SMTP_HOST=mail.spacemail.com:587 + DMARC published; mail now lands in inbox. GOTRUE_MAILER_AUTOCONFIRM=false (real email verification). - kong.yml: proxy /auth/callback on api.disclosure.top → web:3000 so PKCE email links don't 404 at the gateway. - web/app/auth/callback: handle both ?code= (OAuth) and ?token=&type= (PKCE); redirect to the public site host before verifyOtp so the session cookie lands on the right domain. Audit deliverables: - .nirvana/outputs/disclosure-bureau/.../systems-atelier/: 5 docs (code analysis, tech debt, discovery brief, system arch, 5 ADRs) authored by sa-principal that produced this roadmap. Kept in-tree for traceability.
2026-05-18 06:52:59 +00:00
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>
ship: synthesize 158 entities, AG-UI artifacts, chat persistence, auth flow Fase 3 onda 2 — entity synthesis at scale: - scripts/synthesize/20_entity_summary.py: queries DB for entities with total_mentions ≥ threshold + top-K verbatim chunk snippets via entity_mentions JOIN, prompts Sonnet (Holmes-Watson voice, bilingual), writes narrative_summary EN+PT-BR + summary_status=synthesized. Ran on 187 candidates (mentions ≥ 20) → 158 OK · 1 err · 29 skipped (no snippets). Combined with anchor curation: 20 curated + 158 synthesized = 178 entities with real narrative (vs 0 a day ago). Fase 4 — chat with typed artifacts + persistence: - lib/chat/agui.ts: AG-UI v1 typed Artifact union (citation, crop_image, entity_card, evidence_card, hypothesis_card, case_card, navigation_offer) alongside the existing event types. - lib/chat/tools.ts + openrouter.ts: hybrid_search emits up to 6 citation + crop_image artifacts per query. Provider collects them and returns in done.artifacts so the route can persist. - api/sessions/[id]/messages: persist artifacts to messages.citations. - components/chat-bubble.tsx: ArtifactCard renders inline cards (citation, crop_image, entity_card, navigation_offer) for streamed and persisted messages. activeId now persisted in localStorage so navigation between pages keeps the same conversation. New sessions are lazy (only when user has zero). loadMessages hydrates tools + artifacts from server. CRUD UI: rename (✎) + archive (🗑) buttons per session in the list. Home search: - doc-list-filters: input now fires hybrid_search (rerank=0 for speed) in parallel with the local title filter; chunk hits render above the doc grid with snippet + score + classification. - api/search/hybrid: accept ?rerank=0 to skip the cross-encoder (1.3s vs 60s). Auth flow: - infra: SMTP_HOST=mail.spacemail.com:587 + DMARC published; mail now lands in inbox. GOTRUE_MAILER_AUTOCONFIRM=false (real email verification). - kong.yml: proxy /auth/callback on api.disclosure.top → web:3000 so PKCE email links don't 404 at the gateway. - web/app/auth/callback: handle both ?code= (OAuth) and ?token=&type= (PKCE); redirect to the public site host before verifyOtp so the session cookie lands on the right domain. Audit deliverables: - .nirvana/outputs/disclosure-bureau/.../systems-atelier/: 5 docs (code analysis, tech debt, discovery brief, system arch, 5 ADRs) authored by sa-principal that produced this roadmap. Kept in-tree for traceability.
2026-05-18 06:52:59 +00:00
<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>
))}
ship: synthesize 158 entities, AG-UI artifacts, chat persistence, auth flow Fase 3 onda 2 — entity synthesis at scale: - scripts/synthesize/20_entity_summary.py: queries DB for entities with total_mentions ≥ threshold + top-K verbatim chunk snippets via entity_mentions JOIN, prompts Sonnet (Holmes-Watson voice, bilingual), writes narrative_summary EN+PT-BR + summary_status=synthesized. Ran on 187 candidates (mentions ≥ 20) → 158 OK · 1 err · 29 skipped (no snippets). Combined with anchor curation: 20 curated + 158 synthesized = 178 entities with real narrative (vs 0 a day ago). Fase 4 — chat with typed artifacts + persistence: - lib/chat/agui.ts: AG-UI v1 typed Artifact union (citation, crop_image, entity_card, evidence_card, hypothesis_card, case_card, navigation_offer) alongside the existing event types. - lib/chat/tools.ts + openrouter.ts: hybrid_search emits up to 6 citation + crop_image artifacts per query. Provider collects them and returns in done.artifacts so the route can persist. - api/sessions/[id]/messages: persist artifacts to messages.citations. - components/chat-bubble.tsx: ArtifactCard renders inline cards (citation, crop_image, entity_card, navigation_offer) for streamed and persisted messages. activeId now persisted in localStorage so navigation between pages keeps the same conversation. New sessions are lazy (only when user has zero). loadMessages hydrates tools + artifacts from server. CRUD UI: rename (✎) + archive (🗑) buttons per session in the list. Home search: - doc-list-filters: input now fires hybrid_search (rerank=0 for speed) in parallel with the local title filter; chunk hits render above the doc grid with snippet + score + classification. - api/search/hybrid: accept ?rerank=0 to skip the cross-encoder (1.3s vs 60s). Auth flow: - infra: SMTP_HOST=mail.spacemail.com:587 + DMARC published; mail now lands in inbox. GOTRUE_MAILER_AUTOCONFIRM=false (real email verification). - kong.yml: proxy /auth/callback on api.disclosure.top → web:3000 so PKCE email links don't 404 at the gateway. - web/app/auth/callback: handle both ?code= (OAuth) and ?token=&type= (PKCE); redirect to the public site host before verifyOtp so the session cookie lands on the right domain. Audit deliverables: - .nirvana/outputs/disclosure-bureau/.../systems-atelier/: 5 docs (code analysis, tech debt, discovery brief, system arch, 5 ADRs) authored by sa-principal that produced this roadmap. Kept in-tree for traceability.
2026-05-18 06:52:59 +00:00
{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>
)}
ship: synthesize 158 entities, AG-UI artifacts, chat persistence, auth flow Fase 3 onda 2 — entity synthesis at scale: - scripts/synthesize/20_entity_summary.py: queries DB for entities with total_mentions ≥ threshold + top-K verbatim chunk snippets via entity_mentions JOIN, prompts Sonnet (Holmes-Watson voice, bilingual), writes narrative_summary EN+PT-BR + summary_status=synthesized. Ran on 187 candidates (mentions ≥ 20) → 158 OK · 1 err · 29 skipped (no snippets). Combined with anchor curation: 20 curated + 158 synthesized = 178 entities with real narrative (vs 0 a day ago). Fase 4 — chat with typed artifacts + persistence: - lib/chat/agui.ts: AG-UI v1 typed Artifact union (citation, crop_image, entity_card, evidence_card, hypothesis_card, case_card, navigation_offer) alongside the existing event types. - lib/chat/tools.ts + openrouter.ts: hybrid_search emits up to 6 citation + crop_image artifacts per query. Provider collects them and returns in done.artifacts so the route can persist. - api/sessions/[id]/messages: persist artifacts to messages.citations. - components/chat-bubble.tsx: ArtifactCard renders inline cards (citation, crop_image, entity_card, navigation_offer) for streamed and persisted messages. activeId now persisted in localStorage so navigation between pages keeps the same conversation. New sessions are lazy (only when user has zero). loadMessages hydrates tools + artifacts from server. CRUD UI: rename (✎) + archive (🗑) buttons per session in the list. Home search: - doc-list-filters: input now fires hybrid_search (rerank=0 for speed) in parallel with the local title filter; chunk hits render above the doc grid with snippet + score + classification. - api/search/hybrid: accept ?rerank=0 to skip the cross-encoder (1.3s vs 60s). Auth flow: - infra: SMTP_HOST=mail.spacemail.com:587 + DMARC published; mail now lands in inbox. GOTRUE_MAILER_AUTOCONFIRM=false (real email verification). - kong.yml: proxy /auth/callback on api.disclosure.top → web:3000 so PKCE email links don't 404 at the gateway. - web/app/auth/callback: handle both ?code= (OAuth) and ?token=&type= (PKCE); redirect to the public site host before verifyOtp so the session cookie lands on the right domain. Audit deliverables: - .nirvana/outputs/disclosure-bureau/.../systems-atelier/: 5 docs (code analysis, tech debt, discovery brief, system arch, 5 ADRs) authored by sa-principal that produced this roadmap. Kept in-tree for traceability.
2026-05-18 06:52:59 +00:00
{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>
);
}
ship: synthesize 158 entities, AG-UI artifacts, chat persistence, auth flow Fase 3 onda 2 — entity synthesis at scale: - scripts/synthesize/20_entity_summary.py: queries DB for entities with total_mentions ≥ threshold + top-K verbatim chunk snippets via entity_mentions JOIN, prompts Sonnet (Holmes-Watson voice, bilingual), writes narrative_summary EN+PT-BR + summary_status=synthesized. Ran on 187 candidates (mentions ≥ 20) → 158 OK · 1 err · 29 skipped (no snippets). Combined with anchor curation: 20 curated + 158 synthesized = 178 entities with real narrative (vs 0 a day ago). Fase 4 — chat with typed artifacts + persistence: - lib/chat/agui.ts: AG-UI v1 typed Artifact union (citation, crop_image, entity_card, evidence_card, hypothesis_card, case_card, navigation_offer) alongside the existing event types. - lib/chat/tools.ts + openrouter.ts: hybrid_search emits up to 6 citation + crop_image artifacts per query. Provider collects them and returns in done.artifacts so the route can persist. - api/sessions/[id]/messages: persist artifacts to messages.citations. - components/chat-bubble.tsx: ArtifactCard renders inline cards (citation, crop_image, entity_card, navigation_offer) for streamed and persisted messages. activeId now persisted in localStorage so navigation between pages keeps the same conversation. New sessions are lazy (only when user has zero). loadMessages hydrates tools + artifacts from server. CRUD UI: rename (✎) + archive (🗑) buttons per session in the list. Home search: - doc-list-filters: input now fires hybrid_search (rerank=0 for speed) in parallel with the local title filter; chunk hits render above the doc grid with snippet + score + classification. - api/search/hybrid: accept ?rerank=0 to skip the cross-encoder (1.3s vs 60s). Auth flow: - infra: SMTP_HOST=mail.spacemail.com:587 + DMARC published; mail now lands in inbox. GOTRUE_MAILER_AUTOCONFIRM=false (real email verification). - kong.yml: proxy /auth/callback on api.disclosure.top → web:3000 so PKCE email links don't 404 at the gateway. - web/app/auth/callback: handle both ?code= (OAuth) and ?token=&type= (PKCE); redirect to the public site host before verifyOtp so the session cookie lands on the right domain. Audit deliverables: - .nirvana/outputs/disclosure-bureau/.../systems-atelier/: 5 docs (code analysis, tech debt, discovery brief, system arch, 5 ADRs) authored by sa-principal that produced this roadmap. Kept in-tree for traceability.
2026-05-18 06:52:59 +00:00
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;
W3.6: chat request_investigation tool + /jobs/[id] case-file viewer Closes the loop between the chat UI and the Investigation Bureau runtime. Chat tool (web/lib/chat/tools.ts): - request_investigation { kind, question, doc_id?, chunks?, claim? } INSERTs a row in public.investigation_jobs and returns { job_id, kind, status, eta_seconds, status_url, detective }. - kind=hypothesis_tournament → Holmes (1 question → 2-3 rival hypotheses) - kind=evidence_chain → Locard (1 doc → grade-A/B/C evidence with chain of custody, default top-5 anomaly chunks) - Plumbed user.email through ToolHandlerContext so triggered_by audits the requesting user. Public job viewer: - GET /api/jobs/[id] joins investigation_jobs → public.evidence + public.hypotheses for the IDs surfaced in outputs[]. Returns one payload the page can render without n+1 round-trips. Strips triggered_by from the response (it carries the user's email). - app/jobs/[id]/page.tsx server-renders the case-file shell: detective lore header (Holmes blue or Locard green), question chip, scope chip with link back to the document. - components/job-status-poller.tsx client island that polls every 3 s while non-terminal, then once on terminal to hydrate evidence + hypotheses. Renders: · Phase tracker (queued → running → complete | failed) · Hypothesis cards w/ prior + posterior bars + Δ delta indicator + Tetlock band badge (high/medium/low/speculation) · Argument-for / argument-against with [[wiki-link]] auto-linking to /d/<doc>/p<NNN>#<cNNNN> · Evidence cards w/ Grade A/B/C badge + verbatim blockquote + bbox crop preview via /api/crop + custody-steps disclosure · Empty/in-flight panel ("os detetives estão lendo o corpus") · Failure panel surfacing error + partial outputs Inline chat-bubble card (components/chat-bubble.tsx): - ToolTrace.richRender recognises request_investigation results and renders a detective banner with status + ETA + link to /jobs/[id] (target=_blank). Error case renders a red strip with the message. UX flow now: user asks Sherlock a question → request_investigation queues the job → chat card shows "🔎 Holmes · hypothesis_tournament · ETA ~60s" → user clicks → /jobs/<id> live-updates → 60 s later, 2-3 rival hypotheses + their arguments + chunk citations are rendered with Bayesian update visible. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:26:18 +00:00
// 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 isHolmes = r.kind === "hypothesis_tournament";
const tone = isHolmes ? "text-[#7fdbff] border-[#7fdbff]" : "text-[#06d6a0] border-[#06d6a0]";
return (
<div className={`mt-1 ml-3 p-3 rounded border ${tone} bg-[#060a13]`}>
<div className="flex items-baseline justify-between mb-1">
<div className={`font-mono text-[11px] font-bold ${isHolmes ? "text-[#7fdbff]" : "text-[#06d6a0]"}`}>
🔎 {isHolmes ? "Holmes" : "Locard"} · {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 ${isHolmes ? "text-[#7fdbff]" : "text-[#06d6a0]"} 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>
);
}