169 lines
5.5 KiB
TypeScript
169 lines
5.5 KiB
TypeScript
|
|
/**
|
||
|
|
* CommandPalette — global Cmd+K (or Ctrl+K) overlay with hybrid_search.
|
||
|
|
*
|
||
|
|
* Renders directly in <RootLayout>. Type a query, debounced 250ms, hit /api/search/hybrid,
|
||
|
|
* arrow-key navigation, Enter opens the chunk anchor on /d/<doc>#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<Hit[]>([]);
|
||
|
|
const [sel, setSel] = useState(0);
|
||
|
|
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) {
|
||
|
|
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 (
|
||
|
|
<div
|
||
|
|
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-start justify-center pt-24"
|
||
|
|
onClick={() => setOpen(false)}
|
||
|
|
>
|
||
|
|
<div
|
||
|
|
className="w-[min(640px,90vw)] bg-[#040810] border border-[#00ff9c] rounded-lg shadow-2xl overflow-hidden"
|
||
|
|
onClick={(e) => e.stopPropagation()}
|
||
|
|
>
|
||
|
|
<div className="flex items-center gap-2 px-4 py-3 border-b border-[rgba(0,255,156,0.20)]">
|
||
|
|
<span className="text-[#7fdbff] font-mono text-xs">⌘K</span>
|
||
|
|
<input
|
||
|
|
ref={inputRef}
|
||
|
|
value={q}
|
||
|
|
onChange={(e) => 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 && <span className="text-[#5a6678] font-mono text-xs animate-pulse">…</span>}
|
||
|
|
</div>
|
||
|
|
<ul className="max-h-96 overflow-y-auto">
|
||
|
|
{hits.length === 0 && q.trim().length >= 2 && !loading && (
|
||
|
|
<li className="px-4 py-6 text-center text-[#5a6678] text-xs font-mono">sem resultados</li>
|
||
|
|
)}
|
||
|
|
{hits.map((h, i) => (
|
||
|
|
<li
|
||
|
|
key={`${h.doc_id}-${h.chunk_id}`}
|
||
|
|
onMouseEnter={() => 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)]"
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
<div className="flex items-center gap-2 text-[10px] font-mono mb-0.5">
|
||
|
|
<span className="text-[#00ff9c]">{h.chunk_id}</span>
|
||
|
|
<span className="text-[#5a6678]">p{h.page}</span>
|
||
|
|
<span className="text-[#5a6678]">{h.type}</span>
|
||
|
|
{h.classification && <span className="text-[#ff6b6b]">{h.classification}</span>}
|
||
|
|
<span className="text-[#8896aa] ml-auto">{h.score.toFixed(2)}</span>
|
||
|
|
</div>
|
||
|
|
<div className="text-[#c8d4e6] text-xs line-clamp-2">{h.snippet}</div>
|
||
|
|
<div className="text-[10px] font-mono text-[#5a6678] truncate mt-0.5">{h.doc_id}</div>
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
<div className="px-3 py-2 border-t border-[rgba(0,255,156,0.20)] text-[10px] font-mono text-[#5a6678] flex justify-between">
|
||
|
|
<span>↑↓ navegar · ↵ abrir · esc fechar</span>
|
||
|
|
<span>{hits.length > 0 ? `${hits.length} hits` : "hybrid BM25+dense+rerank"}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|