2026-02-13 23:15:12 -08:00
|
|
|
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
2026-02-13 23:49:55 -08:00
|
|
|
import { Globe2, LogOut, MessageSquare, Plus, Search, SendHorizontal, ShieldCheck } from "lucide-preact";
|
2026-02-13 23:15:12 -08:00
|
|
|
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,
|
2026-02-13 23:49:55 -08:00
|
|
|
createSearch,
|
2026-02-13 23:15:12 -08:00
|
|
|
getChat,
|
|
|
|
|
getConfiguredToken,
|
2026-02-13 23:49:55 -08:00
|
|
|
getSearch,
|
2026-02-13 23:15:12 -08:00
|
|
|
listChats,
|
2026-02-13 23:49:55 -08:00
|
|
|
listSearches,
|
2026-02-13 23:15:12 -08:00
|
|
|
runCompletion,
|
2026-02-13 23:49:55 -08:00
|
|
|
runSearch,
|
2026-02-13 23:15:12 -08:00
|
|
|
setAuthToken,
|
|
|
|
|
verifySession,
|
|
|
|
|
type ChatDetail,
|
|
|
|
|
type ChatSummary,
|
|
|
|
|
type CompletionRequestMessage,
|
2026-02-13 23:49:55 -08:00
|
|
|
type SearchDetail,
|
|
|
|
|
type SearchResultItem,
|
|
|
|
|
type SearchSummary,
|
2026-02-13 23:15:12 -08:00
|
|
|
} from "@/lib/api";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
|
|
|
|
|
type Provider = "openai" | "anthropic" | "xai";
|
|
|
|
|
type AuthMode = "open" | "token";
|
2026-02-13 23:49:55 -08:00
|
|
|
type SidebarSelection = { kind: "chat" | "search"; id: string };
|
2026-02-14 00:09:06 -08:00
|
|
|
type DraftSelectionKind = "chat" | "search";
|
2026-02-13 23:49:55 -08:00
|
|
|
type SidebarItem = SidebarSelection & {
|
|
|
|
|
title: string;
|
|
|
|
|
updatedAt: string;
|
|
|
|
|
createdAt: string;
|
|
|
|
|
};
|
2026-02-13 23:15:12 -08:00
|
|
|
|
|
|
|
|
const PROVIDER_DEFAULT_MODELS: Record<Provider, string> = {
|
|
|
|
|
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<ChatSummary, "title">, 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";
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 23:49:55 -08:00
|
|
|
function getSearchTitle(search: Pick<SearchSummary, "title" | "query">) {
|
|
|
|
|
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());
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 23:15:12 -08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 23:49:55 -08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 23:15:12 -08:00
|
|
|
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 [chats, setChats] = useState<ChatSummary[]>([]);
|
2026-02-13 23:49:55 -08:00
|
|
|
const [searches, setSearches] = useState<SearchSummary[]>([]);
|
|
|
|
|
const [selectedItem, setSelectedItem] = useState<SidebarSelection | null>(null);
|
2026-02-13 23:15:12 -08:00
|
|
|
const [selectedChat, setSelectedChat] = useState<ChatDetail | null>(null);
|
2026-02-13 23:49:55 -08:00
|
|
|
const [selectedSearch, setSelectedSearch] = useState<SearchDetail | null>(null);
|
2026-02-14 00:09:06 -08:00
|
|
|
const [draftKind, setDraftKind] = useState<DraftSelectionKind | null>(null);
|
2026-02-13 23:49:55 -08:00
|
|
|
const [isLoadingCollections, setIsLoadingCollections] = useState(false);
|
|
|
|
|
const [isLoadingSelection, setIsLoadingSelection] = useState(false);
|
2026-02-13 23:15:12 -08:00
|
|
|
const [isSending, setIsSending] = useState(false);
|
|
|
|
|
const [composer, setComposer] = useState("");
|
|
|
|
|
const [provider, setProvider] = useState<Provider>("openai");
|
|
|
|
|
const [model, setModel] = useState(PROVIDER_DEFAULT_MODELS.openai);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
2026-02-13 23:49:55 -08:00
|
|
|
const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]);
|
|
|
|
|
|
2026-02-13 23:15:12 -08:00
|
|
|
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([]);
|
2026-02-13 23:49:55 -08:00
|
|
|
setSearches([]);
|
|
|
|
|
setSelectedItem(null);
|
2026-02-13 23:15:12 -08:00
|
|
|
setSelectedChat(null);
|
2026-02-13 23:49:55 -08:00
|
|
|
setSelectedSearch(null);
|
2026-02-14 00:09:06 -08:00
|
|
|
setDraftKind(null);
|
2026-02-13 23:15:12 -08:00
|
|
|
};
|
|
|
|
|
|
2026-02-13 23:49:55 -08:00
|
|
|
const refreshCollections = async (preferredSelection?: SidebarSelection) => {
|
|
|
|
|
setIsLoadingCollections(true);
|
2026-02-13 23:15:12 -08:00
|
|
|
try {
|
2026-02-13 23:49:55 -08:00
|
|
|
const [nextChats, nextSearches] = await Promise.all([listChats(), listSearches()]);
|
|
|
|
|
const nextItems = buildSidebarItems(nextChats, nextSearches);
|
2026-02-13 23:15:12 -08:00
|
|
|
setChats(nextChats);
|
2026-02-13 23:49:55 -08:00
|
|
|
setSearches(nextSearches);
|
2026-02-13 23:15:12 -08:00
|
|
|
|
2026-02-13 23:49:55 -08:00
|
|
|
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;
|
2026-02-13 23:15:12 -08:00
|
|
|
}
|
2026-02-13 23:49:55 -08:00
|
|
|
if (hasItem(current)) {
|
2026-02-13 23:15:12 -08:00
|
|
|
return current;
|
|
|
|
|
}
|
2026-02-13 23:49:55 -08:00
|
|
|
const first = nextItems[0];
|
|
|
|
|
return first ? { kind: first.kind, id: first.id } : null;
|
2026-02-13 23:15:12 -08:00
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
|
|
|
if (message.includes("bearer token")) {
|
|
|
|
|
handleAuthFailure(message);
|
|
|
|
|
} else {
|
|
|
|
|
setError(message);
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
2026-02-13 23:49:55 -08:00
|
|
|
setIsLoadingCollections(false);
|
2026-02-13 23:15:12 -08:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const refreshChat = async (chatId: string) => {
|
2026-02-13 23:49:55 -08:00
|
|
|
setIsLoadingSelection(true);
|
2026-02-13 23:15:12 -08:00
|
|
|
try {
|
|
|
|
|
const chat = await getChat(chatId);
|
|
|
|
|
setSelectedChat(chat);
|
2026-02-13 23:49:55 -08:00
|
|
|
setSelectedSearch(null);
|
2026-02-13 23:15:12 -08:00
|
|
|
} catch (err) {
|
|
|
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
|
|
|
if (message.includes("bearer token")) {
|
|
|
|
|
handleAuthFailure(message);
|
|
|
|
|
} else {
|
|
|
|
|
setError(message);
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
2026-02-13 23:49:55 -08:00
|
|
|
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);
|
2026-02-13 23:15:12 -08:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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;
|
2026-02-13 23:49:55 -08:00
|
|
|
void refreshCollections();
|
2026-02-13 23:15:12 -08:00
|
|
|
}, [isAuthenticated]);
|
|
|
|
|
|
2026-02-13 23:49:55 -08:00
|
|
|
const selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null;
|
|
|
|
|
|
2026-02-13 23:15:12 -08:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!isAuthenticated) {
|
|
|
|
|
setSelectedChat(null);
|
2026-02-13 23:49:55 -08:00
|
|
|
setSelectedSearch(null);
|
2026-02-13 23:15:12 -08:00
|
|
|
return;
|
|
|
|
|
}
|
2026-02-13 23:49:55 -08:00
|
|
|
if (!selectedItem) {
|
2026-02-13 23:15:12 -08:00
|
|
|
setSelectedChat(null);
|
2026-02-13 23:49:55 -08:00
|
|
|
setSelectedSearch(null);
|
2026-02-13 23:15:12 -08:00
|
|
|
return;
|
|
|
|
|
}
|
2026-02-13 23:49:55 -08:00
|
|
|
|
|
|
|
|
if (selectedItem.kind === "chat") {
|
|
|
|
|
void refreshChat(selectedItem.id);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
void refreshSearch(selectedItem.id);
|
|
|
|
|
}, [isAuthenticated, selectedKey]);
|
2026-02-13 23:15:12 -08:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-02-14 00:09:06 -08:00
|
|
|
if (draftKind === "search" || selectedItem?.kind === "search") return;
|
2026-02-13 23:15:12 -08:00
|
|
|
transcriptEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
2026-02-14 00:09:06 -08:00
|
|
|
}, [draftKind, selectedChat?.messages.length, isSending, selectedItem?.kind]);
|
2026-02-13 23:15:12 -08:00
|
|
|
|
|
|
|
|
const messages = selectedChat?.messages ?? [];
|
|
|
|
|
|
2026-02-13 23:49:55 -08:00
|
|
|
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(() => {
|
2026-02-14 00:09:06 -08:00
|
|
|
if (draftKind === "chat") return "New chat";
|
|
|
|
|
if (draftKind === "search") return "New search";
|
2026-02-13 23:49:55 -08:00
|
|
|
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";
|
2026-02-14 00:09:06 -08:00
|
|
|
}, [draftKind, selectedChat, selectedChatSummary, selectedItem, selectedSearch, selectedSearchSummary]);
|
2026-02-13 23:49:55 -08:00
|
|
|
|
2026-02-14 00:09:06 -08:00
|
|
|
const isSearchMode = draftKind ? draftKind === "search" : selectedItem?.kind === "search";
|
2026-02-13 23:56:35 -08:00
|
|
|
const isSearchRunning = isSending && selectedItem?.kind === "search";
|
2026-02-13 23:15:12 -08:00
|
|
|
|
2026-02-14 00:09:06 -08:00
|
|
|
const handleCreateChat = () => {
|
2026-02-13 23:15:12 -08:00
|
|
|
setError(null);
|
2026-02-14 00:09:06 -08:00
|
|
|
setDraftKind("chat");
|
|
|
|
|
setSelectedItem(null);
|
|
|
|
|
setSelectedChat(null);
|
|
|
|
|
setSelectedSearch(null);
|
2026-02-13 23:15:12 -08:00
|
|
|
};
|
|
|
|
|
|
2026-02-14 00:09:06 -08:00
|
|
|
const handleCreateSearch = () => {
|
2026-02-13 23:15:12 -08:00
|
|
|
setError(null);
|
2026-02-14 00:09:06 -08:00
|
|
|
setDraftKind("search");
|
|
|
|
|
setSelectedItem(null);
|
|
|
|
|
setSelectedChat(null);
|
|
|
|
|
setSelectedSearch(null);
|
2026-02-13 23:49:55 -08:00
|
|
|
};
|
2026-02-13 23:15:12 -08:00
|
|
|
|
2026-02-13 23:49:55 -08:00
|
|
|
const handleSendChat = async (content: string) => {
|
2026-02-14 00:09:06 -08:00
|
|
|
let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null;
|
2026-02-13 23:15:12 -08:00
|
|
|
|
2026-02-13 23:49:55 -08:00
|
|
|
if (!chatId) {
|
|
|
|
|
const chat = await createChat();
|
|
|
|
|
chatId = chat.id;
|
2026-02-14 00:09:06 -08:00
|
|
|
setDraftKind(null);
|
2026-02-13 23:49:55 -08:00
|
|
|
setSelectedItem({ kind: "chat", id: chatId });
|
|
|
|
|
setSelectedChat({
|
|
|
|
|
id: chat.id,
|
|
|
|
|
title: chat.title,
|
|
|
|
|
createdAt: chat.createdAt,
|
|
|
|
|
updatedAt: chat.updatedAt,
|
|
|
|
|
messages: [],
|
|
|
|
|
});
|
|
|
|
|
setSelectedSearch(null);
|
|
|
|
|
}
|
2026-02-13 23:15:12 -08:00
|
|
|
|
2026-02-13 23:49:55 -08:00
|
|
|
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],
|
2026-02-13 23:15:12 -08:00
|
|
|
};
|
2026-02-13 23:49:55 -08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
});
|
2026-02-13 23:15:12 -08:00
|
|
|
|
2026-02-13 23:49:55 -08:00
|
|
|
await Promise.all([refreshCollections({ kind: "chat", id: chatId }), refreshChat(chatId)]);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSendSearch = async (query: string) => {
|
2026-02-14 00:09:06 -08:00
|
|
|
let searchId = draftKind === "search" ? null : selectedItem?.kind === "search" ? selectedItem.id : null;
|
2026-02-13 23:49:55 -08:00
|
|
|
|
|
|
|
|
if (!searchId) {
|
2026-02-14 00:09:06 -08:00
|
|
|
const search = await createSearch({
|
|
|
|
|
query,
|
|
|
|
|
title: query.slice(0, 80),
|
|
|
|
|
});
|
2026-02-13 23:49:55 -08:00
|
|
|
searchId = search.id;
|
2026-02-14 00:09:06 -08:00
|
|
|
setDraftKind(null);
|
2026-02-13 23:49:55 -08:00
|
|
|
setSelectedItem({ kind: "search", id: searchId });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!searchId) {
|
|
|
|
|
throw new Error("Unable to initialize search");
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 23:56:35 -08:00
|
|
|
const nowIso = new Date().toISOString();
|
2026-02-13 23:49:55 -08:00
|
|
|
setSelectedSearch((current) => {
|
2026-02-13 23:56:35 -08:00
|
|
|
if (!current || current.id !== searchId) {
|
|
|
|
|
return {
|
|
|
|
|
id: searchId,
|
|
|
|
|
title: query.slice(0, 80),
|
|
|
|
|
query,
|
|
|
|
|
createdAt: nowIso,
|
|
|
|
|
updatedAt: nowIso,
|
|
|
|
|
requestId: null,
|
|
|
|
|
latencyMs: null,
|
|
|
|
|
error: null,
|
2026-02-14 00:14:10 -08:00
|
|
|
answerText: null,
|
|
|
|
|
answerRequestId: null,
|
|
|
|
|
answerCitations: null,
|
|
|
|
|
answerError: null,
|
2026-02-13 23:56:35 -08:00
|
|
|
results: [],
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-02-13 23:49:55 -08:00
|
|
|
return {
|
|
|
|
|
...current,
|
|
|
|
|
title: query.slice(0, 80),
|
|
|
|
|
query,
|
|
|
|
|
error: null,
|
2026-02-13 23:56:35 -08:00
|
|
|
latencyMs: null,
|
2026-02-14 00:14:10 -08:00
|
|
|
answerText: null,
|
|
|
|
|
answerRequestId: null,
|
|
|
|
|
answerCitations: null,
|
|
|
|
|
answerError: null,
|
2026-02-13 23:49:55 -08:00
|
|
|
results: [],
|
2026-02-13 23:15:12 -08:00
|
|
|
};
|
2026-02-13 23:49:55 -08:00
|
|
|
});
|
2026-02-13 23:15:12 -08:00
|
|
|
|
2026-02-13 23:49:55 -08:00
|
|
|
const search = await runSearch(searchId, {
|
|
|
|
|
query,
|
|
|
|
|
title: query.slice(0, 80),
|
|
|
|
|
type: "auto",
|
|
|
|
|
numResults: 10,
|
|
|
|
|
});
|
2026-02-13 23:15:12 -08:00
|
|
|
|
2026-02-13 23:49:55 -08:00
|
|
|
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);
|
2026-02-13 23:15:12 -08:00
|
|
|
|
2026-02-13 23:49:55 -08:00
|
|
|
try {
|
|
|
|
|
if (isSearchMode) {
|
|
|
|
|
await handleSendSearch(content);
|
|
|
|
|
} else {
|
|
|
|
|
await handleSendChat(content);
|
|
|
|
|
}
|
2026-02-13 23:15:12 -08:00
|
|
|
} catch (err) {
|
|
|
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
|
|
|
if (message.includes("bearer token")) {
|
|
|
|
|
handleAuthFailure(message);
|
|
|
|
|
} else {
|
|
|
|
|
setError(message);
|
|
|
|
|
}
|
2026-02-13 23:49:55 -08:00
|
|
|
|
|
|
|
|
if (selectedItem?.kind === "chat") {
|
|
|
|
|
await refreshChat(selectedItem.id);
|
|
|
|
|
}
|
|
|
|
|
if (selectedItem?.kind === "search") {
|
|
|
|
|
await refreshSearch(selectedItem.id);
|
2026-02-13 23:15:12 -08:00
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
setIsSending(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSignIn = async (tokenCandidate: string | null) => {
|
|
|
|
|
setIsSigningIn(true);
|
|
|
|
|
setAuthError(null);
|
|
|
|
|
try {
|
|
|
|
|
await completeSessionCheck(tokenCandidate);
|
2026-02-13 23:49:55 -08:00
|
|
|
await refreshCollections();
|
2026-02-13 23:15:12 -08:00
|
|
|
} 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([]);
|
2026-02-13 23:49:55 -08:00
|
|
|
setSearches([]);
|
|
|
|
|
setSelectedItem(null);
|
2026-02-13 23:15:12 -08:00
|
|
|
setSelectedChat(null);
|
2026-02-13 23:49:55 -08:00
|
|
|
setSelectedSearch(null);
|
2026-02-14 00:09:06 -08:00
|
|
|
setDraftKind(null);
|
2026-02-13 23:15:12 -08:00
|
|
|
setComposer("");
|
|
|
|
|
setError(null);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (isCheckingSession) {
|
|
|
|
|
return (
|
2026-02-13 23:20:57 -08:00
|
|
|
<div className="flex h-full items-center justify-center">
|
2026-02-13 23:15:12 -08:00
|
|
|
<p className="text-sm text-muted-foreground">Checking session...</p>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isAuthenticated) {
|
|
|
|
|
return (
|
2026-02-13 23:20:57 -08:00
|
|
|
<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">
|
2026-02-13 23:15:12 -08:00
|
|
|
<div className="mb-5 flex items-start gap-3">
|
2026-02-13 23:20:57 -08:00
|
|
|
<div className="rounded-lg bg-slate-200 p-2 text-slate-900">
|
2026-02-13 23:15:12 -08:00
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="h-full p-3 md:p-5">
|
2026-02-13 23:20:57 -08:00
|
|
|
<div className="mx-auto flex h-full w-full max-w-[1560px] overflow-hidden rounded-2xl border bg-background shadow-xl shadow-black/40">
|
|
|
|
|
<aside className="flex w-80 shrink-0 flex-col border-r bg-[#111827]">
|
2026-02-13 23:49:55 -08:00
|
|
|
<div className="grid grid-cols-2 gap-2 p-3">
|
|
|
|
|
<Button className="justify-start gap-2" onClick={handleCreateChat}>
|
2026-02-13 23:15:12 -08:00
|
|
|
<Plus className="h-4 w-4" />
|
|
|
|
|
New chat
|
|
|
|
|
</Button>
|
2026-02-13 23:49:55 -08:00
|
|
|
<Button className="justify-start gap-2" variant="secondary" onClick={handleCreateSearch}>
|
|
|
|
|
<Search className="h-4 w-4" />
|
|
|
|
|
New search
|
|
|
|
|
</Button>
|
2026-02-13 23:15:12 -08:00
|
|
|
</div>
|
|
|
|
|
<Separator />
|
|
|
|
|
<div className="flex-1 overflow-y-auto p-2">
|
2026-02-13 23:49:55 -08:00
|
|
|
{isLoadingCollections && sidebarItems.length === 0 ? (
|
|
|
|
|
<p className="px-2 py-3 text-sm text-muted-foreground">Loading conversations...</p>
|
|
|
|
|
) : null}
|
|
|
|
|
{!isLoadingCollections && sidebarItems.length === 0 ? (
|
2026-02-13 23:15:12 -08:00
|
|
|
<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" />
|
2026-02-13 23:49:55 -08:00
|
|
|
Start a chat or run your first search.
|
2026-02-13 23:15:12 -08:00
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-02-13 23:49:55 -08:00
|
|
|
{sidebarItems.map((item) => {
|
|
|
|
|
const active = selectedItem?.kind === item.kind && selectedItem.id === item.id;
|
2026-02-13 23:15:12 -08:00
|
|
|
return (
|
|
|
|
|
<button
|
2026-02-13 23:49:55 -08:00
|
|
|
key={`${item.kind}-${item.id}`}
|
2026-02-13 23:15:12 -08:00
|
|
|
className={cn(
|
|
|
|
|
"mb-1 w-full rounded-lg px-3 py-2 text-left transition",
|
2026-02-13 23:20:57 -08:00
|
|
|
active ? "bg-slate-700 text-slate-50" : "text-slate-200 hover:bg-slate-800"
|
2026-02-13 23:15:12 -08:00
|
|
|
)}
|
2026-02-14 00:09:06 -08:00
|
|
|
onClick={() => {
|
|
|
|
|
setDraftKind(null);
|
|
|
|
|
setSelectedItem({ kind: item.kind, id: item.id });
|
|
|
|
|
}}
|
2026-02-13 23:15:12 -08:00
|
|
|
type="button"
|
|
|
|
|
>
|
2026-02-13 23:49:55 -08:00
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{item.kind === "chat" ? <MessageSquare className="h-3.5 w-3.5" /> : <Search className="h-3.5 w-3.5" />}
|
|
|
|
|
<p className="truncate text-sm font-medium">{item.title}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<p className={cn("mt-1 text-xs", active ? "text-slate-200" : "text-slate-400")}>{formatDate(item.updatedAt)}</p>
|
2026-02-13 23:15:12 -08:00
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
<main className="flex min-w-0 flex-1 flex-col">
|
|
|
|
|
<header className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
|
|
|
|
|
<div>
|
2026-02-13 23:49:55 -08:00
|
|
|
<h1 className="text-sm font-semibold md:text-base">{selectedTitle}</h1>
|
2026-02-13 23:15:12 -08:00
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
|
2026-02-13 23:49:55 -08:00
|
|
|
{isSearchMode ? " • Exa Search" : ""}
|
2026-02-13 23:15:12 -08:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex w-full max-w-xl items-center gap-2 md:w-auto">
|
2026-02-13 23:49:55 -08:00
|
|
|
{!isSearchMode ? (
|
|
|
|
|
<>
|
|
|
|
|
<select
|
|
|
|
|
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
|
|
|
|
|
value={provider}
|
|
|
|
|
onChange={(event) => {
|
|
|
|
|
const nextProvider = event.currentTarget.value as Provider;
|
|
|
|
|
setProvider(nextProvider);
|
|
|
|
|
setModel(PROVIDER_DEFAULT_MODELS[nextProvider]);
|
|
|
|
|
}}
|
|
|
|
|
disabled={isSending}
|
|
|
|
|
>
|
|
|
|
|
<option value="openai">OpenAI</option>
|
|
|
|
|
<option value="anthropic">Anthropic</option>
|
|
|
|
|
<option value="xai">xAI</option>
|
|
|
|
|
</select>
|
|
|
|
|
<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">
|
|
|
|
|
<Globe2 className="mr-2 h-4 w-4" />
|
|
|
|
|
Search mode
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-02-13 23:15:12 -08:00
|
|
|
<Button variant="outline" size="sm" onClick={handleLogout}>
|
|
|
|
|
<LogOut className="mr-1 h-4 w-4" />
|
|
|
|
|
Logout
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<div className="flex-1 overflow-y-auto px-3 py-6 md:px-10">
|
2026-02-13 23:49:55 -08:00
|
|
|
{!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>
|
2026-02-13 23:15:12 -08:00
|
|
|
</div>
|
2026-02-13 23:49:55 -08:00
|
|
|
) : 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">
|
|
|
|
|
{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}
|
|
|
|
|
|
2026-02-14 00:14:10 -08:00
|
|
|
{(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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-13 23:56:35 -08:00
|
|
|
{(isLoadingSelection || isSearchRunning) && !selectedSearch?.results.length ? (
|
|
|
|
|
<p className="text-sm text-muted-foreground">{isSearchRunning ? "Searching Exa..." : "Loading search..."}</p>
|
2026-02-13 23:49:55 -08:00
|
|
|
) : 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}
|
|
|
|
|
|
2026-02-13 23:56:35 -08:00
|
|
|
{!isLoadingSelection && !isSearchRunning && !!selectedSearch?.query && selectedSearch.results.length === 0 ? (
|
2026-02-13 23:49:55 -08:00
|
|
|
<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>
|
|
|
|
|
)}
|
2026-02-13 23:15:12 -08:00
|
|
|
<div ref={transcriptEndRef} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<footer className="border-t p-3 md:p-4">
|
|
|
|
|
<div className="mx-auto max-w-3xl rounded-xl border bg-background p-2 shadow-sm">
|
|
|
|
|
<Textarea
|
|
|
|
|
rows={3}
|
|
|
|
|
value={composer}
|
|
|
|
|
onInput={(event) => setComposer(event.currentTarget.value)}
|
|
|
|
|
onKeyDown={(event) => {
|
|
|
|
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
void handleSend();
|
|
|
|
|
}
|
|
|
|
|
}}
|
2026-02-13 23:49:55 -08:00
|
|
|
placeholder={isSearchMode ? "Search the web" : "Message Sybil"}
|
2026-02-13 23:15:12 -08:00
|
|
|
className="resize-none border-0 shadow-none focus-visible:ring-0"
|
|
|
|
|
disabled={isSending}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex items-center justify-between px-2 pb-1">
|
2026-02-13 23:49:55 -08:00
|
|
|
{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>
|
|
|
|
|
)}
|
2026-02-13 23:15:12 -08:00
|
|
|
<Button onClick={() => void handleSend()} size="icon" disabled={isSending || !composer.trim()}>
|
2026-02-13 23:49:55 -08:00
|
|
|
{isSearchMode ? <Search className="h-4 w-4" /> : <SendHorizontal className="h-4 w-4" />}
|
2026-02-13 23:15:12 -08:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</footer>
|
|
|
|
|
</main>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|