diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index 9f834dd9d..63be54654 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -836,6 +836,13 @@ class Config: # LLM instances are now managed per-user through the LLMConfig system # Legacy environment variables removed in favor of user-specific configurations + # True when an operator-provided global_llm_config.yaml is present. + # Used to gate the per-search-space LLM onboarding flow: when a global + # config file exists, search spaces inherit it and onboarding is skipped. + GLOBAL_LLM_CONFIG_FILE_EXISTS = ( + BASE_DIR / "app" / "config" / "global_llm_config.yaml" + ).exists() + # Global LLM Configurations (optional) # Load from global_llm_config.yaml if available # These can be used as default options for users diff --git a/surfsense_backend/app/routes/model_connections_routes.py b/surfsense_backend/app/routes/model_connections_routes.py index 9263b872c..4d32a32af 100644 --- a/surfsense_backend/app/routes/model_connections_routes.py +++ b/surfsense_backend/app/routes/model_connections_routes.py @@ -317,6 +317,12 @@ async def _assert_connection_access( ) +@router.get("/global-llm-config-status") +async def global_llm_config_status(user: User = Depends(current_active_user)): + del user + return {"exists": config.GLOBAL_LLM_CONFIG_FILE_EXISTS} + + @router.get("/global-model-connections", response_model=list[ConnectionRead]) async def list_global_connections(user: User = Depends(current_active_user)): del user diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index 96306afe4..e7de9e53f 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -8,6 +8,7 @@ import { useEffect, useState } from "react"; import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom"; import { myAccessAtom } from "@/atoms/members/members-query.atoms"; import { + globalLlmConfigStatusAtom, globalModelConnectionsAtom, modelConnectionsAtom, modelRolesAtom, @@ -43,6 +44,9 @@ export function DashboardClientLayout({ ); const { data: modelConnections = [], isLoading: modelConnectionsLoading } = useAtomValue(modelConnectionsAtom); + const { data: globalConfigStatus, isLoading: globalConfigStatusLoading } = useAtomValue( + globalLlmConfigStatusAtom + ); const { data: access = null, isLoading: accessLoading } = useAtomValue(myAccessAtom); const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false); @@ -67,9 +71,18 @@ export function DashboardClientLayout({ !loading && !accessLoading && !globalConfigsLoading && + !globalConfigStatusLoading && !modelConnectionsLoading && !hasCheckedOnboarding ) { + // Onboarding is only relevant when no operator-provided + // global_llm_config.yaml exists. When it does, search spaces inherit + // the global config and should never be forced into onboarding. + if (globalConfigStatus?.exists) { + setHasCheckedOnboarding(true); + return; + } + const onboardingComplete = isLlmOnboardingComplete( modelRoles.chat_model_id, globalConnections, @@ -94,6 +107,8 @@ export function DashboardClientLayout({ loading, accessLoading, globalConfigsLoading, + globalConfigStatusLoading, + globalConfigStatus, modelConnectionsLoading, modelRoles.chat_model_id, globalConnections, @@ -159,6 +174,7 @@ export function DashboardClientLayout({ loading || accessLoading || globalConfigsLoading || + globalConfigStatusLoading || modelConnectionsLoading) && !isOnboardingPage; diff --git a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx index 02d36811f..f4627023d 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx @@ -4,6 +4,7 @@ import { useAtomValue } from "jotai"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useMemo } from "react"; import { + globalLlmConfigStatusAtom, globalModelConnectionsAtom, modelConnectionsAtom, modelRolesAtom, @@ -24,6 +25,9 @@ export default function OnboardPage() { ); const { data: connections = [] } = useAtomValue(modelConnectionsAtom); const { data: roles = {}, isLoading: rolesLoading } = useAtomValue(modelRolesAtom); + const { data: globalConfigStatus, isLoading: globalConfigStatusLoading } = useAtomValue( + globalLlmConfigStatusAtom + ); useEffect(() => { if (!getBearerToken()) redirectToLogin(); @@ -40,10 +44,22 @@ export default function OnboardPage() { connections ); - const isLoading = globalLoading || rolesLoading; - useGlobalLoadingEffect(isLoading); + const isLoading = globalLoading || rolesLoading || globalConfigStatusLoading; - if (isLoading) return null; + // Onboarding only applies when no global_llm_config.yaml exists. If a global + // config is present (or onboarding is already complete), leave this page. + const shouldLeaveOnboarding = + !isLoading && (Boolean(globalConfigStatus?.exists) || onboardingComplete); + + useEffect(() => { + if (shouldLeaveOnboarding) { + router.replace(`/dashboard/${searchSpaceId}/new-chat`); + } + }, [shouldLeaveOnboarding, router, searchSpaceId]); + + useGlobalLoadingEffect(isLoading || shouldLeaveOnboarding); + + if (isLoading || shouldLeaveOnboarding) return null; return (
diff --git a/surfsense_web/atoms/model-connections/model-connections-query.atoms.ts b/surfsense_web/atoms/model-connections/model-connections-query.atoms.ts index 87f31ce9b..04dad9b21 100644 --- a/surfsense_web/atoms/model-connections/model-connections-query.atoms.ts +++ b/surfsense_web/atoms/model-connections/model-connections-query.atoms.ts @@ -11,6 +11,13 @@ export const globalModelConnectionsAtom = atomWithQuery(() => ({ queryFn: () => modelConnectionsApiService.getGlobalConnections(), })); +export const globalLlmConfigStatusAtom = atomWithQuery(() => ({ + queryKey: cacheKeys.modelConnections.globalConfigStatus(), + enabled: !!getBearerToken(), + staleTime: 60 * 60 * 1000, + queryFn: () => modelConnectionsApiService.getGlobalLlmConfigStatus(), +})); + export const modelProvidersAtom = atomWithQuery(() => ({ queryKey: cacheKeys.modelConnections.providers(), enabled: !!getBearerToken(), diff --git a/surfsense_web/components/layout/ui/dialogs/CreateSearchSpaceDialog.tsx b/surfsense_web/components/layout/ui/dialogs/CreateSearchSpaceDialog.tsx index 6f385b465..009b2c120 100644 --- a/surfsense_web/components/layout/ui/dialogs/CreateSearchSpaceDialog.tsx +++ b/surfsense_web/components/layout/ui/dialogs/CreateSearchSpaceDialog.tsx @@ -67,7 +67,7 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac trackSearchSpaceCreated(result.id, values.name); - router.push(`/dashboard/${result.id}/onboard`); + router.push(`/dashboard/${result.id}/new-chat`); } catch (error) { console.error("Failed to create search space:", error); setIsSubmitting(false); diff --git a/surfsense_web/components/new-chat/chat-header.tsx b/surfsense_web/components/new-chat/chat-header.tsx index 9923d8ebb..9882530d4 100644 --- a/surfsense_web/components/new-chat/chat-header.tsx +++ b/surfsense_web/components/new-chat/chat-header.tsx @@ -1,5 +1,6 @@ "use client"; +import { ImageModelSelector } from "./image-model-selector"; import { ModelSelector } from "./model-selector"; interface ChatHeaderProps { @@ -16,6 +17,7 @@ export function ChatHeader({ searchSpaceId, className, onChatModelSelected }: Ch className={className} onChatModelSelected={onChatModelSelected} /> +
); } diff --git a/surfsense_web/components/new-chat/image-model-selector.tsx b/surfsense_web/components/new-chat/image-model-selector.tsx new file mode 100644 index 000000000..e90a46c09 --- /dev/null +++ b/surfsense_web/components/new-chat/image-model-selector.tsx @@ -0,0 +1,301 @@ +"use client"; + +import { useAtom, useAtomValue } from "jotai"; +import { Check, ChevronDown, ImagePlus, Search, SlidersHorizontal } from "lucide-react"; +import { useRouter } from "next/navigation"; +import type { UIEvent } from "react"; +import { useCallback, useMemo, useState } from "react"; +import { updateModelRolesMutationAtom } from "@/atoms/model-connections/model-connections-mutation.atoms"; +import { + globalModelConnectionsAtom, + modelConnectionsAtom, + modelRolesAtom, +} from "@/atoms/model-connections/model-connections-query.atoms"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Drawer, + DrawerContent, + DrawerHandle, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Spinner } from "@/components/ui/spinner"; +import type { ConnectionRead, ModelRead } from "@/contracts/types/model-connections.types"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { AUTO_PROVIDER_ICON_KEY, getProviderIcon } from "@/lib/provider-icons"; +import { cn } from "@/lib/utils"; +import { providerDisplay } from "../settings/model-connections/provider-metadata"; + +interface ImageModelSelectorProps { + searchSpaceId: number; + className?: string; +} + +type ImageModel = ModelRead & { + connectionId: number; + connectionLabel: string; + connectionScope: string; + provider: string; +}; + +const AUTO_IMAGE_MODEL_ID = 0; + +function connectionLabel(connection: ConnectionRead) { + if (connection.scope === "GLOBAL") return "Global"; + return providerDisplay(connection.provider).name; +} + +function flattenImageModels(connections: ConnectionRead[]) { + return connections.flatMap((connection) => + connection.models + .filter((model) => model.enabled && Boolean(model.supports_image_generation)) + .map((model) => ({ + ...model, + connectionId: connection.id, + connectionLabel: connectionLabel(connection), + connectionScope: connection.scope, + provider: connection.provider, + })) + ); +} + +function isFreeGlobalModel(model: ImageModel) { + return model.connectionScope === "GLOBAL" && model.billing_tier?.toLowerCase() === "free"; +} + +function modelName(model: ImageModel) { + const name = model.display_name || model.model_id; + if (model.connectionScope === "GLOBAL") { + return name.replace(/\s+\(free\)$/i, ""); + } + return name; +} + +function filterImageModels(models: ImageModel[], search: string) { + const normalized = search.trim().toLowerCase(); + if (!normalized) return models; + return models.filter((model) => + [modelName(model), model.model_id, model.connectionLabel] + .join(" ") + .toLowerCase() + .includes(normalized) + ); +} + +function groupedModels(models: ImageModel[]) { + return models.reduce>((groups, model) => { + const key = model.connectionLabel; + if (!groups[key]) groups[key] = []; + groups[key].push(model); + return groups; + }, {}); +} + +export function ImageModelSelector({ searchSpaceId, className }: ImageModelSelectorProps) { + const router = useRouter(); + const isMobile = useIsMobile(); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top"); + const [{ data: globalConnections = [], isLoading: globalLoading }] = useAtom( + globalModelConnectionsAtom + ); + const [{ data: connections = [], isLoading: connectionsLoading }] = useAtom(modelConnectionsAtom); + const [{ data: roles }] = useAtom(modelRolesAtom); + const updateRoles = useAtomValue(updateModelRolesMutationAtom); + + const allImageModels = useMemo( + () => flattenImageModels([...globalConnections, ...connections]), + [globalConnections, connections] + ); + + const visibleImageModels = useMemo( + () => filterImageModels(allImageModels, search), + [allImageModels, search] + ); + const imageModelsById = useMemo( + () => new Map(allImageModels.map((model) => [model.id, model])), + [allImageModels] + ); + const selectedModelId = roles?.image_gen_model_id ?? AUTO_IMAGE_MODEL_ID; + const selected = imageModelsById.get(selectedModelId); + const groups = useMemo(() => groupedModels(visibleImageModels), [visibleImageModels]); + const loading = globalLoading || connectionsLoading; + const hasSearchQuery = search.trim().length > 0; + + function handleOpenChange(nextOpen: boolean) { + if (!nextOpen) setSearch(""); + setOpen(nextOpen); + } + + function selectModel(modelId: number) { + updateRoles.mutate({ image_gen_model_id: modelId }); + setSearch(""); + setOpen(false); + } + + function manageModelConnections() { + setOpen(false); + router.push(`/dashboard/${searchSpaceId}/search-space-settings/models`); + } + + const handleScroll = useCallback((event: UIEvent) => { + const el = event.currentTarget; + const atTop = el.scrollTop <= 2; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; + setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); + }, []); + + // Only surface this control when usable image-generation models exist. + if (!loading && allImageModels.length === 0) { + return null; + } + + const content = ( +
+
+
+ + setSearch(event.target.value)} + placeholder="Search image models" + className="h-8 border-0 bg-transparent pl-6 text-sm shadow-none" + /> +
+
+
+ + {loading ? ( +
+ +
+ ) : Object.keys(groups).length === 0 ? ( +
+ {hasSearchQuery + ? "No matching image models." + : "No enabled image models. Add or enable models in Settings."} +
+ ) : ( + Object.entries(groups).map(([connection, models]) => ( +
+
+ {connection} +
+ {models.map((model) => ( + + ))} +
+ )) + )} +
+
+ +
+
+ ); + + const trigger = ( + + ); + + if (isMobile) { + return ( + + {trigger} + + + + Select Image Model + + {content} + + + ); + } + + return ( + + {trigger} + + {content} + + + ); +} diff --git a/surfsense_web/contracts/types/model-connections.types.ts b/surfsense_web/contracts/types/model-connections.types.ts index 6d713329f..0f0c7591e 100644 --- a/surfsense_web/contracts/types/model-connections.types.ts +++ b/surfsense_web/contracts/types/model-connections.types.ts @@ -107,6 +107,10 @@ export const modelRoles = z.object({ image_gen_model_id: z.number().nullable().optional(), }); +export const globalLlmConfigStatus = z.object({ + exists: z.boolean(), +}); + export const modelProviderRead = z.object({ provider: z.string(), transport: z.string(), @@ -135,5 +139,6 @@ export type ModelCreateRequest = z.infer; export type ModelUpdateRequest = z.infer; export type ModelsBulkUpdateRequest = z.infer; export type ModelRoles = z.infer; +export type GlobalLlmConfigStatus = z.infer; export type VerifyConnectionResponse = z.infer; export type ModelProviderRead = z.infer; diff --git a/surfsense_web/lib/apis/model-connections-api.service.ts b/surfsense_web/lib/apis/model-connections-api.service.ts index 3321f7dd7..c69bcbef2 100644 --- a/surfsense_web/lib/apis/model-connections-api.service.ts +++ b/surfsense_web/lib/apis/model-connections-api.service.ts @@ -6,6 +6,8 @@ import { connectionListResponse, connectionRead, connectionUpdateRequest, + type GlobalLlmConfigStatus, + globalLlmConfigStatus, type ModelCreateRequest, type ModelPreviewRead, type ModelProviderRead, @@ -34,6 +36,10 @@ class ModelConnectionsApiService { return baseApiService.get(`/api/v1/global-model-connections`, connectionListResponse); }; + getGlobalLlmConfigStatus = async (): Promise => { + return baseApiService.get(`/api/v1/global-llm-config-status`, globalLlmConfigStatus); + }; + getModelProviders = async (): Promise => { return baseApiService.get(`/api/v1/model-providers`, modelProviderListResponse); }; diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index 962b1c03f..193f53b3c 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -39,6 +39,7 @@ export const cacheKeys = { modelConnections: { all: (searchSpaceId: number) => ["model-connections", searchSpaceId] as const, global: () => ["model-connections", "global"] as const, + globalConfigStatus: () => ["model-connections", "global-config-status"] as const, providers: () => ["model-connections", "providers"] as const, roles: (searchSpaceId: number) => ["model-roles", searchSpaceId] as const, },