model catalog

This commit is contained in:
2026-02-14 21:00:30 -08:00
parent 9bfd0ec1e7
commit cd449a30fa
5 changed files with 327 additions and 13 deletions

View File

@@ -1,7 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { Globe2, LogOut, MessageSquare, Plus, Search, SendHorizontal, Trash2 } from "lucide-preact";
import { Check, ChevronDown, Globe2, LogOut, MessageSquare, Plus, Search, SendHorizontal, Trash2 } from "lucide-preact";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import { AuthScreen } from "@/components/auth/auth-screen";
@@ -13,11 +12,14 @@ import {
deleteChat,
deleteSearch,
getChat,
listModels,
getSearch,
listChats,
listSearches,
runCompletion,
runSearchStream,
type ModelCatalogResponse,
type Provider,
type ChatDetail,
type ChatSummary,
type CompletionRequestMessage,
@@ -28,7 +30,6 @@ import {
import { useSessionAuth } from "@/hooks/use-session-auth";
import { cn } from "@/lib/utils";
type Provider = "openai" | "anthropic" | "xai";
type SidebarSelection = { kind: "chat" | "search"; id: string };
type DraftSelectionKind = "chat" | "search";
type SidebarItem = SidebarSelection & {
@@ -42,12 +43,153 @@ type ContextMenuState = {
y: number;
};
const PROVIDER_DEFAULT_MODELS: Record<Provider, string> = {
openai: "gpt-4.1-mini",
anthropic: "claude-3-5-sonnet-latest",
xai: "grok-3-mini",
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 (preferred && options.includes(preferred)) return preferred;
if (fallback && options.includes(fallback)) return fallback;
return options[0] ?? "";
}
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();
@@ -117,7 +259,12 @@ export default function App() {
const [pendingChatState, setPendingChatState] = useState<{ chatId: string | null; messages: Message[] } | null>(null);
const [composer, setComposer] = useState("");
const [provider, setProvider] = useState<Provider>("openai");
const [model, setModel] = useState(PROVIDER_DEFAULT_MODELS.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);
@@ -179,6 +326,20 @@ export default function App() {
}
};
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 {
@@ -217,9 +378,22 @@ export default function App() {
useEffect(() => {
if (!isAuthenticated) return;
void refreshCollections();
void Promise.all([refreshCollections(), refreshModels()]);
}, [isAuthenticated]);
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(() => {
@@ -419,10 +593,15 @@ export default function App() {
},
];
const selectedModel = model.trim();
if (!selectedModel) {
throw new Error("No model available for selected provider");
}
await runCompletion({
chatId,
provider,
model: model.trim(),
model: selectedModel,
messages: requestMessages,
});
@@ -690,7 +869,8 @@ export default function App() {
onChange={(event) => {
const nextProvider = event.currentTarget.value as Provider;
setProvider(nextProvider);
setModel(PROVIDER_DEFAULT_MODELS[nextProvider]);
const options = getModelOptions(modelCatalog, nextProvider);
setModel((current) => pickProviderModel(options, providerModelPreferences[nextProvider], current));
}}
disabled={isSending}
>
@@ -698,7 +878,18 @@ export default function App() {
<option value="anthropic">Anthropic</option>
<option value="xai">xAI</option>
</select>
<Input value={model} onInput={(event) => setModel(event.currentTarget.value)} placeholder="Model" disabled={isSending} />
<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">