web: separate search route
This commit is contained in:
320
web/src/App.tsx
320
web/src/App.tsx
@@ -1,32 +1,31 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { Globe2, LogOut, MessageSquare, Plus, Search, SendHorizontal, ShieldCheck } from "lucide-preact";
|
||||
import { Globe2, LogOut, MessageSquare, Plus, Search, SendHorizontal } from "lucide-preact";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { AuthScreen } from "@/components/auth/auth-screen";
|
||||
import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
|
||||
import { SearchResultsPanel } from "@/components/search/search-results-panel";
|
||||
import {
|
||||
createChat,
|
||||
createSearch,
|
||||
getChat,
|
||||
getConfiguredToken,
|
||||
getSearch,
|
||||
listChats,
|
||||
listSearches,
|
||||
runCompletion,
|
||||
runSearch,
|
||||
setAuthToken,
|
||||
verifySession,
|
||||
type ChatDetail,
|
||||
type ChatSummary,
|
||||
type CompletionRequestMessage,
|
||||
type SearchDetail,
|
||||
type SearchResultItem,
|
||||
type SearchSummary,
|
||||
} from "@/lib/api";
|
||||
import { useSessionAuth } from "@/hooks/use-session-auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Provider = "openai" | "anthropic" | "xai";
|
||||
type AuthMode = "open" | "token";
|
||||
type SidebarSelection = { kind: "chat" | "search"; id: string };
|
||||
type DraftSelectionKind = "chat" | "search";
|
||||
type SidebarItem = SidebarSelection & {
|
||||
@@ -40,7 +39,6 @@ const PROVIDER_DEFAULT_MODELS: Record<Provider, string> = {
|
||||
anthropic: "claude-3-5-sonnet-latest",
|
||||
xai: "grok-3-mini",
|
||||
};
|
||||
const TOKEN_STORAGE_KEY = "sybil_admin_token";
|
||||
|
||||
function getChatTitle(chat: Pick<ChatSummary, "title">, messages?: ChatDetail["messages"]) {
|
||||
if (chat.title?.trim()) return chat.title.trim();
|
||||
@@ -85,48 +83,19 @@ function formatDate(value: string) {
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function readStoredToken() {
|
||||
return localStorage.getItem(TOKEN_STORAGE_KEY)?.trim() || null;
|
||||
}
|
||||
|
||||
function persistToken(token: string | null) {
|
||||
if (token) {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
||||
return;
|
||||
}
|
||||
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
function normalizeAuthError(message: string) {
|
||||
if (message.includes("missing bearer token") || message.includes("invalid bearer token")) {
|
||||
return "Authentication failed. Enter the ADMIN_TOKEN configured in server/.env.";
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const initialToken = readStoredToken() ?? getConfiguredToken() ?? "";
|
||||
|
||||
const [authTokenInput, setAuthTokenInput] = useState(initialToken);
|
||||
const [isCheckingSession, setIsCheckingSession] = useState(true);
|
||||
const [isSigningIn, setIsSigningIn] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [authMode, setAuthMode] = useState<AuthMode | null>(null);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
const {
|
||||
authTokenInput,
|
||||
setAuthTokenInput,
|
||||
isCheckingSession,
|
||||
isSigningIn,
|
||||
isAuthenticated,
|
||||
authMode,
|
||||
authError,
|
||||
handleAuthFailure: baseHandleAuthFailure,
|
||||
handleSignIn,
|
||||
logout,
|
||||
} = useSessionAuth();
|
||||
|
||||
const [chats, setChats] = useState<ChatSummary[]>([]);
|
||||
const [searches, setSearches] = useState<SearchSummary[]>([]);
|
||||
@@ -145,27 +114,20 @@ export default function App() {
|
||||
|
||||
const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]);
|
||||
|
||||
const completeSessionCheck = async (tokenCandidate: string | null) => {
|
||||
setAuthToken(tokenCandidate);
|
||||
const session = await verifySession();
|
||||
setIsAuthenticated(true);
|
||||
setAuthMode(session.mode);
|
||||
setAuthError(null);
|
||||
persistToken(tokenCandidate);
|
||||
};
|
||||
|
||||
const handleAuthFailure = (message: string) => {
|
||||
setIsAuthenticated(false);
|
||||
setAuthMode(null);
|
||||
setAuthError(normalizeAuthError(message));
|
||||
setAuthToken(null);
|
||||
persistToken(null);
|
||||
const resetWorkspaceState = () => {
|
||||
setChats([]);
|
||||
setSearches([]);
|
||||
setSelectedItem(null);
|
||||
setSelectedChat(null);
|
||||
setSelectedSearch(null);
|
||||
setDraftKind(null);
|
||||
setComposer("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleAuthFailure = (message: string) => {
|
||||
baseHandleAuthFailure(message);
|
||||
resetWorkspaceState();
|
||||
};
|
||||
|
||||
const refreshCollections = async (preferredSelection?: SidebarSelection) => {
|
||||
@@ -239,20 +201,6 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const token = readStoredToken() ?? getConfiguredToken();
|
||||
void (async () => {
|
||||
try {
|
||||
await completeSessionCheck(token);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
handleAuthFailure(message);
|
||||
} finally {
|
||||
setIsCheckingSession(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
void refreshCollections();
|
||||
@@ -311,7 +259,7 @@ export default function App() {
|
||||
}, [draftKind, selectedChat, selectedChatSummary, selectedItem, selectedSearch, selectedSearchSummary]);
|
||||
|
||||
const isSearchMode = draftKind ? draftKind === "search" : selectedItem?.kind === "search";
|
||||
const isSearchRunning = isSending && selectedItem?.kind === "search";
|
||||
const isSearchRunning = isSending && isSearchMode;
|
||||
|
||||
const handleCreateChat = () => {
|
||||
setError(null);
|
||||
@@ -497,36 +445,9 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignIn = async (tokenCandidate: string | null) => {
|
||||
setIsSigningIn(true);
|
||||
setAuthError(null);
|
||||
try {
|
||||
await completeSessionCheck(tokenCandidate);
|
||||
await refreshCollections();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setAuthError(normalizeAuthError(message));
|
||||
setIsAuthenticated(false);
|
||||
setAuthMode(null);
|
||||
} finally {
|
||||
setIsSigningIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setAuthToken(null);
|
||||
persistToken(null);
|
||||
setIsAuthenticated(false);
|
||||
setAuthMode(null);
|
||||
setAuthError(null);
|
||||
setChats([]);
|
||||
setSearches([]);
|
||||
setSelectedItem(null);
|
||||
setSelectedChat(null);
|
||||
setSelectedSearch(null);
|
||||
setDraftKind(null);
|
||||
setComposer("");
|
||||
setError(null);
|
||||
logout();
|
||||
resetWorkspaceState();
|
||||
};
|
||||
|
||||
if (isCheckingSession) {
|
||||
@@ -539,51 +460,13 @@ export default function App() {
|
||||
|
||||
if (!isAuthenticated) {
|
||||
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 handleSignIn(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 handleSignIn(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>
|
||||
<AuthScreen
|
||||
authTokenInput={authTokenInput}
|
||||
setAuthTokenInput={setAuthTokenInput}
|
||||
isSigningIn={isSigningIn}
|
||||
authError={authError}
|
||||
onSignIn={handleSignIn}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -603,9 +486,7 @@ export default function App() {
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{isLoadingCollections && sidebarItems.length === 0 ? (
|
||||
<p className="px-2 py-3 text-sm text-muted-foreground">Loading conversations...</p>
|
||||
) : null}
|
||||
{isLoadingCollections && sidebarItems.length === 0 ? <p className="px-2 py-3 text-sm text-muted-foreground">Loading conversations...</p> : null}
|
||||
{!isLoadingCollections && sidebarItems.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-5 text-center text-sm text-muted-foreground">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
@@ -664,12 +545,7 @@ export default function App() {
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="xai">xAI</option>
|
||||
</select>
|
||||
<Input
|
||||
value={model}
|
||||
onInput={(event) => setModel(event.currentTarget.value)}
|
||||
placeholder="Model"
|
||||
disabled={isSending}
|
||||
/>
|
||||
<Input value={model} onInput={(event) => setModel(event.currentTarget.value)} placeholder="Model" disabled={isSending} />
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-9 items-center rounded-md border border-input px-3 text-sm text-muted-foreground">
|
||||
@@ -686,121 +562,9 @@ export default function App() {
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-3 py-6 md:px-10">
|
||||
{!isSearchMode ? (
|
||||
<>
|
||||
{isLoadingSelection && messages.length === 0 ? <p className="text-sm text-muted-foreground">Loading messages...</p> : null}
|
||||
{!isLoadingSelection && 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>
|
||||
</>
|
||||
<ChatMessagesPanel messages={messages} isLoading={isLoadingSelection} isSending={isSending} />
|
||||
) : (
|
||||
<div className="mx-auto w-full max-w-4xl">
|
||||
{selectedSearch?.query ? (
|
||||
<div className="mb-5">
|
||||
<p className="text-sm text-muted-foreground">Results for</p>
|
||||
<h2 className="mt-1 text-xl font-semibold">{selectedSearch.query}</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{selectedSearch.results.length} result{selectedSearch.results.length === 1 ? "" : "s"}
|
||||
{selectedSearch.latencyMs ? ` • ${selectedSearch.latencyMs} ms` : ""}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(isSearchRunning || !!selectedSearch?.answerText || !!selectedSearch?.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>
|
||||
{isSearchRunning && !selectedSearch?.answerText ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">Generating answer...</p>
|
||||
) : null}
|
||||
{selectedSearch?.answerText ? (
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-slate-100">{selectedSearch.answerText}</p>
|
||||
) : null}
|
||||
{selectedSearch?.answerError ? <p className="mt-2 text-sm text-red-500">{selectedSearch.answerError}</p> : null}
|
||||
{!!selectedSearch?.answerCitations?.length && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{selectedSearch.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>
|
||||
)}
|
||||
|
||||
{(isLoadingSelection || isSearchRunning) && !selectedSearch?.results.length ? (
|
||||
<p className="text-sm text-muted-foreground">{isSearchRunning ? "Searching Exa..." : "Loading search..."}</p>
|
||||
) : null}
|
||||
|
||||
{!isLoadingSelection && !selectedSearch?.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}
|
||||
|
||||
{!isLoadingSelection && !isSearchRunning && !!selectedSearch?.query && selectedSearch.results.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No results found.</p>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-6">
|
||||
{selectedSearch?.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>
|
||||
|
||||
{selectedSearch?.error ? <p className="mt-4 text-sm text-red-600">{selectedSearch.error}</p> : null}
|
||||
</div>
|
||||
<SearchResultsPanel search={selectedSearch} isLoading={isLoadingSelection} isRunning={isSearchRunning} />
|
||||
)}
|
||||
<div ref={transcriptEndRef} />
|
||||
</div>
|
||||
@@ -822,11 +586,7 @@ export default function App() {
|
||||
disabled={isSending}
|
||||
/>
|
||||
<div className="flex items-center justify-between px-2 pb-1">
|
||||
{error ? (
|
||||
<p className="text-xs text-red-600">{error}</p>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{isSearchMode ? "Enter to search" : "Enter to send"}</span>
|
||||
)}
|
||||
{error ? <p className="text-xs text-red-600">{error}</p> : <span className="text-xs text-muted-foreground">{isSearchMode ? "Enter to search" : "Enter to send"}</span>}
|
||||
<Button onClick={() => void handleSend()} size="icon" disabled={isSending || !composer.trim()}>
|
||||
{isSearchMode ? <Search className="h-4 w-4" /> : <SendHorizontal className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user