web: separate search route

This commit is contained in:
2026-02-14 00:22:19 -08:00
parent 6f5787f923
commit acca7be7f0
9 changed files with 529 additions and 282 deletions

View File

@@ -0,0 +1,55 @@
import { ShieldCheck } from "lucide-preact";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type Props = {
authTokenInput: string;
setAuthTokenInput: (value: string) => void;
isSigningIn: boolean;
authError: string | null;
onSignIn: (tokenCandidate: string | null) => Promise<void>;
};
export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, authError, onSignIn }: Props) {
return (
<div className="flex h-full items-center justify-center bg-[radial-gradient(circle_at_top,#171f33_0%,#111827_45%,#0b1020_100%)] p-4">
<div className="w-full max-w-md rounded-2xl border bg-[#0f172a] p-6 shadow-xl shadow-black/40">
<div className="mb-5 flex items-start gap-3">
<div className="rounded-lg bg-slate-200 p-2 text-slate-900">
<ShieldCheck className="h-4 w-4" />
</div>
<div>
<h1 className="text-lg font-semibold">Sign in to Sybil</h1>
<p className="mt-1 text-sm text-muted-foreground">Use your backend admin token.</p>
</div>
</div>
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
void onSignIn(authTokenInput.trim() || null);
}}
>
<Input
type="password"
autoComplete="off"
placeholder="ADMIN_TOKEN"
value={authTokenInput}
onInput={(event) => setAuthTokenInput(event.currentTarget.value)}
disabled={isSigningIn}
/>
<Button className="w-full" type="submit" disabled={isSigningIn}>
{isSigningIn ? "Signing in..." : "Sign in"}
</Button>
<Button className="w-full" type="button" variant="secondary" disabled={isSigningIn} onClick={() => void onSignIn(null)}>
Continue without token
</Button>
</form>
{authError ? <p className="mt-3 text-sm text-red-600">{authError}</p> : null}
<p className="mt-3 text-xs text-muted-foreground">If `ADMIN_TOKEN` is set in `/server/.env`, token login is required.</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { cn } from "@/lib/utils";
import type { Message } from "@/lib/api";
type Props = {
messages: Message[];
isLoading: boolean;
isSending: boolean;
};
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
return (
<>
{isLoading && messages.length === 0 ? <p className="text-sm text-muted-foreground">Loading messages...</p> : null}
{!isLoading && messages.length === 0 ? (
<div className="mx-auto flex max-w-3xl flex-col items-center gap-3 rounded-xl border border-dashed p-8 text-center">
<h2 className="text-lg font-semibold">How can I help today?</h2>
<p className="text-sm text-muted-foreground">Ask a question to begin this conversation.</p>
</div>
) : null}
<div className="mx-auto max-w-3xl space-y-6">
{messages.map((message) => {
const isUser = message.role === "user";
const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending;
return (
<div key={message.id} className={cn("flex", isUser ? "justify-end" : "justify-start")}>
<div
className={cn(
"max-w-[85%] whitespace-pre-wrap rounded-2xl px-4 py-3 text-sm leading-6",
isUser ? "bg-slate-200 text-slate-900" : "bg-slate-800 text-slate-100"
)}
>
{isPendingAssistant ? "Thinking..." : message.content}
</div>
</div>
);
})}
</div>
</>
);
}

View File

@@ -0,0 +1,105 @@
import { Search } from "lucide-preact";
import type { SearchDetail, SearchResultItem } from "@/lib/api";
function summarizeResult(result: SearchResultItem) {
const highlights = Array.isArray(result.highlights) ? result.highlights.filter(Boolean) : [];
if (highlights.length) return highlights.join(" ").slice(0, 420);
return (result.text ?? "").slice(0, 420);
}
function formatHost(url: string) {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return url;
}
}
type Props = {
search: SearchDetail | null;
isLoading: boolean;
isRunning: boolean;
showPrompt?: boolean;
className?: string;
};
export function SearchResultsPanel({ search, isLoading, isRunning, showPrompt = true, className }: Props) {
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>
{isRunning && !search?.answerText ? <p className="mt-2 text-sm text-muted-foreground">Generating answer...</p> : null}
{search?.answerText ? <p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-slate-100">{search.answerText}</p> : null}
{search?.answerError ? <p className="mt-2 text-sm text-red-500">{search.answerError}</p> : null}
{!!search?.answerCitations?.length && (
<div className="mt-3 flex flex-wrap gap-2">
{search.answerCitations.slice(0, 6).map((citation, index) => {
const href = citation.url || citation.id || "";
if (!href) return null;
return (
<a
key={`${href}-${index}`}
href={href}
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"
>
{citation.title?.trim() || formatHost(href)}
</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>
)}
{summary ? <p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-slate-200">{summary}</p> : null}
</article>
);
})}
</div>
{search?.error ? <p className="mt-4 text-sm text-red-600">{search.error}</p> : null}
</div>
);
}