web: separate search route
This commit is contained in:
@@ -40,3 +40,8 @@ Default dev URL: `http://localhost:5173`
|
|||||||
- Composer adapts to the active item:
|
- Composer adapts to the active item:
|
||||||
- Chat sends `POST /v1/chat-completions`.
|
- Chat sends `POST /v1/chat-completions`.
|
||||||
- Search sends `POST /v1/searches/:searchId/run`.
|
- Search sends `POST /v1/searches/:searchId/run`.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
- `/`: full Sybil app (sidebar + chat/search workspace)
|
||||||
|
- `/search?q=bitcoin+price`: standalone search page that renders only the search query box and results panel
|
||||||
|
|||||||
318
web/src/App.tsx
318
web/src/App.tsx
@@ -1,32 +1,31 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Separator } from "@/components/ui/separator";
|
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 {
|
import {
|
||||||
createChat,
|
createChat,
|
||||||
createSearch,
|
createSearch,
|
||||||
getChat,
|
getChat,
|
||||||
getConfiguredToken,
|
|
||||||
getSearch,
|
getSearch,
|
||||||
listChats,
|
listChats,
|
||||||
listSearches,
|
listSearches,
|
||||||
runCompletion,
|
runCompletion,
|
||||||
runSearch,
|
runSearch,
|
||||||
setAuthToken,
|
|
||||||
verifySession,
|
|
||||||
type ChatDetail,
|
type ChatDetail,
|
||||||
type ChatSummary,
|
type ChatSummary,
|
||||||
type CompletionRequestMessage,
|
type CompletionRequestMessage,
|
||||||
type SearchDetail,
|
type SearchDetail,
|
||||||
type SearchResultItem,
|
|
||||||
type SearchSummary,
|
type SearchSummary,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import { useSessionAuth } from "@/hooks/use-session-auth";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type Provider = "openai" | "anthropic" | "xai";
|
type Provider = "openai" | "anthropic" | "xai";
|
||||||
type AuthMode = "open" | "token";
|
|
||||||
type SidebarSelection = { kind: "chat" | "search"; id: string };
|
type SidebarSelection = { kind: "chat" | "search"; id: string };
|
||||||
type DraftSelectionKind = "chat" | "search";
|
type DraftSelectionKind = "chat" | "search";
|
||||||
type SidebarItem = SidebarSelection & {
|
type SidebarItem = SidebarSelection & {
|
||||||
@@ -40,7 +39,6 @@ const PROVIDER_DEFAULT_MODELS: Record<Provider, string> = {
|
|||||||
anthropic: "claude-3-5-sonnet-latest",
|
anthropic: "claude-3-5-sonnet-latest",
|
||||||
xai: "grok-3-mini",
|
xai: "grok-3-mini",
|
||||||
};
|
};
|
||||||
const TOKEN_STORAGE_KEY = "sybil_admin_token";
|
|
||||||
|
|
||||||
function getChatTitle(chat: Pick<ChatSummary, "title">, messages?: ChatDetail["messages"]) {
|
function getChatTitle(chat: Pick<ChatSummary, "title">, messages?: ChatDetail["messages"]) {
|
||||||
if (chat.title?.trim()) return chat.title.trim();
|
if (chat.title?.trim()) return chat.title.trim();
|
||||||
@@ -85,48 +83,19 @@ function formatDate(value: string) {
|
|||||||
}).format(new Date(value));
|
}).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() {
|
export default function App() {
|
||||||
const initialToken = readStoredToken() ?? getConfiguredToken() ?? "";
|
const {
|
||||||
|
authTokenInput,
|
||||||
const [authTokenInput, setAuthTokenInput] = useState(initialToken);
|
setAuthTokenInput,
|
||||||
const [isCheckingSession, setIsCheckingSession] = useState(true);
|
isCheckingSession,
|
||||||
const [isSigningIn, setIsSigningIn] = useState(false);
|
isSigningIn,
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
isAuthenticated,
|
||||||
const [authMode, setAuthMode] = useState<AuthMode | null>(null);
|
authMode,
|
||||||
const [authError, setAuthError] = useState<string | null>(null);
|
authError,
|
||||||
|
handleAuthFailure: baseHandleAuthFailure,
|
||||||
|
handleSignIn,
|
||||||
|
logout,
|
||||||
|
} = useSessionAuth();
|
||||||
|
|
||||||
const [chats, setChats] = useState<ChatSummary[]>([]);
|
const [chats, setChats] = useState<ChatSummary[]>([]);
|
||||||
const [searches, setSearches] = useState<SearchSummary[]>([]);
|
const [searches, setSearches] = useState<SearchSummary[]>([]);
|
||||||
@@ -145,27 +114,20 @@ export default function App() {
|
|||||||
|
|
||||||
const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]);
|
const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]);
|
||||||
|
|
||||||
const completeSessionCheck = async (tokenCandidate: string | null) => {
|
const resetWorkspaceState = () => {
|
||||||
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);
|
|
||||||
setChats([]);
|
setChats([]);
|
||||||
setSearches([]);
|
setSearches([]);
|
||||||
setSelectedItem(null);
|
setSelectedItem(null);
|
||||||
setSelectedChat(null);
|
setSelectedChat(null);
|
||||||
setSelectedSearch(null);
|
setSelectedSearch(null);
|
||||||
setDraftKind(null);
|
setDraftKind(null);
|
||||||
|
setComposer("");
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuthFailure = (message: string) => {
|
||||||
|
baseHandleAuthFailure(message);
|
||||||
|
resetWorkspaceState();
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshCollections = async (preferredSelection?: SidebarSelection) => {
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) return;
|
if (!isAuthenticated) return;
|
||||||
void refreshCollections();
|
void refreshCollections();
|
||||||
@@ -311,7 +259,7 @@ export default function App() {
|
|||||||
}, [draftKind, selectedChat, selectedChatSummary, selectedItem, selectedSearch, selectedSearchSummary]);
|
}, [draftKind, selectedChat, selectedChatSummary, selectedItem, selectedSearch, selectedSearchSummary]);
|
||||||
|
|
||||||
const isSearchMode = draftKind ? draftKind === "search" : selectedItem?.kind === "search";
|
const isSearchMode = draftKind ? draftKind === "search" : selectedItem?.kind === "search";
|
||||||
const isSearchRunning = isSending && selectedItem?.kind === "search";
|
const isSearchRunning = isSending && isSearchMode;
|
||||||
|
|
||||||
const handleCreateChat = () => {
|
const handleCreateChat = () => {
|
||||||
setError(null);
|
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 = () => {
|
const handleLogout = () => {
|
||||||
setAuthToken(null);
|
logout();
|
||||||
persistToken(null);
|
resetWorkspaceState();
|
||||||
setIsAuthenticated(false);
|
|
||||||
setAuthMode(null);
|
|
||||||
setAuthError(null);
|
|
||||||
setChats([]);
|
|
||||||
setSearches([]);
|
|
||||||
setSelectedItem(null);
|
|
||||||
setSelectedChat(null);
|
|
||||||
setSelectedSearch(null);
|
|
||||||
setDraftKind(null);
|
|
||||||
setComposer("");
|
|
||||||
setError(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isCheckingSession) {
|
if (isCheckingSession) {
|
||||||
@@ -539,51 +460,13 @@ export default function App() {
|
|||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center bg-[radial-gradient(circle_at_top,#171f33_0%,#111827_45%,#0b1020_100%)] p-4">
|
<AuthScreen
|
||||||
<div className="w-full max-w-md rounded-2xl border bg-[#0f172a] p-6 shadow-xl shadow-black/40">
|
authTokenInput={authTokenInput}
|
||||||
<div className="mb-5 flex items-start gap-3">
|
setAuthTokenInput={setAuthTokenInput}
|
||||||
<div className="rounded-lg bg-slate-200 p-2 text-slate-900">
|
isSigningIn={isSigningIn}
|
||||||
<ShieldCheck className="h-4 w-4" />
|
authError={authError}
|
||||||
</div>
|
onSignIn={handleSignIn}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,9 +486,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex-1 overflow-y-auto p-2">
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
{isLoadingCollections && sidebarItems.length === 0 ? (
|
{isLoadingCollections && sidebarItems.length === 0 ? <p className="px-2 py-3 text-sm text-muted-foreground">Loading conversations...</p> : null}
|
||||||
<p className="px-2 py-3 text-sm text-muted-foreground">Loading conversations...</p>
|
|
||||||
) : null}
|
|
||||||
{!isLoadingCollections && sidebarItems.length === 0 ? (
|
{!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">
|
<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" />
|
<MessageSquare className="h-5 w-5" />
|
||||||
@@ -664,12 +545,7 @@ export default function App() {
|
|||||||
<option value="anthropic">Anthropic</option>
|
<option value="anthropic">Anthropic</option>
|
||||||
<option value="xai">xAI</option>
|
<option value="xai">xAI</option>
|
||||||
</select>
|
</select>
|
||||||
<Input
|
<Input value={model} onInput={(event) => setModel(event.currentTarget.value)} placeholder="Model" disabled={isSending} />
|
||||||
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">
|
<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">
|
<div className="flex-1 overflow-y-auto px-3 py-6 md:px-10">
|
||||||
{!isSearchMode ? (
|
{!isSearchMode ? (
|
||||||
<>
|
<ChatMessagesPanel messages={messages} isLoading={isLoadingSelection} isSending={isSending} />
|
||||||
{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>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="mx-auto w-full max-w-4xl">
|
<SearchResultsPanel search={selectedSearch} isLoading={isLoadingSelection} isRunning={isSearchRunning} />
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
<div ref={transcriptEndRef} />
|
<div ref={transcriptEndRef} />
|
||||||
</div>
|
</div>
|
||||||
@@ -822,11 +586,7 @@ export default function App() {
|
|||||||
disabled={isSending}
|
disabled={isSending}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between px-2 pb-1">
|
<div className="flex items-center justify-between px-2 pb-1">
|
||||||
{error ? (
|
{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>}
|
||||||
<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()}>
|
<Button onClick={() => void handleSend()} size="icon" disabled={isSending || !composer.trim()}>
|
||||||
{isSearchMode ? <Search className="h-4 w-4" /> : <SendHorizontal className="h-4 w-4" />}
|
{isSearchMode ? <Search className="h-4 w-4" /> : <SendHorizontal className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
web/src/hooks/use-session-auth.ts
Normal file
103
web/src/hooks/use-session-auth.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
import { getConfiguredToken, setAuthToken, verifySession } from "@/lib/api";
|
||||||
|
|
||||||
|
const TOKEN_STORAGE_KEY = "sybil_admin_token";
|
||||||
|
|
||||||
|
export type AuthMode = "open" | "token";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessionAuth() {
|
||||||
|
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 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 handleSignIn = async (tokenCandidate: string | null) => {
|
||||||
|
setIsSigningIn(true);
|
||||||
|
setAuthError(null);
|
||||||
|
try {
|
||||||
|
await completeSessionCheck(tokenCandidate);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
setAuthError(normalizeAuthError(message));
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setAuthMode(null);
|
||||||
|
} finally {
|
||||||
|
setIsSigningIn(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
setAuthToken(null);
|
||||||
|
persistToken(null);
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setAuthMode(null);
|
||||||
|
setAuthError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
authTokenInput,
|
||||||
|
setAuthTokenInput,
|
||||||
|
isCheckingSession,
|
||||||
|
isSigningIn,
|
||||||
|
isAuthenticated,
|
||||||
|
authMode,
|
||||||
|
authError,
|
||||||
|
handleAuthFailure,
|
||||||
|
handleSignIn,
|
||||||
|
logout,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { render } from "preact";
|
import { render } from "preact";
|
||||||
import App from "./App";
|
import { RootRouter } from "@/root-router";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
render(<App />, document.getElementById("app")!);
|
render(<RootRouter />, document.getElementById("app")!);
|
||||||
|
|||||||
161
web/src/pages/search-route-page.tsx
Normal file
161
web/src/pages/search-route-page.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { useEffect, useRef, useState } from "preact/hooks";
|
||||||
|
import { Search } from "lucide-preact";
|
||||||
|
import { AuthScreen } from "@/components/auth/auth-screen";
|
||||||
|
import { SearchResultsPanel } from "@/components/search/search-results-panel";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { createSearch, runSearch, type SearchDetail } from "@/lib/api";
|
||||||
|
import { useSessionAuth } from "@/hooks/use-session-auth";
|
||||||
|
|
||||||
|
function readQueryFromUrl() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
return params.get("q")?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushSearchQuery(query: string) {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (query.trim()) {
|
||||||
|
params.set("q", query.trim());
|
||||||
|
} else {
|
||||||
|
params.delete("q");
|
||||||
|
}
|
||||||
|
const next = `${window.location.pathname}${params.toString() ? `?${params.toString()}` : ""}`;
|
||||||
|
window.history.pushState({}, "", next);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchRoutePage() {
|
||||||
|
const {
|
||||||
|
authTokenInput,
|
||||||
|
setAuthTokenInput,
|
||||||
|
isCheckingSession,
|
||||||
|
isSigningIn,
|
||||||
|
isAuthenticated,
|
||||||
|
authError,
|
||||||
|
handleAuthFailure,
|
||||||
|
handleSignIn,
|
||||||
|
} = useSessionAuth();
|
||||||
|
|
||||||
|
const [queryInput, setQueryInput] = useState(readQueryFromUrl());
|
||||||
|
const [routeQuery, setRouteQuery] = useState(readQueryFromUrl());
|
||||||
|
const [search, setSearch] = useState<SearchDetail | null>(null);
|
||||||
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const requestCounterRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPopState = () => {
|
||||||
|
const next = readQueryFromUrl();
|
||||||
|
setRouteQuery(next);
|
||||||
|
setQueryInput(next);
|
||||||
|
};
|
||||||
|
window.addEventListener("popstate", onPopState);
|
||||||
|
return () => window.removeEventListener("popstate", onPopState);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runQuery = async (query: string) => {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setSearch(null);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = ++requestCounterRef.current;
|
||||||
|
setError(null);
|
||||||
|
setIsRunning(true);
|
||||||
|
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
setSearch({
|
||||||
|
id: `temp-search-${requestId}`,
|
||||||
|
title: trimmed.slice(0, 80),
|
||||||
|
query: trimmed,
|
||||||
|
createdAt: nowIso,
|
||||||
|
updatedAt: nowIso,
|
||||||
|
requestId: null,
|
||||||
|
latencyMs: null,
|
||||||
|
error: null,
|
||||||
|
answerText: null,
|
||||||
|
answerRequestId: null,
|
||||||
|
answerCitations: null,
|
||||||
|
answerError: null,
|
||||||
|
results: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await createSearch({
|
||||||
|
query: trimmed,
|
||||||
|
title: trimmed.slice(0, 80),
|
||||||
|
});
|
||||||
|
const result = await runSearch(created.id, {
|
||||||
|
query: trimmed,
|
||||||
|
title: trimmed.slice(0, 80),
|
||||||
|
type: "auto",
|
||||||
|
numResults: 10,
|
||||||
|
});
|
||||||
|
if (requestId === requestCounterRef.current) {
|
||||||
|
setSearch(result);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (message.includes("bearer token")) {
|
||||||
|
handleAuthFailure(message);
|
||||||
|
} else if (requestId === requestCounterRef.current) {
|
||||||
|
setError(message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestId === requestCounterRef.current) {
|
||||||
|
setIsRunning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return;
|
||||||
|
void runQuery(routeQuery);
|
||||||
|
}, [isAuthenticated, routeQuery]);
|
||||||
|
|
||||||
|
if (isCheckingSession) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<p className="text-sm text-muted-foreground">Checking session...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<AuthScreen
|
||||||
|
authTokenInput={authTokenInput}
|
||||||
|
setAuthTokenInput={setAuthTokenInput}
|
||||||
|
isSigningIn={isSigningIn}
|
||||||
|
authError={authError}
|
||||||
|
onSignIn={handleSignIn}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto px-3 py-6 md:px-6">
|
||||||
|
<div className="mx-auto w-full max-w-4xl space-y-5">
|
||||||
|
<form
|
||||||
|
className="flex items-center gap-2 rounded-xl border bg-background p-2 shadow-sm"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const next = queryInput.trim();
|
||||||
|
pushSearchQuery(next);
|
||||||
|
setRouteQuery(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input value={queryInput} onInput={(event) => setQueryInput(event.currentTarget.value)} placeholder="Search the web" />
|
||||||
|
<Button type="submit" size="icon" disabled={!queryInput.trim() || isRunning}>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||||
|
|
||||||
|
<SearchResultsPanel search={search} isLoading={false} isRunning={isRunning} showPrompt={false} className="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
web/src/root-router.tsx
Normal file
18
web/src/root-router.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
import App from "@/App";
|
||||||
|
import SearchRoutePage from "@/pages/search-route-page";
|
||||||
|
|
||||||
|
export function RootRouter() {
|
||||||
|
const [path, setPath] = useState(window.location.pathname);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPopState = () => setPath(window.location.pathname);
|
||||||
|
window.addEventListener("popstate", onPopState);
|
||||||
|
return () => window.removeEventListener("popstate", onPopState);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (path === "/search") {
|
||||||
|
return <SearchRoutePage />;
|
||||||
|
}
|
||||||
|
return <App />;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user