"use client"; import * as React from "react"; import { useApi, api, revalidate } from "@/lib/client/api"; import type { ModelsResponse, PolyModel, ProviderId, UserPreferences, PublicConnection, Folder } from "@/lib/client/types"; export interface AppUser { id: string; email: string; name: string; role?: string | null; emailVerified: boolean; isAdmin: boolean; onboardingCompletedAt: string | null; createdAt: string; } /** Smart-router selection sentinel — stored in `selectedModelKey` when the user picks AUTO. */ export const AUTO_MODEL_KEY = "auto"; interface AppState { user: AppUser; preferences: UserPreferences; updatePreferences: (patch: Partial & { onboardingCompleted?: boolean }) => Promise; models: PolyModel[]; modelsByKey: Map; favorites: Set; recents: string[]; /** User-defined labels per model key ("My research model"). */ labels: Record; connectedProviders: Set; connections: PublicConnection[]; folders: Folder[]; loadingModels: boolean; toggleFavorite: (key: string) => Promise; setLabel: (key: string, label: string | null) => Promise; refreshModels: () => Promise; refreshConnections: () => Promise; refreshFolders: () => Promise; /** Currently selected model key for new chats (persisted in localStorage). `AUTO_MODEL_KEY` = smart router. */ selectedModelKey: string | null; setSelectedModelKey: (key: string | null) => void; /** Active project (workspace) for new chats; null = no project. Persisted. */ activeProjectId: string | null; setActiveProjectId: (id: string | null) => void; sidebarOpen: boolean; setSidebarOpen: (v: boolean) => void; paletteOpen: boolean; setPaletteOpen: (v: boolean) => void; /** Open the global conversation search (⌘K search mode / mobile sheet). */ searchOpen: boolean; setSearchOpen: (v: boolean) => void; } const Ctx = React.createContext(null); export function useApp(): AppState { const v = React.useContext(Ctx); if (!v) throw new Error("useApp outside AppProvider"); return v; } const LS_MODEL = "polyllm:selected-model"; const LS_PROJECT = "polyllm:active-project"; export function AppProvider({ user, preferences: initialPrefs, children }: { user: AppUser; preferences: UserPreferences; children: React.ReactNode }) { const [preferences, setPreferences] = React.useState(initialPrefs); const models = useApi("/api/models"); const connections = useApi<{ connections: PublicConnection[] }>("/api/providers"); const foldersQ = useApi<{ folders: Folder[] }>("/api/folders"); const [selectedModelKey, setSelected] = React.useState(null); const [activeProjectId, setProject] = React.useState(null); const [sidebarOpen, setSidebarOpen] = React.useState(false); const [paletteOpen, setPaletteOpen] = React.useState(false); const [searchOpen, setSearchOpen] = React.useState(false); React.useEffect(() => { const stored = typeof window !== "undefined" ? window.localStorage.getItem(LS_MODEL) : null; // eslint-disable-next-line react-hooks/set-state-in-effect setSelected(stored ?? initialPrefs.defaultModelKey ?? null); setProject(window.localStorage.getItem(LS_PROJECT) || null); }, [initialPrefs.defaultModelKey]); const modelList = React.useMemo(() => models.data?.models ?? [], [models.data]); const modelsByKey = React.useMemo(() => new Map(modelList.map((m) => [m.key, m])), [modelList]); const favorites = React.useMemo(() => new Set(models.data?.favorites ?? []), [models.data]); const labels = React.useMemo(() => models.data?.labels ?? {}, [models.data]); const connectedProviders = React.useMemo(() => new Set(models.data?.connectedProviders ?? []), [models.data]); // Fall back to the first connected model when the stored selection is unusable (AUTO is always valid). React.useEffect(() => { if (!models.data) return; if (selectedModelKey === AUTO_MODEL_KEY) return; if (selectedModelKey && modelsByKey.has(selectedModelKey) && connectedProviders.has(modelsByKey.get(selectedModelKey)!.provider)) return; const first = modelList.find((m) => connectedProviders.has(m.provider) && m.status !== "deprecated"); if (first && first.key !== selectedModelKey) { // eslint-disable-next-line react-hooks/set-state-in-effect setSelected(first.key); } }, [models.data, modelList, modelsByKey, connectedProviders, selectedModelKey]); const setSelectedModelKey = React.useCallback((key: string | null) => { setSelected(key); if (key) window.localStorage.setItem(LS_MODEL, key); else window.localStorage.removeItem(LS_MODEL); }, []); const setActiveProjectId = React.useCallback((id: string | null) => { setProject(id); if (id) window.localStorage.setItem(LS_PROJECT, id); else window.localStorage.removeItem(LS_PROJECT); }, []); const value: AppState = { user, preferences, updatePreferences: async (patch) => { const res = await api<{ preferences: UserPreferences }>("/api/preferences", { method: "PATCH", json: patch }); setPreferences(res.preferences); }, models: modelList, modelsByKey, favorites, recents: models.data?.recents ?? [], labels, connectedProviders, connections: connections.data?.connections ?? [], folders: foldersQ.data?.folders ?? [], loadingModels: models.isLoading, toggleFavorite: async (key) => { await api("/api/models", { method: "POST", json: { action: "toggle-favorite", modelKey: key } }); await models.mutate(); }, setLabel: async (key, label) => { await api("/api/models", { method: "POST", json: { action: "set-label", modelKey: key, label } }); await models.mutate(); }, refreshModels: async () => { await models.mutate(); }, refreshConnections: async () => { await Promise.all([connections.mutate(), models.mutate()]); }, refreshFolders: async () => { await foldersQ.mutate(); }, selectedModelKey, setSelectedModelKey, activeProjectId, setActiveProjectId, sidebarOpen, setSidebarOpen, paletteOpen, setPaletteOpen, searchOpen, setSearchOpen, }; return {children}; } export function invalidateConversations() { return revalidate("/api/conversations"); } export function invalidateProjects() { return revalidate("/api/projects"); }