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,
},