SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
6.4 KB · 166 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import { useApi, api, revalidate } from "@/lib/client/api";4import type { ModelsResponse, PolyModel, ProviderId, UserPreferences, PublicConnection, Folder } from "@/lib/client/types";56export interface AppUser {7  id: string;8  email: string;9  name: string;10  role?: string | null;11  emailVerified: boolean;12  isAdmin: boolean;13  onboardingCompletedAt: string | null;14  createdAt: string;15}1617/** Smart-router selection sentinel — stored in `selectedModelKey` when the user picks AUTO. */18export const AUTO_MODEL_KEY = "auto";1920interface AppState {21  user: AppUser;22  preferences: UserPreferences;23  updatePreferences: (patch: Partial<UserPreferences> & { onboardingCompleted?: boolean }) => Promise<void>;24  models: PolyModel[];25  modelsByKey: Map<string, PolyModel>;26  favorites: Set<string>;27  recents: string[];28  /** User-defined labels per model key ("My research model"). */29  labels: Record<string, string>;30  connectedProviders: Set<ProviderId>;31  connections: PublicConnection[];32  folders: Folder[];33  loadingModels: boolean;34  toggleFavorite: (key: string) => Promise<void>;35  setLabel: (key: string, label: string | null) => Promise<void>;36  refreshModels: () => Promise<void>;37  refreshConnections: () => Promise<void>;38  refreshFolders: () => Promise<void>;39  /** Currently selected model key for new chats (persisted in localStorage). `AUTO_MODEL_KEY` = smart router. */40  selectedModelKey: string | null;41  setSelectedModelKey: (key: string | null) => void;42  /** Active project (workspace) for new chats; null = no project. Persisted. */43  activeProjectId: string | null;44  setActiveProjectId: (id: string | null) => void;45  sidebarOpen: boolean;46  setSidebarOpen: (v: boolean) => void;47  paletteOpen: boolean;48  setPaletteOpen: (v: boolean) => void;49  /** Open the global conversation search (⌘K search mode / mobile sheet). */50  searchOpen: boolean;51  setSearchOpen: (v: boolean) => void;52}5354const Ctx = React.createContext<AppState | null>(null);5556export function useApp(): AppState {57  const v = React.useContext(Ctx);58  if (!v) throw new Error("useApp outside AppProvider");59  return v;60}6162const LS_MODEL = "polyllm:selected-model";63const LS_PROJECT = "polyllm:active-project";6465export function AppProvider({ user, preferences: initialPrefs, children }: { user: AppUser; preferences: UserPreferences; children: React.ReactNode }) {66  const [preferences, setPreferences] = React.useState(initialPrefs);67  const models = useApi<ModelsResponse>("/api/models");68  const connections = useApi<{ connections: PublicConnection[] }>("/api/providers");69  const foldersQ = useApi<{ folders: Folder[] }>("/api/folders");70  const [selectedModelKey, setSelected] = React.useState<string | null>(null);71  const [activeProjectId, setProject] = React.useState<string | null>(null);72  const [sidebarOpen, setSidebarOpen] = React.useState(false);73  const [paletteOpen, setPaletteOpen] = React.useState(false);74  const [searchOpen, setSearchOpen] = React.useState(false);7576  React.useEffect(() => {77    const stored = typeof window !== "undefined" ? window.localStorage.getItem(LS_MODEL) : null;78    // eslint-disable-next-line react-hooks/set-state-in-effect79    setSelected(stored ?? initialPrefs.defaultModelKey ?? null);80    setProject(window.localStorage.getItem(LS_PROJECT) || null);81  }, [initialPrefs.defaultModelKey]);8283  const modelList = React.useMemo(() => models.data?.models ?? [], [models.data]);84  const modelsByKey = React.useMemo(() => new Map(modelList.map((m) => [m.key, m])), [modelList]);85  const favorites = React.useMemo(() => new Set(models.data?.favorites ?? []), [models.data]);86  const labels = React.useMemo(() => models.data?.labels ?? {}, [models.data]);87  const connectedProviders = React.useMemo(() => new Set(models.data?.connectedProviders ?? []), [models.data]);8889  // Fall back to the first connected model when the stored selection is unusable (AUTO is always valid).90  React.useEffect(() => {91    if (!models.data) return;92    if (selectedModelKey === AUTO_MODEL_KEY) return;93    if (selectedModelKey && modelsByKey.has(selectedModelKey) && connectedProviders.has(modelsByKey.get(selectedModelKey)!.provider)) return;94    const first = modelList.find((m) => connectedProviders.has(m.provider) && m.status !== "deprecated");95    if (first && first.key !== selectedModelKey) {96      // eslint-disable-next-line react-hooks/set-state-in-effect97      setSelected(first.key);98    }99  }, [models.data, modelList, modelsByKey, connectedProviders, selectedModelKey]);100101  const setSelectedModelKey = React.useCallback((key: string | null) => {102    setSelected(key);103    if (key) window.localStorage.setItem(LS_MODEL, key);104    else window.localStorage.removeItem(LS_MODEL);105  }, []);106107  const setActiveProjectId = React.useCallback((id: string | null) => {108    setProject(id);109    if (id) window.localStorage.setItem(LS_PROJECT, id);110    else window.localStorage.removeItem(LS_PROJECT);111  }, []);112113  const value: AppState = {114    user,115    preferences,116    updatePreferences: async (patch) => {117      const res = await api<{ preferences: UserPreferences }>("/api/preferences", { method: "PATCH", json: patch });118      setPreferences(res.preferences);119    },120    models: modelList,121    modelsByKey,122    favorites,123    recents: models.data?.recents ?? [],124    labels,125    connectedProviders,126    connections: connections.data?.connections ?? [],127    folders: foldersQ.data?.folders ?? [],128    loadingModels: models.isLoading,129    toggleFavorite: async (key) => {130      await api("/api/models", { method: "POST", json: { action: "toggle-favorite", modelKey: key } });131      await models.mutate();132    },133    setLabel: async (key, label) => {134      await api("/api/models", { method: "POST", json: { action: "set-label", modelKey: key, label } });135      await models.mutate();136    },137    refreshModels: async () => {138      await models.mutate();139    },140    refreshConnections: async () => {141      await Promise.all([connections.mutate(), models.mutate()]);142    },143    refreshFolders: async () => {144      await foldersQ.mutate();145    },146    selectedModelKey,147    setSelectedModelKey,148    activeProjectId,149    setActiveProjectId,150    sidebarOpen,151    setSidebarOpen,152    paletteOpen,153    setPaletteOpen,154    searchOpen,155    setSearchOpen,156  };157  return <Ctx.Provider value={value}>{children}</Ctx.Provider>;158}159160export function invalidateConversations() {161  return revalidate("/api/conversations");162}163export function invalidateProjects() {164  return revalidate("/api/projects");165}166