import { useEffect, useMemo, useRef, useState } from "preact/hooks"; import { Globe2, LogOut, MessageSquare, Plus, Search, SendHorizontal, ShieldCheck } 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 { 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 { cn } from "@/lib/utils"; type Provider = "openai" | "anthropic" | "xai"; type AuthMode = "open" | "token"; type SidebarSelection = { kind: "chat" | "search"; id: string }; type SidebarItem = SidebarSelection & { title: string; updatedAt: string; createdAt: string; }; const PROVIDER_DEFAULT_MODELS: Record = { openai: "gpt-4.1-mini", 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(); const firstUserMessage = messages?.find((m) => m.role === "user")?.content.trim(); if (firstUserMessage) return firstUserMessage.slice(0, 48); return "New chat"; } function getSearchTitle(search: Pick) { if (search.title?.trim()) return search.title.trim(); if (search.query?.trim()) return search.query.trim().slice(0, 64); return "New search"; } function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): SidebarItem[] { const items: SidebarItem[] = [ ...chats.map((chat) => ({ kind: "chat" as const, id: chat.id, title: getChatTitle(chat), updatedAt: chat.updatedAt, createdAt: chat.createdAt, })), ...searches.map((search) => ({ kind: "search" as const, id: search.id, title: getSearchTitle(search), updatedAt: search.updatedAt, createdAt: search.createdAt, })), ]; return items.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); } function formatDate(value: string) { return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", }).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 [chats, setChats] = useState([]); const [searches, setSearches] = useState([]); const [selectedItem, setSelectedItem] = useState(null); const [selectedChat, setSelectedChat] = useState(null); const [selectedSearch, setSelectedSearch] = useState(null); const [isLoadingCollections, setIsLoadingCollections] = useState(false); const [isLoadingSelection, setIsLoadingSelection] = useState(false); const [isSending, setIsSending] = useState(false); const [composer, setComposer] = useState(""); const [provider, setProvider] = useState("openai"); const [model, setModel] = useState(PROVIDER_DEFAULT_MODELS.openai); const [error, setError] = useState(null); const transcriptEndRef = useRef(null); 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); setChats([]); setSearches([]); setSelectedItem(null); setSelectedChat(null); setSelectedSearch(null); }; const refreshCollections = async (preferredSelection?: SidebarSelection) => { setIsLoadingCollections(true); try { const [nextChats, nextSearches] = await Promise.all([listChats(), listSearches()]); const nextItems = buildSidebarItems(nextChats, nextSearches); setChats(nextChats); setSearches(nextSearches); setSelectedItem((current) => { const hasItem = (candidate: SidebarSelection | null) => { if (!candidate) return false; return nextItems.some((item) => item.kind === candidate.kind && item.id === candidate.id); }; if (preferredSelection && hasItem(preferredSelection)) { return preferredSelection; } if (hasItem(current)) { return current; } const first = nextItems[0]; return first ? { kind: first.kind, id: first.id } : null; }); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { handleAuthFailure(message); } else { setError(message); } } finally { setIsLoadingCollections(false); } }; const refreshChat = async (chatId: string) => { setIsLoadingSelection(true); try { const chat = await getChat(chatId); setSelectedChat(chat); setSelectedSearch(null); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { handleAuthFailure(message); } else { setError(message); } } finally { setIsLoadingSelection(false); } }; const refreshSearch = async (searchId: string) => { setIsLoadingSelection(true); try { const search = await getSearch(searchId); setSelectedSearch(search); setSelectedChat(null); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { handleAuthFailure(message); } else { setError(message); } } finally { setIsLoadingSelection(false); } }; 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(); }, [isAuthenticated]); const selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null; useEffect(() => { if (!isAuthenticated) { setSelectedChat(null); setSelectedSearch(null); return; } if (!selectedItem) { setSelectedChat(null); setSelectedSearch(null); return; } if (selectedItem.kind === "chat") { void refreshChat(selectedItem.id); return; } void refreshSearch(selectedItem.id); }, [isAuthenticated, selectedKey]); useEffect(() => { transcriptEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); }, [selectedChat?.messages.length, selectedSearch?.results.length, isSending]); const messages = selectedChat?.messages ?? []; const selectedChatSummary = useMemo(() => { if (!selectedItem || selectedItem.kind !== "chat") return null; return chats.find((chat) => chat.id === selectedItem.id) ?? null; }, [chats, selectedItem]); const selectedSearchSummary = useMemo(() => { if (!selectedItem || selectedItem.kind !== "search") return null; return searches.find((search) => search.id === selectedItem.id) ?? null; }, [searches, selectedItem]); const selectedTitle = useMemo(() => { if (!selectedItem) return "Sybil"; if (selectedItem.kind === "chat") { if (selectedChat) return getChatTitle(selectedChat, selectedChat.messages); if (selectedChatSummary) return getChatTitle(selectedChatSummary); return "New chat"; } if (selectedSearch) return getSearchTitle(selectedSearch); if (selectedSearchSummary) return getSearchTitle(selectedSearchSummary); return "New search"; }, [selectedChat, selectedChatSummary, selectedItem, selectedSearch, selectedSearchSummary]); const isSearchMode = selectedItem?.kind === "search"; const handleCreateChat = async () => { setError(null); try { const chat = await createChat(); setSelectedItem({ kind: "chat", id: chat.id }); setSelectedChat({ id: chat.id, title: chat.title, createdAt: chat.createdAt, updatedAt: chat.updatedAt, messages: [], }); setSelectedSearch(null); await refreshCollections({ kind: "chat", id: chat.id }); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { handleAuthFailure(message); } else { setError(message); } } }; const handleCreateSearch = async () => { setError(null); try { const search = await createSearch(); setSelectedItem({ kind: "search", id: search.id }); setSelectedSearch({ id: search.id, title: search.title, query: search.query, createdAt: search.createdAt, updatedAt: search.updatedAt, requestId: null, latencyMs: null, error: null, results: [], }); setSelectedChat(null); await refreshCollections({ kind: "search", id: search.id }); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { handleAuthFailure(message); } else { setError(message); } } }; const handleSendChat = async (content: string) => { let chatId = selectedItem?.kind === "chat" ? selectedItem.id : null; if (!chatId) { const chat = await createChat(); chatId = chat.id; setSelectedItem({ kind: "chat", id: chatId }); setSelectedChat({ id: chat.id, title: chat.title, createdAt: chat.createdAt, updatedAt: chat.updatedAt, messages: [], }); setSelectedSearch(null); } if (!chatId) { throw new Error("Unable to initialize chat"); } let baseChat = selectedChat; if (!baseChat || baseChat.id !== chatId) { baseChat = await getChat(chatId); } const optimisticUserMessage = { id: `temp-user-${Date.now()}`, createdAt: new Date().toISOString(), role: "user" as const, content, name: null, }; const optimisticAssistantMessage = { id: `temp-assistant-${Date.now()}`, createdAt: new Date().toISOString(), role: "assistant" as const, content: "", name: null, }; setSelectedChat((current) => { if (!current || current.id !== chatId) return current; return { ...current, messages: [...current.messages, optimisticUserMessage, optimisticAssistantMessage], }; }); const requestMessages: CompletionRequestMessage[] = [ ...baseChat.messages.map((message) => ({ role: message.role, content: message.content, ...(message.name ? { name: message.name } : {}), })), { role: "user", content, }, ]; await runCompletion({ chatId, provider, model: model.trim(), messages: requestMessages, }); await Promise.all([refreshCollections({ kind: "chat", id: chatId }), refreshChat(chatId)]); }; const handleSendSearch = async (query: string) => { let searchId = selectedItem?.kind === "search" ? selectedItem.id : null; if (!searchId) { const search = await createSearch(); searchId = search.id; setSelectedItem({ kind: "search", id: searchId }); } if (!searchId) { throw new Error("Unable to initialize search"); } setSelectedSearch((current) => { if (!current || current.id !== searchId) return current; return { ...current, title: query.slice(0, 80), query, error: null, results: [], }; }); const search = await runSearch(searchId, { query, title: query.slice(0, 80), type: "auto", numResults: 10, }); setSelectedSearch(search); setSelectedChat(null); await refreshCollections({ kind: "search", id: searchId }); }; const handleSend = async () => { const content = composer.trim(); if (!content || isSending) return; setComposer(""); setError(null); setIsSending(true); try { if (isSearchMode) { await handleSendSearch(content); } else { await handleSendChat(content); } } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { handleAuthFailure(message); } else { setError(message); } if (selectedItem?.kind === "chat") { await refreshChat(selectedItem.id); } if (selectedItem?.kind === "search") { await refreshSearch(selectedItem.id); } } finally { setIsSending(false); } }; 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); setComposer(""); setError(null); }; if (isCheckingSession) { return (

Checking session...

); } 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.

); } return (

{selectedTitle}

Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""} {isSearchMode ? " • Exa Search" : ""}

{!isSearchMode ? ( <> setModel(event.currentTarget.value)} placeholder="Model" disabled={isSending} /> ) : (
Search mode
)}
{!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} {isLoadingSelection && !selectedSearch?.results.length ? (

Loading search...

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

Search the web

Use the composer below to run a new Exa search.

) : null} {!isLoadingSelection && !!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}
)}