1219 lines
42 KiB
TypeScript
1219 lines
42 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
|
import { Check, ChevronDown, Globe2, Menu, MessageSquare, Plus, Search, SendHorizontal, Trash2 } from "lucide-preact";
|
|
import { Button } from "@/components/ui/button";
|
|
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,
|
|
deleteChat,
|
|
deleteSearch,
|
|
getChat,
|
|
listModels,
|
|
getSearch,
|
|
listChats,
|
|
listSearches,
|
|
runCompletionStream,
|
|
runSearchStream,
|
|
suggestChatTitle,
|
|
type ModelCatalogResponse,
|
|
type Provider,
|
|
type ChatDetail,
|
|
type ChatSummary,
|
|
type CompletionRequestMessage,
|
|
type Message,
|
|
type SearchDetail,
|
|
type SearchSummary,
|
|
} from "@/lib/api";
|
|
import { useSessionAuth } from "@/hooks/use-session-auth";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type SidebarSelection = { kind: "chat" | "search"; id: string };
|
|
type DraftSelectionKind = "chat" | "search";
|
|
type SidebarItem = SidebarSelection & {
|
|
title: string;
|
|
updatedAt: string;
|
|
createdAt: string;
|
|
initiatedProvider: Provider | null;
|
|
initiatedModel: string | null;
|
|
lastUsedProvider: Provider | null;
|
|
lastUsedModel: string | null;
|
|
};
|
|
type ContextMenuState = {
|
|
item: SidebarSelection;
|
|
x: number;
|
|
y: number;
|
|
};
|
|
|
|
function readSidebarSelectionFromUrl(): SidebarSelection | null {
|
|
if (typeof window === "undefined") return null;
|
|
const params = new URLSearchParams(window.location.search);
|
|
const chatId = params.get("chat")?.trim();
|
|
if (chatId) {
|
|
return { kind: "chat", id: chatId };
|
|
}
|
|
const searchId = params.get("search")?.trim();
|
|
if (searchId) {
|
|
return { kind: "search", id: searchId };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function buildWorkspaceUrl(selection: SidebarSelection | null) {
|
|
if (typeof window === "undefined") return "/";
|
|
const params = new URLSearchParams(window.location.search);
|
|
params.delete("chat");
|
|
params.delete("search");
|
|
if (selection) {
|
|
params.set(selection.kind === "chat" ? "chat" : "search", selection.id);
|
|
}
|
|
const query = params.toString();
|
|
return `${window.location.pathname}${query ? `?${query}` : ""}`;
|
|
}
|
|
|
|
const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
|
|
openai: ["gpt-4.1-mini"],
|
|
anthropic: ["claude-3-5-sonnet-latest"],
|
|
xai: ["grok-3-mini"],
|
|
};
|
|
|
|
const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
|
|
openai: { models: [], loadedAt: null, error: null },
|
|
anthropic: { models: [], loadedAt: null, error: null },
|
|
xai: { models: [], loadedAt: null, error: null },
|
|
};
|
|
|
|
const MODEL_PREFERENCES_STORAGE_KEY = "sybil:modelPreferencesByProvider";
|
|
|
|
type ProviderModelPreferences = Record<Provider, string | null>;
|
|
|
|
const EMPTY_MODEL_PREFERENCES: ProviderModelPreferences = {
|
|
openai: null,
|
|
anthropic: null,
|
|
xai: null,
|
|
};
|
|
|
|
function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: Provider) {
|
|
const providerModels = catalog[provider]?.models ?? [];
|
|
if (providerModels.length) return providerModels;
|
|
return PROVIDER_FALLBACK_MODELS[provider];
|
|
}
|
|
|
|
function loadStoredModelPreferences() {
|
|
if (typeof window === "undefined") return EMPTY_MODEL_PREFERENCES;
|
|
try {
|
|
const raw = window.localStorage.getItem(MODEL_PREFERENCES_STORAGE_KEY);
|
|
if (!raw) return EMPTY_MODEL_PREFERENCES;
|
|
const parsed = JSON.parse(raw) as Partial<Record<Provider, string>>;
|
|
return {
|
|
openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null,
|
|
anthropic: typeof parsed.anthropic === "string" && parsed.anthropic.trim() ? parsed.anthropic.trim() : null,
|
|
xai: typeof parsed.xai === "string" && parsed.xai.trim() ? parsed.xai.trim() : null,
|
|
};
|
|
} catch {
|
|
return EMPTY_MODEL_PREFERENCES;
|
|
}
|
|
}
|
|
|
|
function pickProviderModel(options: string[], preferred: string | null, fallback: string | null = null) {
|
|
if (fallback && options.includes(fallback)) return fallback;
|
|
if (preferred && options.includes(preferred)) return preferred;
|
|
return options[0] ?? "";
|
|
}
|
|
|
|
function getProviderLabel(provider: Provider | null | undefined) {
|
|
if (provider === "openai") return "OpenAI";
|
|
if (provider === "anthropic") return "Anthropic";
|
|
if (provider === "xai") return "xAI";
|
|
return "";
|
|
}
|
|
|
|
function getChatModelSelection(chat: Pick<ChatSummary, "lastUsedProvider" | "lastUsedModel"> | Pick<ChatDetail, "lastUsedProvider" | "lastUsedModel"> | null) {
|
|
if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null;
|
|
return {
|
|
provider: chat.lastUsedProvider,
|
|
model: chat.lastUsedModel.trim(),
|
|
};
|
|
}
|
|
|
|
type ModelComboboxProps = {
|
|
options: string[];
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
disabled?: boolean;
|
|
};
|
|
|
|
function ModelCombobox({ options, value, onChange, disabled = false }: ModelComboboxProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const [query, setQuery] = useState("");
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const filteredOptions = useMemo(() => {
|
|
const needle = query.trim().toLowerCase();
|
|
if (!needle) return options;
|
|
return options.filter((option) => option.toLowerCase().includes(needle));
|
|
}, [options, query]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
inputRef.current?.focus();
|
|
}, [open]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
|
|
const handlePointerDown = (event: PointerEvent) => {
|
|
if (rootRef.current?.contains(event.target as Node)) return;
|
|
setOpen(false);
|
|
setQuery("");
|
|
};
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key !== "Escape") return;
|
|
setOpen(false);
|
|
setQuery("");
|
|
};
|
|
|
|
window.addEventListener("pointerdown", handlePointerDown);
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => {
|
|
window.removeEventListener("pointerdown", handlePointerDown);
|
|
window.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
}, [open]);
|
|
|
|
return (
|
|
<div className="relative" ref={rootRef}>
|
|
<button
|
|
type="button"
|
|
className="flex h-9 min-w-56 items-center justify-between rounded-md border border-input bg-background px-2 text-sm"
|
|
onClick={() => {
|
|
if (disabled) return;
|
|
setOpen((current) => !current);
|
|
}}
|
|
disabled={disabled}
|
|
>
|
|
<span className="truncate text-left">{value || "Select model"}</span>
|
|
<ChevronDown className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
|
</button>
|
|
{open ? (
|
|
<div className="absolute right-0 z-50 mt-1 w-full rounded-md border border-border bg-background p-1 shadow-md">
|
|
<input
|
|
ref={inputRef}
|
|
value={query}
|
|
onInput={(event) => setQuery(event.currentTarget.value)}
|
|
className="mb-1 h-8 w-full rounded-sm border border-input bg-background px-2 text-sm outline-none"
|
|
placeholder="Filter models"
|
|
/>
|
|
<div className="max-h-64 overflow-y-auto">
|
|
{filteredOptions.length ? (
|
|
filteredOptions.map((option) => (
|
|
<button
|
|
key={option}
|
|
type="button"
|
|
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-muted"
|
|
onClick={() => {
|
|
onChange(option);
|
|
setOpen(false);
|
|
setQuery("");
|
|
}}
|
|
>
|
|
<Check className={cn("h-4 w-4", option === value ? "opacity-100" : "opacity-0")} />
|
|
<span className="truncate">{option}</span>
|
|
</button>
|
|
))
|
|
) : (
|
|
<p className="px-2 py-2 text-sm text-muted-foreground">No models found</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
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,
|
|
initiatedProvider: chat.initiatedProvider,
|
|
initiatedModel: chat.initiatedModel,
|
|
lastUsedProvider: chat.lastUsedProvider,
|
|
lastUsedModel: chat.lastUsedModel,
|
|
})),
|
|
...searches.map((search) => ({
|
|
kind: "search" as const,
|
|
id: search.id,
|
|
title: getSearchTitle(search),
|
|
updatedAt: search.updatedAt,
|
|
createdAt: search.createdAt,
|
|
initiatedProvider: null,
|
|
initiatedModel: null,
|
|
lastUsedProvider: null,
|
|
lastUsedModel: null,
|
|
})),
|
|
];
|
|
|
|
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));
|
|
}
|
|
|
|
export default function App() {
|
|
const {
|
|
authTokenInput,
|
|
setAuthTokenInput,
|
|
isCheckingSession,
|
|
isSigningIn,
|
|
isAuthenticated,
|
|
authMode,
|
|
authError,
|
|
handleAuthFailure: baseHandleAuthFailure,
|
|
handleSignIn,
|
|
logout,
|
|
} = useSessionAuth();
|
|
|
|
const [chats, setChats] = useState<ChatSummary[]>([]);
|
|
const [searches, setSearches] = useState<SearchSummary[]>([]);
|
|
const [selectedItem, setSelectedItem] = useState<SidebarSelection | null>(null);
|
|
const [selectedChat, setSelectedChat] = useState<ChatDetail | null>(null);
|
|
const [selectedSearch, setSelectedSearch] = useState<SearchDetail | null>(null);
|
|
const [draftKind, setDraftKind] = useState<DraftSelectionKind | null>(null);
|
|
const [isLoadingCollections, setIsLoadingCollections] = useState(false);
|
|
const [isLoadingSelection, setIsLoadingSelection] = useState(false);
|
|
const [isSending, setIsSending] = useState(false);
|
|
const [pendingChatState, setPendingChatState] = useState<{ chatId: string | null; messages: Message[] } | null>(null);
|
|
const [composer, setComposer] = useState("");
|
|
const [provider, setProvider] = useState<Provider>("openai");
|
|
const [modelCatalog, setModelCatalog] = useState<ModelCatalogResponse["providers"]>(EMPTY_MODEL_CATALOG);
|
|
const [providerModelPreferences, setProviderModelPreferences] = useState<ProviderModelPreferences>(() => loadStoredModelPreferences());
|
|
const [model, setModel] = useState(() => {
|
|
const stored = loadStoredModelPreferences();
|
|
return stored.openai ?? PROVIDER_FALLBACK_MODELS.openai[0];
|
|
});
|
|
const [error, setError] = useState<string | null>(null);
|
|
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
|
const contextMenuRef = useRef<HTMLDivElement>(null);
|
|
const selectedItemRef = useRef<SidebarSelection | null>(null);
|
|
const pendingTitleGenerationRef = useRef<Set<string>>(new Set());
|
|
const searchRunAbortRef = useRef<AbortController | null>(null);
|
|
const searchRunCounterRef = useRef(0);
|
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
|
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
|
const initialRouteSelectionRef = useRef<SidebarSelection | null>(readSidebarSelectionFromUrl());
|
|
const hasSyncedSelectionHistoryRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (typeof document === "undefined") return;
|
|
const textarea = document.getElementById("composer-input") as HTMLTextAreaElement | null;
|
|
if (!textarea) return;
|
|
textarea.style.height = "0px";
|
|
textarea.style.height = `${textarea.scrollHeight}px`;
|
|
}, [composer]);
|
|
|
|
const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]);
|
|
|
|
const resetWorkspaceState = () => {
|
|
setChats([]);
|
|
setSearches([]);
|
|
setSelectedItem(null);
|
|
setSelectedChat(null);
|
|
setSelectedSearch(null);
|
|
setDraftKind(null);
|
|
setPendingChatState(null);
|
|
setComposer("");
|
|
setError(null);
|
|
};
|
|
|
|
const handleAuthFailure = (message: string) => {
|
|
baseHandleAuthFailure(message);
|
|
resetWorkspaceState();
|
|
};
|
|
|
|
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 refreshModels = async () => {
|
|
try {
|
|
const data = await listModels();
|
|
setModelCatalog(data.providers);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
if (message.includes("bearer token")) {
|
|
handleAuthFailure(message);
|
|
} else {
|
|
setError(message);
|
|
}
|
|
}
|
|
};
|
|
|
|
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(() => {
|
|
if (!isAuthenticated) return;
|
|
const preferredSelection = initialRouteSelectionRef.current;
|
|
initialRouteSelectionRef.current = null;
|
|
void Promise.all([refreshCollections(preferredSelection ?? undefined), refreshModels()]);
|
|
}, [isAuthenticated]);
|
|
|
|
useEffect(() => {
|
|
const onPopState = () => {
|
|
setContextMenu(null);
|
|
setDraftKind(null);
|
|
setSelectedItem(readSidebarSelectionFromUrl());
|
|
setIsMobileSidebarOpen(false);
|
|
};
|
|
window.addEventListener("popstate", onPopState);
|
|
return () => window.removeEventListener("popstate", onPopState);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isAuthenticated) {
|
|
hasSyncedSelectionHistoryRef.current = false;
|
|
return;
|
|
}
|
|
const current = `${window.location.pathname}${window.location.search}`;
|
|
const next = buildWorkspaceUrl(selectedItem);
|
|
|
|
if (!hasSyncedSelectionHistoryRef.current) {
|
|
hasSyncedSelectionHistoryRef.current = true;
|
|
if (current !== next) {
|
|
window.history.replaceState({}, "", next);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (current !== next) {
|
|
window.history.pushState({}, "", next);
|
|
}
|
|
}, [isAuthenticated, selectedItem]);
|
|
|
|
const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]);
|
|
|
|
useEffect(() => {
|
|
setModel((current) => {
|
|
return pickProviderModel(providerModelOptions, providerModelPreferences[provider], current);
|
|
});
|
|
}, [provider, providerModelOptions, providerModelPreferences]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return;
|
|
window.localStorage.setItem(MODEL_PREFERENCES_STORAGE_KEY, JSON.stringify(providerModelPreferences));
|
|
}, [providerModelPreferences]);
|
|
|
|
const selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null;
|
|
|
|
useEffect(() => {
|
|
selectedItemRef.current = selectedItem;
|
|
}, [selectedItem]);
|
|
|
|
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(() => {
|
|
if (draftKind === "search" || selectedItem?.kind === "search") return;
|
|
transcriptEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
|
}, [draftKind, selectedChat?.messages.length, isSending, selectedItem?.kind]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
searchRunAbortRef.current?.abort();
|
|
searchRunAbortRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
const messages = selectedChat?.messages ?? [];
|
|
const isSearchMode = draftKind ? draftKind === "search" : selectedItem?.kind === "search";
|
|
const isSearchRunning = isSending && isSearchMode;
|
|
const isSendingActiveChat =
|
|
isSending &&
|
|
!isSearchMode &&
|
|
!!pendingChatState &&
|
|
!!pendingChatState.chatId &&
|
|
selectedItem?.kind === "chat" &&
|
|
selectedItem.id === pendingChatState.chatId;
|
|
const displayMessages = useMemo(() => {
|
|
if (!pendingChatState) return messages;
|
|
if (pendingChatState.chatId) {
|
|
if (selectedItem?.kind === "chat" && selectedItem.id === pendingChatState.chatId) {
|
|
return pendingChatState.messages;
|
|
}
|
|
return messages;
|
|
}
|
|
return isSearchMode ? messages : pendingChatState.messages;
|
|
}, [isSearchMode, messages, pendingChatState, selectedItem]);
|
|
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
if (draftKind || selectedItem?.kind !== "chat") return;
|
|
const detailSelection = selectedChat?.id === selectedItem.id ? getChatModelSelection(selectedChat) : null;
|
|
const summarySelection = getChatModelSelection(selectedChatSummary);
|
|
const nextSelection = detailSelection ?? summarySelection;
|
|
if (!nextSelection) return;
|
|
setProvider(nextSelection.provider);
|
|
setModel(nextSelection.model);
|
|
}, [draftKind, selectedChat, selectedChatSummary, selectedItem]);
|
|
|
|
const selectedTitle = useMemo(() => {
|
|
if (draftKind === "chat") return "New chat";
|
|
if (draftKind === "search") return "New search";
|
|
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";
|
|
}, [draftKind, selectedChat, selectedChatSummary, selectedItem, selectedSearch, selectedSearchSummary]);
|
|
|
|
const pageTitle = useMemo(() => {
|
|
if (draftKind || !selectedItem) return "Sybil";
|
|
if (selectedItem.kind === "chat") {
|
|
if (selectedChat) return `${getChatTitle(selectedChat, selectedChat.messages)} — Sybil`;
|
|
if (selectedChatSummary) return `${getChatTitle(selectedChatSummary)} — Sybil`;
|
|
return "Sybil";
|
|
}
|
|
const searchQuery = selectedSearch?.query?.trim() || selectedSearchSummary?.query?.trim();
|
|
if (searchQuery) return `${searchQuery} — Sybil`;
|
|
if (selectedSearch) return `${getSearchTitle(selectedSearch)} — Sybil`;
|
|
if (selectedSearchSummary) return `${getSearchTitle(selectedSearchSummary)} — Sybil`;
|
|
return "Sybil";
|
|
}, [draftKind, selectedChat, selectedChatSummary, selectedItem, selectedSearch, selectedSearchSummary]);
|
|
|
|
useEffect(() => {
|
|
document.title = pageTitle;
|
|
}, [pageTitle]);
|
|
|
|
const handleCreateChat = () => {
|
|
setError(null);
|
|
setContextMenu(null);
|
|
setDraftKind("chat");
|
|
setSelectedItem(null);
|
|
setSelectedChat(null);
|
|
setSelectedSearch(null);
|
|
setIsMobileSidebarOpen(false);
|
|
};
|
|
|
|
const handleCreateSearch = () => {
|
|
setError(null);
|
|
setContextMenu(null);
|
|
setDraftKind("search");
|
|
setSelectedItem(null);
|
|
setSelectedChat(null);
|
|
setSelectedSearch(null);
|
|
setIsMobileSidebarOpen(false);
|
|
};
|
|
|
|
const openContextMenu = (event: MouseEvent, item: SidebarSelection) => {
|
|
event.preventDefault();
|
|
const menuWidth = 160;
|
|
const menuHeight = 40;
|
|
const padding = 8;
|
|
const x = Math.min(event.clientX, window.innerWidth - menuWidth - padding);
|
|
const y = Math.min(event.clientY, window.innerHeight - menuHeight - padding);
|
|
setContextMenu({ item, x: Math.max(padding, x), y: Math.max(padding, y) });
|
|
};
|
|
|
|
const handleDeleteFromContextMenu = async () => {
|
|
if (!contextMenu || isSending) return;
|
|
const target = contextMenu.item;
|
|
setContextMenu(null);
|
|
setError(null);
|
|
try {
|
|
if (target.kind === "chat") {
|
|
await deleteChat(target.id);
|
|
} else {
|
|
await deleteSearch(target.id);
|
|
}
|
|
await refreshCollections();
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
if (message.includes("bearer token")) {
|
|
handleAuthFailure(message);
|
|
} else {
|
|
setError(message);
|
|
}
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!contextMenu) return;
|
|
const handlePointerDown = (event: PointerEvent) => {
|
|
if (contextMenuRef.current?.contains(event.target as Node)) return;
|
|
setContextMenu(null);
|
|
};
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") setContextMenu(null);
|
|
};
|
|
window.addEventListener("pointerdown", handlePointerDown);
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => {
|
|
window.removeEventListener("pointerdown", handlePointerDown);
|
|
window.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
}, [contextMenu]);
|
|
|
|
const handleSendChat = async (content: string) => {
|
|
const optimisticUserMessage: Message = {
|
|
id: `temp-user-${Date.now()}`,
|
|
createdAt: new Date().toISOString(),
|
|
role: "user",
|
|
content,
|
|
name: null,
|
|
};
|
|
|
|
const optimisticAssistantMessage: Message = {
|
|
id: `temp-assistant-${Date.now()}`,
|
|
createdAt: new Date().toISOString(),
|
|
role: "assistant",
|
|
content: "",
|
|
name: null,
|
|
};
|
|
|
|
setPendingChatState({
|
|
chatId: selectedItem?.kind === "chat" ? selectedItem.id : null,
|
|
messages: (selectedChat?.messages ?? []).concat(optimisticUserMessage, optimisticAssistantMessage),
|
|
});
|
|
|
|
let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null;
|
|
|
|
if (!chatId) {
|
|
const chat = await createChat();
|
|
chatId = chat.id;
|
|
setDraftKind(null);
|
|
setChats((current) => {
|
|
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
|
return [chat, ...withoutExisting];
|
|
});
|
|
setSelectedItem({ kind: "chat", id: chatId });
|
|
setPendingChatState((current) => (current ? { ...current, chatId } : current));
|
|
setSelectedChat({
|
|
id: chat.id,
|
|
title: chat.title,
|
|
createdAt: chat.createdAt,
|
|
updatedAt: chat.updatedAt,
|
|
initiatedProvider: chat.initiatedProvider,
|
|
initiatedModel: chat.initiatedModel,
|
|
lastUsedProvider: chat.lastUsedProvider,
|
|
lastUsedModel: chat.lastUsedModel,
|
|
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 requestMessages: CompletionRequestMessage[] = [
|
|
...baseChat.messages.map((message) => ({
|
|
role: message.role,
|
|
content: message.content,
|
|
...(message.name ? { name: message.name } : {}),
|
|
})),
|
|
{
|
|
role: "user",
|
|
content,
|
|
},
|
|
];
|
|
|
|
const selectedModel = model.trim();
|
|
if (!selectedModel) {
|
|
throw new Error("No model available for selected provider");
|
|
}
|
|
|
|
const chatSummary = chats.find((chat) => chat.id === chatId);
|
|
const hasExistingTitle = Boolean(selectedChat?.id === chatId ? selectedChat.title?.trim() : chatSummary?.title?.trim());
|
|
if (!hasExistingTitle && !pendingTitleGenerationRef.current.has(chatId)) {
|
|
pendingTitleGenerationRef.current.add(chatId);
|
|
void suggestChatTitle({ chatId, content })
|
|
.then((updatedChat) => {
|
|
setChats((current) =>
|
|
current.map((chat) => {
|
|
if (chat.id !== updatedChat.id) return chat;
|
|
return { ...chat, title: updatedChat.title, updatedAt: updatedChat.updatedAt };
|
|
})
|
|
);
|
|
setSelectedChat((current) => {
|
|
if (!current || current.id !== updatedChat.id) return current;
|
|
return { ...current, title: updatedChat.title, updatedAt: updatedChat.updatedAt };
|
|
});
|
|
})
|
|
.catch(() => {
|
|
// ignore title suggestion errors so chat flow is not interrupted
|
|
})
|
|
.finally(() => {
|
|
pendingTitleGenerationRef.current.delete(chatId);
|
|
});
|
|
}
|
|
|
|
let streamErrorMessage: string | null = null;
|
|
|
|
await runCompletionStream(
|
|
{
|
|
chatId,
|
|
provider,
|
|
model: selectedModel,
|
|
messages: requestMessages,
|
|
},
|
|
{
|
|
onMeta: (payload) => {
|
|
if (payload.chatId !== chatId) return;
|
|
setPendingChatState((current) => (current ? { ...current, chatId: payload.chatId } : current));
|
|
},
|
|
onDelta: (payload) => {
|
|
if (!payload.text) return;
|
|
setPendingChatState((current) => {
|
|
if (!current) return current;
|
|
let updated = false;
|
|
const nextMessages = current.messages.map((message, index, all) => {
|
|
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-");
|
|
if (!isTarget) return message;
|
|
updated = true;
|
|
return { ...message, content: message.content + payload.text };
|
|
});
|
|
return updated ? { ...current, messages: nextMessages } : current;
|
|
});
|
|
},
|
|
onDone: (payload) => {
|
|
setPendingChatState((current) => {
|
|
if (!current) return current;
|
|
let updated = false;
|
|
const nextMessages = current.messages.map((message, index, all) => {
|
|
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-");
|
|
if (!isTarget) return message;
|
|
updated = true;
|
|
return { ...message, content: payload.text };
|
|
});
|
|
return updated ? { ...current, messages: nextMessages } : current;
|
|
});
|
|
},
|
|
onError: (payload) => {
|
|
streamErrorMessage = payload.message;
|
|
},
|
|
}
|
|
);
|
|
|
|
if (streamErrorMessage) {
|
|
throw new Error(streamErrorMessage);
|
|
}
|
|
|
|
await refreshCollections();
|
|
const currentSelection = selectedItemRef.current;
|
|
if (currentSelection?.kind === "chat" && currentSelection.id === chatId) {
|
|
await refreshChat(chatId);
|
|
}
|
|
setPendingChatState(null);
|
|
};
|
|
|
|
const handleSendSearch = async (query: string) => {
|
|
const runId = ++searchRunCounterRef.current;
|
|
searchRunAbortRef.current?.abort();
|
|
const abortController = new AbortController();
|
|
searchRunAbortRef.current = abortController;
|
|
|
|
let searchId = draftKind === "search" ? null : selectedItem?.kind === "search" ? selectedItem.id : null;
|
|
|
|
if (!searchId) {
|
|
const search = await createSearch({
|
|
query,
|
|
title: query.slice(0, 80),
|
|
});
|
|
searchId = search.id;
|
|
setDraftKind(null);
|
|
setSelectedItem({ kind: "search", id: searchId });
|
|
}
|
|
|
|
if (!searchId) {
|
|
throw new Error("Unable to initialize search");
|
|
}
|
|
|
|
const nowIso = new Date().toISOString();
|
|
setSelectedSearch((current) => {
|
|
if (!current || current.id !== searchId) {
|
|
return {
|
|
id: searchId,
|
|
title: query.slice(0, 80),
|
|
query,
|
|
createdAt: nowIso,
|
|
updatedAt: nowIso,
|
|
requestId: null,
|
|
latencyMs: null,
|
|
error: null,
|
|
answerText: null,
|
|
answerRequestId: null,
|
|
answerCitations: null,
|
|
answerError: null,
|
|
results: [],
|
|
};
|
|
}
|
|
return {
|
|
...current,
|
|
title: query.slice(0, 80),
|
|
query,
|
|
error: null,
|
|
latencyMs: null,
|
|
answerText: null,
|
|
answerRequestId: null,
|
|
answerCitations: null,
|
|
answerError: null,
|
|
results: [],
|
|
};
|
|
});
|
|
|
|
try {
|
|
await runSearchStream(
|
|
searchId,
|
|
{
|
|
query,
|
|
title: query.slice(0, 80),
|
|
type: "auto",
|
|
numResults: 10,
|
|
},
|
|
{
|
|
onSearchResults: (payload) => {
|
|
if (runId !== searchRunCounterRef.current) return;
|
|
setSelectedSearch((current) => {
|
|
if (!current || current.id !== searchId) return current;
|
|
return {
|
|
...current,
|
|
requestId: payload.requestId ?? current.requestId,
|
|
error: null,
|
|
results: payload.results,
|
|
};
|
|
});
|
|
},
|
|
onSearchError: (payload) => {
|
|
if (runId !== searchRunCounterRef.current) return;
|
|
setSelectedSearch((current) => {
|
|
if (!current || current.id !== searchId) return current;
|
|
return { ...current, error: payload.error };
|
|
});
|
|
},
|
|
onAnswer: (payload) => {
|
|
if (runId !== searchRunCounterRef.current) return;
|
|
setSelectedSearch((current) => {
|
|
if (!current || current.id !== searchId) return current;
|
|
return {
|
|
...current,
|
|
answerText: payload.answerText,
|
|
answerRequestId: payload.answerRequestId,
|
|
answerCitations: payload.answerCitations,
|
|
answerError: null,
|
|
};
|
|
});
|
|
},
|
|
onAnswerError: (payload) => {
|
|
if (runId !== searchRunCounterRef.current) return;
|
|
setSelectedSearch((current) => {
|
|
if (!current || current.id !== searchId) return current;
|
|
return { ...current, answerError: payload.error };
|
|
});
|
|
},
|
|
onDone: (payload) => {
|
|
if (runId !== searchRunCounterRef.current) return;
|
|
setSelectedSearch(payload.search);
|
|
setSelectedChat(null);
|
|
},
|
|
onError: (payload) => {
|
|
if (runId !== searchRunCounterRef.current) return;
|
|
setError(payload.message);
|
|
},
|
|
},
|
|
{ signal: abortController.signal }
|
|
);
|
|
} catch (err) {
|
|
if (abortController.signal.aborted) return;
|
|
throw err;
|
|
} finally {
|
|
if (runId === searchRunCounterRef.current) {
|
|
searchRunAbortRef.current = 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 (!isSearchMode) {
|
|
setPendingChatState(null);
|
|
}
|
|
|
|
if (selectedItem?.kind === "chat") {
|
|
await refreshChat(selectedItem.id);
|
|
}
|
|
if (selectedItem?.kind === "search") {
|
|
await refreshSearch(selectedItem.id);
|
|
}
|
|
} finally {
|
|
setIsSending(false);
|
|
}
|
|
};
|
|
|
|
|
|
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">
|
|
<div className="flex h-full w-full overflow-hidden bg-background">
|
|
{isMobileSidebarOpen ? (
|
|
<button
|
|
type="button"
|
|
className="fixed inset-0 z-30 bg-black/45 md:hidden"
|
|
onClick={() => setIsMobileSidebarOpen(false)}
|
|
aria-label="Close sidebar"
|
|
/>
|
|
) : null}
|
|
|
|
<aside
|
|
className={cn(
|
|
"fixed inset-y-0 left-0 z-40 flex w-[85vw] max-w-80 shrink-0 flex-col border-r bg-[hsl(272_34%_14%)] transition-transform md:static md:z-auto md:w-80 md:max-w-none",
|
|
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
|
)}
|
|
>
|
|
<div className="grid grid-cols-2 gap-2 p-3">
|
|
<Button className="justify-start gap-2" onClick={handleCreateChat}>
|
|
<Plus className="h-4 w-4" />
|
|
New chat
|
|
</Button>
|
|
<Button className="justify-start gap-2" variant="secondary" onClick={handleCreateSearch}>
|
|
<Search className="h-4 w-4" />
|
|
New search
|
|
</Button>
|
|
</div>
|
|
<Separator />
|
|
<div className="flex-1 overflow-y-auto p-2">
|
|
{isLoadingCollections && sidebarItems.length === 0 ? <p className="px-2 py-3 text-sm text-muted-foreground">Loading conversations...</p> : null}
|
|
{!isLoadingCollections && sidebarItems.length === 0 ? (
|
|
<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" />
|
|
Start a chat or run your first search.
|
|
</div>
|
|
) : null}
|
|
{sidebarItems.map((item) => {
|
|
const active = selectedItem?.kind === item.kind && selectedItem.id === item.id;
|
|
const initiatedLabel = item.kind === "chat" && item.initiatedModel
|
|
? `${getProviderLabel(item.initiatedProvider)}${item.initiatedProvider ? " · " : ""}${item.initiatedModel}`
|
|
: null;
|
|
return (
|
|
<button
|
|
key={`${item.kind}-${item.id}`}
|
|
className={cn(
|
|
"mb-1 w-full rounded-lg px-3 py-2 text-left transition",
|
|
active ? "bg-violet-500/30 text-violet-100" : "text-violet-200/85 hover:bg-violet-500/15"
|
|
)}
|
|
onClick={() => {
|
|
setContextMenu(null);
|
|
setDraftKind(null);
|
|
setSelectedItem({ kind: item.kind, id: item.id });
|
|
setIsMobileSidebarOpen(false);
|
|
}}
|
|
onContextMenu={(event) => openContextMenu(event, { kind: item.kind, id: item.id })}
|
|
type="button"
|
|
>
|
|
<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>
|
|
<div className="mt-1 flex items-center gap-2 text-xs">
|
|
<p className={cn("shrink-0", active ? "text-violet-100/90" : "text-violet-300/60")}>{formatDate(item.updatedAt)}</p>
|
|
{initiatedLabel ? (
|
|
<p className={cn("ml-auto truncate text-right", active ? "text-violet-200/65" : "text-violet-300/45")}>{initiatedLabel}</p>
|
|
) : null}
|
|
</div>
|
|
</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 className="flex items-start gap-2">
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
variant="ghost"
|
|
className="-ml-1 h-8 w-8 md:hidden"
|
|
onClick={() => setIsMobileSidebarOpen(true)}
|
|
aria-label="Open sidebar"
|
|
>
|
|
<Menu className="h-4 w-4" />
|
|
</Button>
|
|
|
|
<div>
|
|
<h1 className="text-sm font-semibold md:text-base">{selectedTitle}</h1>
|
|
<p className="text-xs text-muted-foreground">
|
|
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
|
|
{isSearchMode ? " • Exa Search" : ""}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex w-full max-w-xl items-center gap-2 md:w-auto">
|
|
{!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);
|
|
const options = getModelOptions(modelCatalog, nextProvider);
|
|
setModel((current) => pickProviderModel(options, providerModelPreferences[nextProvider], current));
|
|
}}
|
|
disabled={isSending}
|
|
>
|
|
<option value="openai">OpenAI</option>
|
|
<option value="anthropic">Anthropic</option>
|
|
<option value="xai">xAI</option>
|
|
</select>
|
|
<ModelCombobox
|
|
options={providerModelOptions}
|
|
value={model}
|
|
disabled={isSending || providerModelOptions.length === 0}
|
|
onChange={(nextModel) => {
|
|
setModel(nextModel);
|
|
setProviderModelPreferences((current) => ({
|
|
...current,
|
|
[provider]: nextModel,
|
|
}));
|
|
}}
|
|
/>
|
|
</>
|
|
) : (
|
|
<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>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
<div className="flex-1 overflow-y-auto px-3 py-6 md:px-10">
|
|
{!isSearchMode ? (
|
|
<ChatMessagesPanel messages={displayMessages} isLoading={isLoadingSelection} isSending={isSendingActiveChat} />
|
|
) : (
|
|
<SearchResultsPanel search={selectedSearch} isLoading={isLoadingSelection} isRunning={isSearchRunning} />
|
|
)}
|
|
<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
|
|
id="composer-input"
|
|
rows={1}
|
|
value={composer}
|
|
onInput={(event) => {
|
|
const textarea = event.currentTarget;
|
|
textarea.style.height = "0px";
|
|
textarea.style.height = `${textarea.scrollHeight}px`;
|
|
setComposer(textarea.value);
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
event.preventDefault();
|
|
void handleSend();
|
|
}
|
|
}}
|
|
placeholder={isSearchMode ? "Search the web" : "Message Sybil"}
|
|
className="max-h-40 min-h-0 resize-none overflow-y-auto border-0 shadow-none focus-visible:ring-0"
|
|
disabled={isSending}
|
|
/>
|
|
<div className={cn("flex items-center px-2 pb-1", error ? "justify-between" : "justify-end")}>
|
|
{error ? <p className="text-xs text-red-600">{error}</p> : null}
|
|
<Button onClick={() => void handleSend()} size="icon" disabled={isSending || !composer.trim()}>
|
|
{isSearchMode ? <Search className="h-4 w-4" /> : <SendHorizontal className="h-4 w-4" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</footer>
|
|
</main>
|
|
</div>
|
|
{contextMenu ? (
|
|
<div
|
|
ref={contextMenuRef}
|
|
className="fixed z-50 min-w-40 rounded-md border border-border bg-background p-1 shadow-md"
|
|
style={{ left: contextMenu.x, top: contextMenu.y }}
|
|
onContextMenu={(event) => event.preventDefault()}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm text-red-600 transition hover:bg-muted disabled:text-muted-foreground"
|
|
onClick={() => void handleDeleteFromContextMenu()}
|
|
disabled={isSending}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
Delete
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|