feat: fix onboarding trigger

- Introduced a new endpoint to check the existence of a global LLM configuration file.
- Updated the frontend to utilize this status, affecting onboarding flow and user experience.
- Added necessary atoms and types for managing global LLM config status in the application state.
- Refactored navigation to ensure proper routing based on the global config status.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-06-17 23:30:56 -07:00
parent 55f91a29d5
commit c9afeb2817
11 changed files with 371 additions and 4 deletions

View file

@ -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

View file

@ -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

View file

@ -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;

View file

@ -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 (
<div className="flex min-h-screen select-none flex-col items-center justify-center bg-main-panel p-4">

View file

@ -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(),

View file

@ -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);

View file

@ -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}
/>
<ImageModelSelector searchSpaceId={searchSpaceId} className={className} />
</div>
);
}

View file

@ -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<Record<string, ImageModel[]>>((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<HTMLDivElement>) => {
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 = (
<div className="flex h-[320px] select-none flex-col overflow-hidden">
<div className="p-2">
<div className="relative">
<Search className="absolute left-0.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search image models"
className="h-8 border-0 bg-transparent pl-6 text-sm shadow-none"
/>
</div>
</div>
<div
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-1.5 py-1.5"
onScroll={handleScroll}
style={{
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
<button
type="button"
className="flex w-full items-center justify-between rounded-md px-3 py-2 text-left transition-colors hover:bg-accent hover:text-accent-foreground"
onClick={() => selectModel(AUTO_IMAGE_MODEL_ID)}
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2 font-medium">
{getProviderIcon(AUTO_PROVIDER_ICON_KEY, { className: "size-4 shrink-0" })}
<span className="truncate">Auto</span>
</div>
</div>
{selectedModelId === AUTO_IMAGE_MODEL_ID ? <Check className="h-4 w-4" /> : null}
</button>
{loading ? (
<div className="flex items-center justify-center py-8">
<Spinner />
</div>
) : Object.keys(groups).length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-muted-foreground">
{hasSearchQuery
? "No matching image models."
: "No enabled image models. Add or enable models in Settings."}
</div>
) : (
Object.entries(groups).map(([connection, models]) => (
<div key={connection} className="mt-3">
<div className="px-2 py-1 text-sm font-semibold text-muted-foreground">
{connection}
</div>
{models.map((model) => (
<button
type="button"
key={model.id}
className="flex w-full items-center justify-between rounded-md px-3 py-2 text-left transition-colors hover:bg-accent hover:text-accent-foreground"
onClick={() => selectModel(model.id)}
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2 font-medium">
{getProviderIcon(model.provider, { className: "size-4 shrink-0" })}
<span className="truncate">{modelName(model)}</span>
</div>
</div>
<div className="ml-3 flex shrink-0 items-center gap-2">
{isFreeGlobalModel(model) ? (
<Badge
variant="secondary"
className="h-5 shrink-0 rounded-sm border-0 bg-popover-foreground/10 px-1.5 text-[11px] text-popover-foreground hover:bg-popover-foreground/10"
>
Free
</Badge>
) : null}
{roles?.image_gen_model_id === model.id ? <Check className="h-4 w-4" /> : null}
</div>
</button>
))}
</div>
))
)}
</div>
<div className="p-2">
<Button
variant="ghost"
className="w-full justify-start rounded-md bg-foreground/5 hover:bg-foreground/10 hover:text-foreground"
onClick={manageModelConnections}
>
<SlidersHorizontal className="h-4 w-4" /> Manage models
</Button>
</div>
</div>
);
const trigger = (
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
"h-8 min-w-0 gap-2 rounded-md px-3 text-muted-foreground transition-colors",
"select-none",
"hover:bg-foreground/10 hover:text-foreground",
"data-[state=open]:bg-foreground/10 data-[state=open]:text-foreground",
className
)}
>
{selected ? (
getProviderIcon(selected.provider, { className: "size-4 shrink-0" })
) : (
<ImagePlus className="size-4 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate text-sm">
{selected ? modelName(selected) : "Auto"}
</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
</Button>
);
if (isMobile) {
return (
<Drawer open={open} onOpenChange={handleOpenChange}>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[85vh]">
<DrawerHandle />
<DrawerHeader>
<DrawerTitle>Select Image Model</DrawerTitle>
</DrawerHeader>
{content}
</DrawerContent>
</Drawer>
);
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
align="start"
className="w-[340px] border border-popover-border bg-popover p-0 text-popover-foreground shadow-md"
>
{content}
</PopoverContent>
</Popover>
);
}

View file

@ -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<typeof modelCreateRequest>;
export type ModelUpdateRequest = z.infer<typeof modelUpdateRequest>;
export type ModelsBulkUpdateRequest = z.infer<typeof modelsBulkUpdateRequest>;
export type ModelRoles = z.infer<typeof modelRoles>;
export type GlobalLlmConfigStatus = z.infer<typeof globalLlmConfigStatus>;
export type VerifyConnectionResponse = z.infer<typeof verifyConnectionResponse>;
export type ModelProviderRead = z.infer<typeof modelProviderRead>;

View file

@ -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<GlobalLlmConfigStatus> => {
return baseApiService.get(`/api/v1/global-llm-config-status`, globalLlmConfigStatus);
};
getModelProviders = async (): Promise<ModelProviderRead[]> => {
return baseApiService.get(`/api/v1/model-providers`, modelProviderListResponse);
};

View file

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