web: separate search route
This commit is contained in:
55
web/src/components/auth/auth-screen.tsx
Normal file
55
web/src/components/auth/auth-screen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
web/src/components/chat/chat-messages-panel.tsx
Normal file
40
web/src/components/chat/chat-messages-panel.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
105
web/src/components/search/search-results-panel.tsx
Normal file
105
web/src/components/search/search-results-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user