diff --git a/web/README.md b/web/README.md index fcf1390..8d65185 100644 --- a/web/README.md +++ b/web/README.md @@ -40,3 +40,8 @@ Default dev URL: `http://localhost:5173` - Composer adapts to the active item: - Chat sends `POST /v1/chat-completions`. - 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 diff --git a/web/src/App.tsx b/web/src/App.tsx index 3d53c7e..828b079 100644 --- a/web/src/App.tsx +++ b/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 = { anthropic: "claude-3-5-sonnet-latest", xai: "grok-3-mini", }; -const TOKEN_STORAGE_KEY = "sybil_admin_token"; function getChatTitle(chat: Pick, 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(null); - const [authError, setAuthError] = useState(null); + const { + authTokenInput, + setAuthTokenInput, + isCheckingSession, + isSigningIn, + isAuthenticated, + authMode, + authError, + handleAuthFailure: baseHandleAuthFailure, + handleSignIn, + logout, + } = useSessionAuth(); const [chats, setChats] = useState([]); const [searches, setSearches] = useState([]); @@ -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 ( -
-
-
-
- -
-
-

Sign in to Sybil

-

Use your backend admin token.

-
-
- -
{ - event.preventDefault(); - void handleSignIn(authTokenInput.trim() || null); - }} - > - setAuthTokenInput(event.currentTarget.value)} - disabled={isSigningIn} - /> - - -
- - {authError ?

{authError}

: null} -

If `ADMIN_TOKEN` is set in `/server/.env`, token login is required.

-
-
+ ); } @@ -603,9 +486,7 @@ export default function App() {
- {isLoadingCollections && sidebarItems.length === 0 ? ( -

Loading conversations...

- ) : null} + {isLoadingCollections && sidebarItems.length === 0 ?

Loading conversations...

: null} {!isLoadingCollections && sidebarItems.length === 0 ? (
@@ -664,12 +545,7 @@ export default function App() { - setModel(event.currentTarget.value)} - placeholder="Model" - disabled={isSending} - /> + setModel(event.currentTarget.value)} placeholder="Model" disabled={isSending} /> ) : (
@@ -686,121 +562,9 @@ export default function App() {
{!isSearchMode ? ( - <> - {isLoadingSelection && messages.length === 0 ?

Loading messages...

: null} - {!isLoadingSelection && messages.length === 0 ? ( -
-

How can I help today?

-

Ask a question to begin this conversation.

-
- ) : null} -
- {messages.map((message) => { - const isUser = message.role === "user"; - const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending; - return ( -
-
- {isPendingAssistant ? "Thinking..." : message.content} -
-
- ); - })} -
- + ) : ( -
- {selectedSearch?.query ? ( -
-

Results for

-

{selectedSearch.query}

-

- {selectedSearch.results.length} result{selectedSearch.results.length === 1 ? "" : "s"} - {selectedSearch.latencyMs ? ` • ${selectedSearch.latencyMs} ms` : ""} -

-
- ) : null} - - {(isSearchRunning || !!selectedSearch?.answerText || !!selectedSearch?.answerError) && ( -
-

Answer

- {isSearchRunning && !selectedSearch?.answerText ? ( -

Generating answer...

- ) : null} - {selectedSearch?.answerText ? ( -

{selectedSearch.answerText}

- ) : null} - {selectedSearch?.answerError ?

{selectedSearch.answerError}

: null} - {!!selectedSearch?.answerCitations?.length && ( -
- {selectedSearch.answerCitations.slice(0, 6).map((citation, index) => { - const href = citation.url || citation.id || ""; - if (!href) return null; - return ( - - {citation.title?.trim() || formatHost(href)} - - ); - })} -
- )} -
- )} - - {(isLoadingSelection || isSearchRunning) && !selectedSearch?.results.length ? ( -

{isSearchRunning ? "Searching Exa..." : "Loading search..."}

- ) : null} - - {!isLoadingSelection && !selectedSearch?.query ? ( -
- -

Search the web

-

Use the composer below to run a new Exa search.

-
- ) : null} - - {!isLoadingSelection && !isSearchRunning && !!selectedSearch?.query && selectedSearch.results.length === 0 ? ( -

No results found.

- ) : null} - -
- {selectedSearch?.results.map((result) => { - const summary = summarizeResult(result); - return ( -
-

{formatHost(result.url)}

- - {result.title || result.url} - - {(result.publishedDate || result.author) && ( -

- {[result.publishedDate, result.author].filter(Boolean).join(" • ")} -

- )} - {summary ?

{summary}

: null} -
- ); - })} -
- - {selectedSearch?.error ?

{selectedSearch.error}

: null} -
+ )}
@@ -822,11 +586,7 @@ export default function App() { disabled={isSending} />
- {error ? ( -

{error}

- ) : ( - {isSearchMode ? "Enter to search" : "Enter to send"} - )} + {error ?

{error}

: {isSearchMode ? "Enter to search" : "Enter to send"}} diff --git a/web/src/components/auth/auth-screen.tsx b/web/src/components/auth/auth-screen.tsx new file mode 100644 index 0000000..0716b96 --- /dev/null +++ b/web/src/components/auth/auth-screen.tsx @@ -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; +}; + +export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, authError, onSignIn }: Props) { + return ( +
+
+
+
+ +
+
+

Sign in to Sybil

+

Use your backend admin token.

+
+
+ +
{ + event.preventDefault(); + void onSignIn(authTokenInput.trim() || null); + }} + > + setAuthTokenInput(event.currentTarget.value)} + disabled={isSigningIn} + /> + + +
+ + {authError ?

{authError}

: null} +

If `ADMIN_TOKEN` is set in `/server/.env`, token login is required.

+
+
+ ); +} diff --git a/web/src/components/chat/chat-messages-panel.tsx b/web/src/components/chat/chat-messages-panel.tsx new file mode 100644 index 0000000..2757107 --- /dev/null +++ b/web/src/components/chat/chat-messages-panel.tsx @@ -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 ?

Loading messages...

: null} + {!isLoading && messages.length === 0 ? ( +
+

How can I help today?

+

Ask a question to begin this conversation.

+
+ ) : null} +
+ {messages.map((message) => { + const isUser = message.role === "user"; + const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending; + return ( +
+
+ {isPendingAssistant ? "Thinking..." : message.content} +
+
+ ); + })} +
+ + ); +} diff --git a/web/src/components/search/search-results-panel.tsx b/web/src/components/search/search-results-panel.tsx new file mode 100644 index 0000000..47e9ba9 --- /dev/null +++ b/web/src/components/search/search-results-panel.tsx @@ -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 ( +
+ {search?.query ? ( +
+

Results for

+

{search.query}

+

+ {search.results.length} result{search.results.length === 1 ? "" : "s"} + {search.latencyMs ? ` • ${search.latencyMs} ms` : ""} +

+
+ ) : null} + + {(isRunning || !!search?.answerText || !!search?.answerError) && ( +
+

Answer

+ {isRunning && !search?.answerText ?

Generating answer...

: null} + {search?.answerText ?

{search.answerText}

: null} + {search?.answerError ?

{search.answerError}

: null} + {!!search?.answerCitations?.length && ( +
+ {search.answerCitations.slice(0, 6).map((citation, index) => { + const href = citation.url || citation.id || ""; + if (!href) return null; + return ( + + {citation.title?.trim() || formatHost(href)} + + ); + })} +
+ )} +
+ )} + + {(isLoading || isRunning) && !search?.results.length ? ( +

{isRunning ? "Searching Exa..." : "Loading search..."}

+ ) : null} + + {showPrompt && !isLoading && !search?.query ? ( +
+ +

Search the web

+

Use the composer below to run a new Exa search.

+
+ ) : null} + + {!isLoading && !isRunning && !!search?.query && search.results.length === 0 ? ( +

No results found.

+ ) : null} + +
+ {search?.results.map((result) => { + const summary = summarizeResult(result); + return ( +
+

{formatHost(result.url)}

+ + {result.title || result.url} + + {(result.publishedDate || result.author) && ( +

{[result.publishedDate, result.author].filter(Boolean).join(" • ")}

+ )} + {summary ?

{summary}

: null} +
+ ); + })} +
+ + {search?.error ?

{search.error}

: null} +
+ ); +} diff --git a/web/src/hooks/use-session-auth.ts b/web/src/hooks/use-session-auth.ts new file mode 100644 index 0000000..022605c --- /dev/null +++ b/web/src/hooks/use-session-auth.ts @@ -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(null); + const [authError, setAuthError] = useState(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, + }; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 06d2941..2fe65ee 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,5 +1,5 @@ import { render } from "preact"; -import App from "./App"; +import { RootRouter } from "@/root-router"; import "./index.css"; -render(, document.getElementById("app")!); +render(, document.getElementById("app")!); diff --git a/web/src/pages/search-route-page.tsx b/web/src/pages/search-route-page.tsx new file mode 100644 index 0000000..77c1612 --- /dev/null +++ b/web/src/pages/search-route-page.tsx @@ -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(null); + const [isRunning, setIsRunning] = useState(false); + const [error, setError] = useState(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 ( +
+

Checking session...

+
+ ); + } + + if (!isAuthenticated) { + return ( + + ); + } + + return ( +
+
+
{ + event.preventDefault(); + const next = queryInput.trim(); + pushSearchQuery(next); + setRouteQuery(next); + }} + > + setQueryInput(event.currentTarget.value)} placeholder="Search the web" /> + +
+ + {error ?

{error}

: null} + + +
+
+ ); +} diff --git a/web/src/root-router.tsx b/web/src/root-router.tsx new file mode 100644 index 0000000..c96eb3e --- /dev/null +++ b/web/src/root-router.tsx @@ -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 ; + } + return ; +}