/** * CommandPalette — global Cmd+K (or Ctrl+K) overlay with hybrid_search. * * Renders directly in . Type a query, debounced 250ms, hit /api/search/hybrid, * arrow-key navigation, Enter opens the chunk anchor on /d/#cNNNN. */ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; interface Hit { 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; } export function CommandPalette() { const router = useRouter(); const [open, setOpen] = useState(false); const [q, setQ] = useState(""); const [loading, setLoading] = useState(false); const [hits, setHits] = useState([]); const [sel, setSel] = useState(0); const inputRef = useRef(null); // Global hotkey useEffect(() => { function onKey(e: KeyboardEvent) { const cmd = e.metaKey || e.ctrlKey; if (cmd && e.key.toLowerCase() === "k") { e.preventDefault(); setOpen((o) => !o); } else if (e.key === "Escape") { setOpen(false); } } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); // Focus input when opened useEffect(() => { if (open) setTimeout(() => inputRef.current?.focus(), 30); else { setQ(""); setHits([]); setSel(0); } }, [open]); // Debounced search useEffect(() => { if (!open || q.trim().length < 2) { setHits([]); return; } const ctl = new AbortController(); const t = setTimeout(async () => { setLoading(true); try { const res = await fetch( `/api/search/hybrid?q=${encodeURIComponent(q)}&lang=pt&top_k=10`, { signal: ctl.signal }, ); if (!res.ok) { setHits([]); return; } const data = (await res.json()) as { hits?: Hit[] }; setHits(data.hits ?? []); setSel(0); } catch { // aborted or error — keep last hits } finally { setLoading(false); } }, 250); return () => { ctl.abort(); clearTimeout(t); }; }, [q, open]); const go = useCallback( (h: Hit) => { router.push(h.href); setOpen(false); }, [router], ); function onKeyDown(e: React.KeyboardEvent) { if (e.key === "ArrowDown") { e.preventDefault(); setSel((s) => Math.min(hits.length - 1, s + 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setSel((s) => Math.max(0, s - 1)); } else if (e.key === "Enter") { e.preventDefault(); if (hits[sel]) go(hits[sel]); } } if (!open) return null; return (
setOpen(false)} >
e.stopPropagation()} >
⌘K setQ(e.target.value)} onKeyDown={onKeyDown} placeholder="busca semântica no corpus inteiro…" className="flex-1 bg-transparent text-[#c8d4e6] outline-none font-mono text-sm placeholder:text-[#5a6678]" /> {loading && }
    {hits.length === 0 && q.trim().length >= 2 && !loading && (
  • sem resultados
  • )} {hits.map((h, i) => (
  • setSel(i)} onClick={() => go(h)} className={`px-3 py-2 cursor-pointer border-l-2 ${ sel === i ? "border-[#00ff9c] bg-[rgba(0,255,156,0.06)]" : "border-transparent hover:bg-[rgba(127,219,255,0.04)]" }`} >
    {h.chunk_id} p{h.page} {h.type} {h.classification && {h.classification}} {h.score.toFixed(2)}
    {h.snippet}
    {h.doc_id}
  • ))}
↑↓ navegar · ↵ abrir · esc fechar {hits.length > 0 ? `${hits.length} hits` : "hybrid BM25+dense+rerank"}
); }