2026-02-14 02:02:18 -08:00
|
|
|
import { useEffect, useRef, useState } from "preact/hooks";
|
2026-02-14 00:22:19 -08:00
|
|
|
import { Search } from "lucide-preact";
|
|
|
|
|
import type { SearchDetail, SearchResultItem } from "@/lib/api";
|
2026-02-14 00:33:15 -08:00
|
|
|
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
2026-02-14 02:02:18 -08:00
|
|
|
import { cn } from "@/lib/utils";
|
2026-02-14 00:33:15 -08:00
|
|
|
|
|
|
|
|
function cleanResultText(input: string) {
|
|
|
|
|
return input
|
|
|
|
|
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, "$1")
|
|
|
|
|
.replace(/\[\s*\]/g, " ")
|
|
|
|
|
.replace(/(^|\s)#{1,6}\s*/g, "$1")
|
|
|
|
|
.replace(/^\s*[-*+]\s+/gm, "")
|
|
|
|
|
.replace(/(\*\*|__|\*|_|`{1,3}|~~)/g, "")
|
|
|
|
|
.replace(/\r?\n+/g, " ")
|
|
|
|
|
.replace(/\s{2,}/g, " ")
|
|
|
|
|
.trim();
|
|
|
|
|
}
|
2026-02-14 00:22:19 -08:00
|
|
|
|
|
|
|
|
function summarizeResult(result: SearchResultItem) {
|
|
|
|
|
const highlights = Array.isArray(result.highlights) ? result.highlights.filter(Boolean) : [];
|
2026-02-14 00:33:15 -08:00
|
|
|
const raw = highlights.length ? highlights.join(" ") : result.text ?? "";
|
|
|
|
|
const cleaned = cleanResultText(raw);
|
|
|
|
|
if (cleaned.length <= 680) return cleaned;
|
|
|
|
|
return `${cleaned.slice(0, 679).trimEnd()}…`;
|
2026-02-14 00:22:19 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatHost(url: string) {
|
|
|
|
|
try {
|
|
|
|
|
return new URL(url).hostname.replace(/^www\./, "");
|
|
|
|
|
} catch {
|
|
|
|
|
return url;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 00:33:15 -08:00
|
|
|
function normalizeHref(href: string) {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = new URL(href);
|
|
|
|
|
parsed.hash = "";
|
|
|
|
|
const normalized = parsed.toString();
|
|
|
|
|
return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
|
|
|
|
|
} catch {
|
|
|
|
|
return href.trim().replace(/\/$/, "");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 00:22:19 -08:00
|
|
|
type Props = {
|
|
|
|
|
search: SearchDetail | null;
|
|
|
|
|
isLoading: boolean;
|
|
|
|
|
isRunning: boolean;
|
|
|
|
|
showPrompt?: boolean;
|
|
|
|
|
className?: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function SearchResultsPanel({ search, isLoading, isRunning, showPrompt = true, className }: Props) {
|
2026-02-14 02:02:18 -08:00
|
|
|
const ANSWER_COLLAPSED_HEIGHT_CLASS = "h-[3rem]";
|
|
|
|
|
const [isAnswerExpanded, setIsAnswerExpanded] = useState(false);
|
|
|
|
|
const [canExpandAnswer, setCanExpandAnswer] = useState(false);
|
|
|
|
|
const answerBodyRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setIsAnswerExpanded(false);
|
|
|
|
|
setCanExpandAnswer(false);
|
|
|
|
|
}, [search?.id, search?.answerText]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const el = answerBodyRef.current;
|
|
|
|
|
if (!el || !search?.answerText) {
|
|
|
|
|
setCanExpandAnswer(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (isAnswerExpanded) {
|
|
|
|
|
setCanExpandAnswer(true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setCanExpandAnswer(el.scrollHeight - el.clientHeight > 1);
|
|
|
|
|
}, [search?.answerText, isAnswerExpanded]);
|
|
|
|
|
|
2026-02-14 00:33:15 -08:00
|
|
|
const citationEntries = (search?.answerCitations ?? [])
|
|
|
|
|
.map((citation, index) => {
|
|
|
|
|
const href = citation.url || citation.id || "";
|
|
|
|
|
if (!href) return null;
|
|
|
|
|
return {
|
|
|
|
|
href,
|
|
|
|
|
normalizedHref: normalizeHref(href),
|
|
|
|
|
index: index + 1,
|
|
|
|
|
label: citation.title?.trim() || formatHost(href),
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
.filter((entry): entry is { href: string; normalizedHref: string; index: number; label: string } => !!entry);
|
|
|
|
|
|
|
|
|
|
const resolveCitationIndex = (href: string) => {
|
|
|
|
|
const normalized = normalizeHref(href);
|
|
|
|
|
return citationEntries.find((entry) => entry.normalizedHref === normalized)?.index;
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-14 02:02:18 -08:00
|
|
|
const hasAnswerText = !!search?.answerText;
|
|
|
|
|
const isAnswerLoading = isRunning && !hasAnswerText;
|
|
|
|
|
const hasCitations = citationEntries.length > 0;
|
|
|
|
|
const isExpandable = hasAnswerText && (canExpandAnswer || hasCitations);
|
|
|
|
|
|
2026-02-14 00:22:19 -08:00
|
|
|
return (
|
|
|
|
|
<div className={className ?? "mx-auto w-full max-w-4xl"}>
|
|
|
|
|
{search?.query ? (
|
|
|
|
|
<div className="mb-5">
|
|
|
|
|
<p className="text-sm text-muted-foreground">Results for</p>
|
|
|
|
|
<h2 className="mt-1 text-xl font-semibold">{search.query}</h2>
|
|
|
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
|
|
|
{search.results.length} result{search.results.length === 1 ? "" : "s"}
|
|
|
|
|
{search.latencyMs ? ` • ${search.latencyMs} ms` : ""}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{(isRunning || !!search?.answerText || !!search?.answerError) && (
|
|
|
|
|
<section className="mb-6 rounded-xl border border-slate-600/60 bg-[#121a2e] p-4">
|
|
|
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-sky-300/90">Answer</p>
|
2026-02-14 02:02:18 -08:00
|
|
|
{(isAnswerLoading || hasAnswerText) ? (
|
|
|
|
|
<div className="mt-2">
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<div
|
|
|
|
|
ref={answerBodyRef}
|
|
|
|
|
className={cn(
|
|
|
|
|
"overflow-hidden",
|
|
|
|
|
!isAnswerExpanded && ANSWER_COLLAPSED_HEIGHT_CLASS
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{isAnswerLoading ? (
|
|
|
|
|
<div className="text-sm text-muted-foreground">Generating answer...</div>
|
|
|
|
|
) : (
|
|
|
|
|
<MarkdownContent
|
|
|
|
|
markdown={search?.answerText ?? ""}
|
|
|
|
|
mode="citationTokens"
|
|
|
|
|
resolveCitationIndex={resolveCitationIndex}
|
|
|
|
|
className="text-sm leading-6 text-slate-100"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{!isAnswerExpanded && (isExpandable || isAnswerLoading) ? (
|
|
|
|
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-7 bg-gradient-to-t from-[#121a2e] to-transparent" />
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-2 h-5">
|
|
|
|
|
{isExpandable ? (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="inline-flex items-center gap-1 text-xs font-medium text-sky-200 hover:text-sky-100"
|
|
|
|
|
onClick={() => setIsAnswerExpanded((current) => !current)}
|
|
|
|
|
>
|
|
|
|
|
<span className={cn("inline-block text-[11px] transition-transform", isAnswerExpanded && "rotate-180")}>▾</span>
|
|
|
|
|
{isAnswerExpanded ? "Show less" : "Show more"}
|
|
|
|
|
</button>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-02-14 00:33:15 -08:00
|
|
|
) : null}
|
2026-02-14 00:22:19 -08:00
|
|
|
{search?.answerError ? <p className="mt-2 text-sm text-red-500">{search.answerError}</p> : null}
|
2026-02-14 02:02:18 -08:00
|
|
|
{isAnswerExpanded && !!citationEntries.length && (
|
2026-02-14 00:22:19 -08:00
|
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
2026-02-14 00:33:15 -08:00
|
|
|
{citationEntries.slice(0, 8).map((citation) => {
|
2026-02-14 00:22:19 -08:00
|
|
|
return (
|
|
|
|
|
<a
|
2026-02-14 00:33:15 -08:00
|
|
|
key={`${citation.href}-${citation.index}`}
|
|
|
|
|
href={citation.href}
|
2026-02-14 00:22:19 -08:00
|
|
|
target="_blank"
|
|
|
|
|
rel="noreferrer"
|
|
|
|
|
className="rounded-md border border-slate-500/60 px-2 py-1 text-xs text-sky-200 hover:bg-slate-700/40"
|
|
|
|
|
>
|
2026-02-14 00:33:15 -08:00
|
|
|
<span className="mr-1 rounded bg-slate-700/80 px-1 py-0.5 text-[10px] text-slate-100">{citation.index}</span>
|
|
|
|
|
{citation.label}
|
2026-02-14 00:22:19 -08:00
|
|
|
</a>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{(isLoading || isRunning) && !search?.results.length ? (
|
|
|
|
|
<p className="text-sm text-muted-foreground">{isRunning ? "Searching Exa..." : "Loading search..."}</p>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{showPrompt && !isLoading && !search?.query ? (
|
|
|
|
|
<div className="flex flex-col items-center justify-center gap-2 rounded-xl border border-dashed p-8 text-center">
|
|
|
|
|
<Search className="h-6 w-6 text-muted-foreground" />
|
|
|
|
|
<h2 className="text-lg font-semibold">Search the web</h2>
|
|
|
|
|
<p className="text-sm text-muted-foreground">Use the composer below to run a new Exa search.</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{!isLoading && !isRunning && !!search?.query && search.results.length === 0 ? (
|
|
|
|
|
<p className="text-sm text-muted-foreground">No results found.</p>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
{search?.results.map((result) => {
|
|
|
|
|
const summary = summarizeResult(result);
|
|
|
|
|
return (
|
|
|
|
|
<article key={result.id} className="rounded-lg border border-border bg-[#0d1322] px-4 py-4 shadow-sm">
|
|
|
|
|
<p className="text-xs text-emerald-300/85">{formatHost(result.url)}</p>
|
|
|
|
|
<a href={result.url} target="_blank" rel="noreferrer" className="mt-1 block text-lg font-medium text-sky-300 hover:underline">
|
|
|
|
|
{result.title || result.url}
|
|
|
|
|
</a>
|
|
|
|
|
{(result.publishedDate || result.author) && (
|
|
|
|
|
<p className="mt-1 text-xs text-muted-foreground">{[result.publishedDate, result.author].filter(Boolean).join(" • ")}</p>
|
|
|
|
|
)}
|
2026-02-14 00:33:15 -08:00
|
|
|
{summary ? <p className="mt-2 text-sm leading-6 text-slate-200">{summary}</p> : null}
|
2026-02-14 00:22:19 -08:00
|
|
|
</article>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{search?.error ? <p className="mt-4 text-sm text-red-600">{search.error}</p> : null}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|