feat(database-migrations): add migration to remove legacy model config tables and remove stale model connection code

This commit is contained in:
Anish Sarkar 2026-06-13 12:45:43 +05:30
parent 50668775f8
commit bd4a04f2e7
93 changed files with 956 additions and 11442 deletions

View file

@ -1,6 +0,0 @@
import { ImageModelManager } from "@/components/settings/image-model-manager";
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
const { search_space_id } = await params;
return <ImageModelManager searchSpaceId={Number(search_space_id)} />;
}

View file

@ -1,6 +0,0 @@
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
const { search_space_id } = await params;
return <LLMRoleManager key={search_space_id} searchSpaceId={Number(search_space_id)} />;
}

View file

@ -1,6 +0,0 @@
import { VisionModelManager } from "@/components/settings/vision-model-manager";
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
const { search_space_id } = await params;
return <VisionModelManager searchSpaceId={Number(search_space_id)} />;
}

View file

@ -1,96 +0,0 @@
import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type {
CreateImageGenConfigRequest,
CreateImageGenConfigResponse,
DeleteImageGenConfigResponse,
GetImageGenConfigsResponse,
UpdateImageGenConfigRequest,
UpdateImageGenConfigResponse,
} from "@/contracts/types/new-llm-config.types";
import { imageGenConfigApiService } from "@/lib/apis/image-gen-config-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
/**
* Mutation atom for creating a new ImageGenerationConfig
*/
export const createImageGenConfigMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
mutationKey: ["image-gen-configs", "create"],
meta: { suppressGlobalErrorToast: true },
enabled: !!searchSpaceId,
mutationFn: async (request: CreateImageGenConfigRequest) => {
return imageGenConfigApiService.createConfig(request);
},
onSuccess: (_: CreateImageGenConfigResponse, request: CreateImageGenConfigRequest) => {
toast.success(`${request.name} created`);
queryClient.invalidateQueries({
queryKey: cacheKeys.imageGenConfigs.all(Number(searchSpaceId)),
});
},
onError: (error: Error) => {
toast.error(error.message || "Failed to create image model");
},
};
});
/**
* Mutation atom for updating an existing ImageGenerationConfig
*/
export const updateImageGenConfigMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
mutationKey: ["image-gen-configs", "update"],
meta: { suppressGlobalErrorToast: true },
enabled: !!searchSpaceId,
mutationFn: async (request: UpdateImageGenConfigRequest) => {
return imageGenConfigApiService.updateConfig(request);
},
onSuccess: (_: UpdateImageGenConfigResponse, request: UpdateImageGenConfigRequest) => {
toast.success(`${request.data.name ?? "Configuration"} updated`);
queryClient.invalidateQueries({
queryKey: cacheKeys.imageGenConfigs.all(Number(searchSpaceId)),
});
queryClient.invalidateQueries({
queryKey: cacheKeys.imageGenConfigs.byId(request.id),
});
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update image model");
},
};
});
/**
* Mutation atom for deleting an ImageGenerationConfig
*/
export const deleteImageGenConfigMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
mutationKey: ["image-gen-configs", "delete"],
meta: { suppressGlobalErrorToast: true },
enabled: !!searchSpaceId,
mutationFn: async (request: { id: number; name: string }) => {
return imageGenConfigApiService.deleteConfig(request.id);
},
onSuccess: (_: DeleteImageGenConfigResponse, request: { id: number; name: string }) => {
toast.success(`${request.name} deleted`);
queryClient.setQueryData(
cacheKeys.imageGenConfigs.all(Number(searchSpaceId)),
(oldData: GetImageGenConfigsResponse | undefined) => {
if (!oldData) return oldData;
return oldData.filter((config) => config.id !== request.id);
}
);
},
onError: (error: Error) => {
toast.error(error.message || "Failed to delete image model");
},
};
});

View file

@ -1,33 +0,0 @@
import { atomWithQuery } from "jotai-tanstack-query";
import { imageGenConfigApiService } from "@/lib/apis/image-gen-config-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
/**
* Query atom for fetching user-created image gen configs for the active search space
*/
export const imageGenConfigsAtom = atomWithQuery((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
queryKey: cacheKeys.imageGenConfigs.all(Number(searchSpaceId)),
enabled: !!searchSpaceId,
staleTime: 5 * 60 * 1000, // 5 minutes
queryFn: async () => {
return imageGenConfigApiService.getConfigs(Number(searchSpaceId));
},
};
});
/**
* Query atom for fetching global image gen configs (from YAML, negative IDs)
*/
export const globalImageGenConfigsAtom = atomWithQuery(() => {
return {
queryKey: cacheKeys.imageGenConfigs.global(),
staleTime: 10 * 60 * 1000, // 10 minutes - global configs rarely change
queryFn: async () => {
return imageGenConfigApiService.getGlobalConfigs();
},
};
});

View file

@ -1,132 +0,0 @@
import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type {
CreateNewLLMConfigRequest,
CreateNewLLMConfigResponse,
DeleteNewLLMConfigRequest,
DeleteNewLLMConfigResponse,
GetNewLLMConfigsResponse,
UpdateLLMPreferencesRequest,
UpdateNewLLMConfigRequest,
UpdateNewLLMConfigResponse,
} from "@/contracts/types/new-llm-config.types";
import { newLLMConfigApiService } from "@/lib/apis/new-llm-config-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
/**
* Mutation atom for creating a new NewLLMConfig
*/
export const createNewLLMConfigMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
mutationKey: ["new-llm-configs", "create"],
meta: { suppressGlobalErrorToast: true },
enabled: !!searchSpaceId,
mutationFn: async (request: CreateNewLLMConfigRequest) => {
return newLLMConfigApiService.createConfig(request);
},
onSuccess: (_: CreateNewLLMConfigResponse, request: CreateNewLLMConfigRequest) => {
toast.success(`${request.name} created`);
queryClient.invalidateQueries({
queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
});
},
onError: (error: Error) => {
toast.error(error.message || "Failed to create model");
},
};
});
/**
* Mutation atom for updating an existing NewLLMConfig
*/
export const updateNewLLMConfigMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
mutationKey: ["new-llm-configs", "update"],
meta: { suppressGlobalErrorToast: true },
enabled: !!searchSpaceId,
mutationFn: async (request: UpdateNewLLMConfigRequest) => {
return newLLMConfigApiService.updateConfig(request);
},
onSuccess: (_: UpdateNewLLMConfigResponse, request: UpdateNewLLMConfigRequest) => {
toast.success(`${request.data.name ?? "Configuration"} updated`);
queryClient.invalidateQueries({
queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
});
queryClient.invalidateQueries({
queryKey: cacheKeys.newLLMConfigs.byId(request.id),
});
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update");
},
};
});
/**
* Mutation atom for deleting a NewLLMConfig
*/
export const deleteNewLLMConfigMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
mutationKey: ["new-llm-configs", "delete"],
meta: { suppressGlobalErrorToast: true },
enabled: !!searchSpaceId,
mutationFn: async (request: DeleteNewLLMConfigRequest & { name: string }) => {
return newLLMConfigApiService.deleteConfig({ id: request.id });
},
onSuccess: (
_: DeleteNewLLMConfigResponse,
request: DeleteNewLLMConfigRequest & { name: string }
) => {
toast.success(`${request.name} deleted`);
queryClient.setQueryData(
cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
(oldData: GetNewLLMConfigsResponse | undefined) => {
if (!oldData) return oldData;
return oldData.filter((config) => config.id !== request.id);
}
);
},
onError: (error: Error) => {
toast.error(error.message || "Failed to delete");
},
};
});
/**
* Mutation atom for updating LLM preferences (role assignments)
*/
export const updateLLMPreferencesMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
mutationKey: ["llm-preferences", "update"],
meta: { suppressGlobalErrorToast: true },
enabled: !!searchSpaceId,
mutationFn: async (request: UpdateLLMPreferencesRequest) => {
return newLLMConfigApiService.updateLLMPreferences(request);
},
onSuccess: (_data, request: UpdateLLMPreferencesRequest) => {
queryClient.setQueryData(
cacheKeys.newLLMConfigs.preferences(Number(searchSpaceId)),
(old: Record<string, unknown> | undefined) => ({ ...old, ...request.data })
);
// Automation eligibility is derived from these model preferences
// (agent/image/vision). Invalidate it so the automations gate alert
// reflects the new selection without a manual refresh.
queryClient.invalidateQueries({
queryKey: cacheKeys.automations.modelEligibility(Number(searchSpaceId)),
});
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update LLM preferences");
},
};
});

View file

@ -1,98 +0,0 @@
import { atomWithQuery } from "jotai-tanstack-query";
import type { LLMModel } from "@/contracts/enums/llm-models";
import { LLM_MODELS } from "@/contracts/enums/llm-models";
import { newLLMConfigApiService } from "@/lib/apis/new-llm-config-api.service";
import { getBearerToken } from "@/lib/auth-utils";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
/**
* Query atom for fetching all NewLLMConfigs for the active search space
*/
export const newLLMConfigsAtom = atomWithQuery((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
enabled: !!searchSpaceId,
staleTime: 5 * 60 * 1000, // 5 minutes
queryFn: async () => {
return newLLMConfigApiService.getConfigs({
search_space_id: Number(searchSpaceId),
});
},
};
});
/**
* Query atom for fetching global NewLLMConfigs (from YAML, negative IDs)
*/
export const globalNewLLMConfigsAtom = atomWithQuery(() => {
return {
queryKey: cacheKeys.newLLMConfigs.global(),
staleTime: 10 * 60 * 1000, // 10 minutes - global configs rarely change
enabled: !!getBearerToken(),
queryFn: async () => {
return newLLMConfigApiService.getGlobalConfigs();
},
};
});
/**
* Query atom for fetching LLM preferences (role assignments) for the active search space
*/
export const llmPreferencesAtom = atomWithQuery((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
queryKey: cacheKeys.newLLMConfigs.preferences(Number(searchSpaceId)),
enabled: !!searchSpaceId,
staleTime: 5 * 60 * 1000, // 5 minutes
queryFn: async () => {
return newLLMConfigApiService.getLLMPreferences(Number(searchSpaceId));
},
};
});
/**
* Query atom for fetching default system instructions template
*/
export const defaultSystemInstructionsAtom = atomWithQuery(() => {
return {
queryKey: cacheKeys.newLLMConfigs.defaultInstructions(),
staleTime: 60 * 60 * 1000, // 1 hour - this rarely changes
queryFn: async () => {
return newLLMConfigApiService.getDefaultSystemInstructions();
},
};
});
/**
* Query atom for the dynamic model catalogue.
* Fetched from the backend (which proxies OpenRouter's public API).
* Falls back to the static hardcoded list on error.
*/
export const modelListAtom = atomWithQuery(() => {
return {
queryKey: cacheKeys.newLLMConfigs.modelList(),
staleTime: 60 * 60 * 1000, // 1 hour - models don't change often
placeholderData: LLM_MODELS,
queryFn: async (): Promise<LLMModel[]> => {
const data = await newLLMConfigApiService.getModels();
const dynamicModels = data.map((m) => ({
value: m.value,
label: m.label,
provider: m.provider,
contextWindow: m.context_window ?? undefined,
}));
// Providers covered by the dynamic API (from OpenRouter mapping).
// For uncovered providers (Ollama, Groq, Bedrock, etc.) keep the
// hand-curated static suggestions so users still see model options.
const coveredProviders = new Set(dynamicModels.map((m) => m.provider));
const staticFallbacks = LLM_MODELS.filter((m) => !coveredProviders.has(m.provider));
return [...dynamicModels, ...staticFallbacks];
},
};
});

View file

@ -1,87 +0,0 @@
import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type {
CreateVisionLLMConfigRequest,
CreateVisionLLMConfigResponse,
DeleteVisionLLMConfigResponse,
GetVisionLLMConfigsResponse,
UpdateVisionLLMConfigRequest,
UpdateVisionLLMConfigResponse,
} from "@/contracts/types/new-llm-config.types";
import { visionLLMConfigApiService } from "@/lib/apis/vision-llm-config-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
export const createVisionLLMConfigMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
mutationKey: ["vision-llm-configs", "create"],
meta: { suppressGlobalErrorToast: true },
enabled: !!searchSpaceId,
mutationFn: async (request: CreateVisionLLMConfigRequest) => {
return visionLLMConfigApiService.createConfig(request);
},
onSuccess: (_: CreateVisionLLMConfigResponse, request: CreateVisionLLMConfigRequest) => {
toast.success(`${request.name} created`);
queryClient.invalidateQueries({
queryKey: cacheKeys.visionLLMConfigs.all(Number(searchSpaceId)),
});
},
onError: (error: Error) => {
toast.error(error.message || "Failed to create vision model");
},
};
});
export const updateVisionLLMConfigMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
mutationKey: ["vision-llm-configs", "update"],
meta: { suppressGlobalErrorToast: true },
enabled: !!searchSpaceId,
mutationFn: async (request: UpdateVisionLLMConfigRequest) => {
return visionLLMConfigApiService.updateConfig(request);
},
onSuccess: (_: UpdateVisionLLMConfigResponse, request: UpdateVisionLLMConfigRequest) => {
toast.success(`${request.data.name ?? "Configuration"} updated`);
queryClient.invalidateQueries({
queryKey: cacheKeys.visionLLMConfigs.all(Number(searchSpaceId)),
});
queryClient.invalidateQueries({
queryKey: cacheKeys.visionLLMConfigs.byId(request.id),
});
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update vision model");
},
};
});
export const deleteVisionLLMConfigMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
mutationKey: ["vision-llm-configs", "delete"],
meta: { suppressGlobalErrorToast: true },
enabled: !!searchSpaceId,
mutationFn: async (request: { id: number; name: string }) => {
return visionLLMConfigApiService.deleteConfig(request.id);
},
onSuccess: (_: DeleteVisionLLMConfigResponse, request: { id: number; name: string }) => {
toast.success(`${request.name} deleted`);
queryClient.setQueryData(
cacheKeys.visionLLMConfigs.all(Number(searchSpaceId)),
(oldData: GetVisionLLMConfigsResponse | undefined) => {
if (!oldData) return oldData;
return oldData.filter((config) => config.id !== request.id);
}
);
},
onError: (error: Error) => {
toast.error(error.message || "Failed to delete vision model");
},
};
});

View file

@ -1,51 +0,0 @@
import { atomWithQuery } from "jotai-tanstack-query";
import type { LLMModel } from "@/contracts/enums/llm-models";
import { VISION_MODELS } from "@/contracts/enums/vision-providers";
import { visionLLMConfigApiService } from "@/lib/apis/vision-llm-config-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
export const visionLLMConfigsAtom = atomWithQuery((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
queryKey: cacheKeys.visionLLMConfigs.all(Number(searchSpaceId)),
enabled: !!searchSpaceId,
staleTime: 5 * 60 * 1000,
queryFn: async () => {
return visionLLMConfigApiService.getConfigs(Number(searchSpaceId));
},
};
});
export const globalVisionLLMConfigsAtom = atomWithQuery(() => {
return {
queryKey: cacheKeys.visionLLMConfigs.global(),
staleTime: 10 * 60 * 1000,
queryFn: async () => {
return visionLLMConfigApiService.getGlobalConfigs();
},
};
});
export const visionModelListAtom = atomWithQuery(() => {
return {
queryKey: cacheKeys.visionLLMConfigs.modelList(),
staleTime: 60 * 60 * 1000,
placeholderData: VISION_MODELS,
queryFn: async (): Promise<LLMModel[]> => {
const data = await visionLLMConfigApiService.getModels();
const dynamicModels = data.map((m) => ({
value: m.value,
label: m.label,
provider: m.provider,
contextWindow: m.context_window ?? undefined,
}));
const coveredProviders = new Set(dynamicModels.map((m) => m.provider));
const staticFallbacks = VISION_MODELS.filter((m) => !coveredProviders.has(m.provider));
return [...dynamicModels, ...staticFallbacks];
},
};
});

View file

@ -1,17 +1,5 @@
"use client";
import { useCallback, useState } from "react";
import { ImageConfigDialog } from "@/components/shared/image-config-dialog";
import { ModelConfigDialog } from "@/components/shared/model-config-dialog";
import { VisionConfigDialog } from "@/components/shared/vision-config-dialog";
import type {
GlobalImageGenConfig,
GlobalNewLLMConfig,
GlobalVisionLLMConfig,
ImageGenerationConfig,
NewLLMConfigPublic,
VisionLLMConfig,
} from "@/contracts/types/new-llm-config.types";
import { ModelSelector } from "./model-selector";
interface ChatHeaderProps {
@ -20,148 +8,9 @@ interface ChatHeaderProps {
}
export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) {
// LLM config dialog state
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedConfig, setSelectedConfig] = useState<
NewLLMConfigPublic | GlobalNewLLMConfig | null
>(null);
const [isGlobal, setIsGlobal] = useState(false);
const [dialogMode, setDialogMode] = useState<"create" | "edit" | "view">("view");
// Image config dialog state
const [imageDialogOpen, setImageDialogOpen] = useState(false);
const [selectedImageConfig, setSelectedImageConfig] = useState<
ImageGenerationConfig | GlobalImageGenConfig | null
>(null);
const [isImageGlobal, setIsImageGlobal] = useState(false);
const [imageDialogMode, setImageDialogMode] = useState<"create" | "edit" | "view">("view");
// Vision config dialog state
const [visionDialogOpen, setVisionDialogOpen] = useState(false);
const [selectedVisionConfig, setSelectedVisionConfig] = useState<
VisionLLMConfig | GlobalVisionLLMConfig | null
>(null);
const [isVisionGlobal, setIsVisionGlobal] = useState(false);
const [visionDialogMode, setVisionDialogMode] = useState<"create" | "edit" | "view">("view");
// Default provider for create dialogs
const [defaultLLMProvider, setDefaultLLMProvider] = useState<string | undefined>();
const [defaultImageProvider, setDefaultImageProvider] = useState<string | undefined>();
const [defaultVisionProvider, setDefaultVisionProvider] = useState<string | undefined>();
// LLM handlers
const handleEditLLMConfig = useCallback(
(config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => {
setSelectedConfig(config);
setIsGlobal(global);
setDialogMode(global ? "view" : "edit");
setDefaultLLMProvider(undefined);
setDialogOpen(true);
},
[]
);
const handleAddNewLLM = useCallback((provider?: string) => {
setSelectedConfig(null);
setIsGlobal(false);
setDialogMode("create");
setDefaultLLMProvider(provider);
setDialogOpen(true);
}, []);
const handleDialogClose = useCallback((open: boolean) => {
setDialogOpen(open);
if (!open) setSelectedConfig(null);
}, []);
// Image model handlers
const handleAddImageModel = useCallback((provider?: string) => {
setSelectedImageConfig(null);
setIsImageGlobal(false);
setImageDialogMode("create");
setDefaultImageProvider(provider);
setImageDialogOpen(true);
}, []);
const handleEditImageConfig = useCallback(
(config: ImageGenerationConfig | GlobalImageGenConfig, global: boolean) => {
setSelectedImageConfig(config);
setIsImageGlobal(global);
setImageDialogMode(global ? "view" : "edit");
setDefaultImageProvider(undefined);
setImageDialogOpen(true);
},
[]
);
const handleImageDialogClose = useCallback((open: boolean) => {
setImageDialogOpen(open);
if (!open) setSelectedImageConfig(null);
}, []);
// Vision model handlers
const handleAddVisionModel = useCallback((provider?: string) => {
setSelectedVisionConfig(null);
setIsVisionGlobal(false);
setVisionDialogMode("create");
setDefaultVisionProvider(provider);
setVisionDialogOpen(true);
}, []);
const handleEditVisionConfig = useCallback(
(config: VisionLLMConfig | GlobalVisionLLMConfig, global: boolean) => {
setSelectedVisionConfig(config);
setIsVisionGlobal(global);
setVisionDialogMode(global ? "view" : "edit");
setDefaultVisionProvider(undefined);
setVisionDialogOpen(true);
},
[]
);
const handleVisionDialogClose = useCallback((open: boolean) => {
setVisionDialogOpen(open);
if (!open) setSelectedVisionConfig(null);
}, []);
return (
<div className="flex items-center gap-2">
<ModelSelector
onEditLLM={handleEditLLMConfig}
onAddNewLLM={handleAddNewLLM}
onEditImage={handleEditImageConfig}
onAddNewImage={handleAddImageModel}
onEditVision={handleEditVisionConfig}
onAddNewVision={handleAddVisionModel}
className={className}
/>
<ModelConfigDialog
open={dialogOpen}
onOpenChange={handleDialogClose}
config={selectedConfig}
isGlobal={isGlobal}
searchSpaceId={searchSpaceId}
mode={dialogMode}
defaultProvider={defaultLLMProvider}
/>
<ImageConfigDialog
open={imageDialogOpen}
onOpenChange={handleImageDialogClose}
config={selectedImageConfig}
isGlobal={isImageGlobal}
searchSpaceId={searchSpaceId}
mode={imageDialogMode}
defaultProvider={defaultImageProvider}
/>
<VisionConfigDialog
open={visionDialogOpen}
onOpenChange={handleVisionDialogClose}
config={selectedVisionConfig}
isGlobal={isVisionGlobal}
searchSpaceId={searchSpaceId}
mode={visionDialogMode}
defaultProvider={defaultVisionProvider}
/>
<ModelSelector searchSpaceId={searchSpaceId} className={className} />
</div>
);
}

View file

@ -1,423 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, Dot, FileText, Info, Pencil, RefreshCw, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
import { deleteNewLLMConfigMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
import {
globalNewLLMConfigsAtom,
newLLMConfigsAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { ModelConfigDialog } from "@/components/shared/model-config-dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import type { NewLLMConfig } from "@/contracts/types/new-llm-config.types";
import { useMediaQuery } from "@/hooks/use-media-query";
import { getProviderIcon } from "@/lib/provider-icons";
import { cn } from "@/lib/utils";
interface AgentModelManagerProps {
searchSpaceId: number;
}
function getInitials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
export function AgentModelManager({ searchSpaceId }: AgentModelManagerProps) {
const isDesktop = useMediaQuery("(min-width: 768px)");
// Mutations
const { mutateAsync: deleteConfig, isPending: isDeleting } = useAtomValue(
deleteNewLLMConfigMutationAtom
);
// Queries
const {
data: configs,
isFetching: isLoading,
error: fetchError,
refetch: refreshConfigs,
} = useAtomValue(newLLMConfigsAtom);
const { data: globalConfigs = [] } = useAtomValue(globalNewLLMConfigsAtom);
// Members for user resolution
const { data: members } = useAtomValue(membersAtom);
const memberMap = useMemo(() => {
const map = new Map<string, { name: string; email?: string; avatarUrl?: string }>();
if (members) {
for (const m of members) {
map.set(m.user_id, {
name: m.user_display_name || m.user_email || "Unknown",
email: m.user_email || undefined,
avatarUrl: m.user_avatar_url || undefined,
});
}
}
return map;
}, [members]);
// Permissions
const { data: access } = useAtomValue(myAccessAtom);
const canCreate =
!!access && (access.is_owner || (access.permissions?.includes("llm_configs:create") ?? false));
const canUpdate =
!!access && (access.is_owner || (access.permissions?.includes("llm_configs:update") ?? false));
const canDelete =
!!access && (access.is_owner || (access.permissions?.includes("llm_configs:delete") ?? false));
const isReadOnly = !canCreate && !canUpdate && !canDelete;
// Local state
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingConfig, setEditingConfig] = useState<NewLLMConfig | null>(null);
const [configToDelete, setConfigToDelete] = useState<NewLLMConfig | null>(null);
const handleDelete = async () => {
if (!configToDelete) return;
try {
await deleteConfig({ id: configToDelete.id, name: configToDelete.name });
setConfigToDelete(null);
} catch {
// Error handled by mutation state
}
};
const openEditDialog = (config: NewLLMConfig) => {
setEditingConfig(config);
setIsDialogOpen(true);
};
const openNewDialog = () => {
setEditingConfig(null);
setIsDialogOpen(true);
};
return (
<div className="space-y-5 md:space-y-6">
{/* Header actions */}
<div className="flex items-center justify-between">
<Button
variant="secondary"
size="sm"
onClick={() => refreshConfigs()}
disabled={isLoading}
className="gap-2"
>
<RefreshCw className={cn("h-3.5 w-3.5", isLoading && "animate-spin")} />
Refresh
</Button>
{canCreate && (
<Button
variant="outline"
onClick={openNewDialog}
className="gap-2 border-transparent bg-white text-[#1f1f1f] font-medium hover:bg-zinc-100 hover:text-[#1f1f1f] dark:border-transparent dark:bg-white dark:text-[#1f1f1f]"
>
Add Model
</Button>
)}
</div>
{/* Fetch Error Alert */}
{fetchError && (
<div>
<Alert variant="destructive" className="py-3 md:py-4">
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
{fetchError?.message ?? "Failed to load configurations"}
</AlertDescription>
</Alert>
</div>
)}
{/* Read-only / Limited permissions notice */}
{access && !isLoading && isReadOnly && (
<div>
<Alert>
<Info />
<AlertDescription>
<p>
You have <span className="font-medium">read-only</span> access to LLM
configurations. Contact a space owner to request additional permissions.
</p>
</AlertDescription>
</Alert>
</div>
)}
{access && !isLoading && !isReadOnly && (!canCreate || !canUpdate || !canDelete) && (
<div>
<Alert>
<Info />
<AlertDescription>
<p>
You can{" "}
{[canCreate && "create", canUpdate && "edit", canDelete && "delete"]
.filter(Boolean)
.join(" and ")}{" "}
configurations
{!canDelete && ", but cannot delete them"}.
</p>
</AlertDescription>
</Alert>
</div>
)}
{/* Global Configs Info */}
{(isLoading || globalConfigs.length > 0) && (
<Alert>
<Info />
<AlertDescription>
{isLoading ? (
<div className="flex min-h-[1.625em] items-center">
<Skeleton className="h-4 w-60 bg-accent-foreground/15" />
</div>
) : (
<p>
<span className="font-medium">
{globalConfigs.length} global {globalConfigs.length === 1 ? "model" : "models"}
</span>{" "}
available from your administrator.
</p>
)}
</AlertDescription>
</Alert>
)}
{/* Loading Skeleton */}
{isLoading && (
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
<Card key={key} className="border-accent bg-accent/20">
<CardContent className="p-4 flex flex-col gap-3 min-h-32">
<Skeleton className="h-4 w-32 md:w-40 bg-accent" />
<Skeleton className="h-3 w-full bg-accent" />
<Skeleton className="h-3 w-24 md:w-28 bg-accent mt-auto" />
</CardContent>
</Card>
))}
</div>
)}
{/* Configurations List */}
{!isLoading && (
<div className="space-y-4">
{configs?.length === 0 ? (
<div>
<Card className="border-0 bg-transparent shadow-none">
<CardContent className="flex flex-col items-center justify-center py-10 md:py-16 text-center">
<h3 className="text-sm md:text-base font-semibold mb-2">No Models Yet</h3>
<p className="text-[11px] md:text-xs text-muted-foreground max-w-sm mb-4">
{canCreate
? "Add your first model to power chat, reports, and other agent capabilities"
: "No models have been added to this space yet. Contact a space owner to add one"}
</p>
</CardContent>
</Card>
</div>
) : (
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
{configs?.map((config) => {
const member = config.user_id ? memberMap.get(config.user_id) : null;
return (
<div key={config.id}>
<Card className="group relative overflow-hidden transition-all duration-200 border-accent bg-accent/20 hover:shadow-md h-full">
<CardContent className="p-4 flex flex-col gap-3 h-full">
{/* Header: Icon + Name + Actions */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<div className="shrink-0">
{getProviderIcon(config.provider, { className: "size-4" })}
</div>
<div className="min-w-0 flex-1">
<h4 className="text-sm font-semibold tracking-tight truncate">
{config.name}
</h4>
{config.description && (
<p className="text-[11px] text-muted-foreground/70 truncate mt-0.5">
{config.description}
</p>
)}
</div>
</div>
{(canUpdate || canDelete) && (
<div className="flex items-center gap-1 shrink-0 sm:w-0 sm:overflow-hidden sm:group-hover:w-auto sm:opacity-0 sm:group-hover:opacity-100 transition-all duration-150">
{canUpdate && (
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => openEditDialog(config)}
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-accent-foreground"
>
<Pencil className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{canDelete && (
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setConfigToDelete(config)}
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)}
</div>
{/* Feature badges */}
<div className="flex items-center gap-1.5 flex-wrap">
{config.citations_enabled && (
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
>
Citations
</Badge>
)}
{!config.use_default_system_instructions &&
config.system_instructions && (
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
>
<FileText className="h-2.5 w-2.5 mr-1" />
Custom
</Badge>
)}
</div>
{/* Footer: Date + Creator */}
<div className="mt-auto space-y-2">
<Separator className="bg-accent" />
<div className="flex items-center">
<span className="shrink-0 text-[11px] text-muted-foreground/60 whitespace-nowrap">
{new Date(config.created_at).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</span>
{member && (
<>
<Dot className="h-4 w-4 text-muted-foreground/30 shrink-0" />
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<div className="min-w-0 flex items-center gap-1.5 cursor-default">
<Avatar className="size-4.5 shrink-0">
{member.avatarUrl && (
<AvatarImage src={member.avatarUrl} alt={member.name} />
)}
<AvatarFallback className="text-[9px]">
{getInitials(member.name)}
</AvatarFallback>
</Avatar>
<span className="text-[11px] text-muted-foreground/60 truncate">
{member.name}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{member.email || member.name}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
)}
</div>
</div>
</CardContent>
</Card>
</div>
);
})}
</div>
)}
</div>
)}
{/* Add/Edit Configuration Dialog */}
<ModelConfigDialog
open={isDialogOpen}
onOpenChange={(open) => {
setIsDialogOpen(open);
if (!open) setEditingConfig(null);
}}
config={editingConfig}
isGlobal={false}
searchSpaceId={searchSpaceId}
mode={editingConfig ? "edit" : "create"}
/>
{/* Delete Confirmation Dialog */}
<AlertDialog
open={!!configToDelete}
onOpenChange={(open) => !open && setConfigToDelete(null)}
>
<AlertDialogContent className="select-none">
<AlertDialogHeader>
<AlertDialogTitle>Delete Model</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{configToDelete?.name}</span>? This
action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Spinner size="sm" className="mr-2" />
Deleting
</>
) : (
"Delete"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View file

@ -1,489 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, Dot, Info, Pencil, RefreshCw, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { deleteImageGenConfigMutationAtom } from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
import {
globalImageGenConfigsAtom,
imageGenConfigsAtom,
} from "@/atoms/image-gen-config/image-gen-config-query.atoms";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
import { ImageConfigDialog } from "@/components/shared/image-config-dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import type { ImageGenerationConfig } from "@/contracts/types/new-llm-config.types";
import { useMediaQuery } from "@/hooks/use-media-query";
import { getProviderIcon } from "@/lib/provider-icons";
import { cn } from "@/lib/utils";
interface ImageModelManagerProps {
searchSpaceId: number;
}
function getInitials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
const isDesktop = useMediaQuery("(min-width: 768px)");
const {
mutateAsync: deleteConfig,
isPending: isDeleting,
error: deleteError,
} = useAtomValue(deleteImageGenConfigMutationAtom);
const {
data: userConfigs,
isFetching: configsLoading,
error: fetchError,
refetch: refreshConfigs,
} = useAtomValue(imageGenConfigsAtom);
const { data: globalConfigs = [], isFetching: globalLoading } =
useAtomValue(globalImageGenConfigsAtom);
const { data: members } = useAtomValue(membersAtom);
const memberMap = useMemo(() => {
const map = new Map<string, { name: string; email?: string; avatarUrl?: string }>();
if (members) {
for (const m of members) {
map.set(m.user_id, {
name: m.user_display_name || m.user_email || "Unknown",
email: m.user_email || undefined,
avatarUrl: m.user_avatar_url || undefined,
});
}
}
return map;
}, [members]);
const { data: access } = useAtomValue(myAccessAtom);
const canCreate =
!!access &&
(access.is_owner || (access.permissions?.includes("image_generations:create") ?? false));
const canDelete =
!!access &&
(access.is_owner || (access.permissions?.includes("image_generations:delete") ?? false));
const canUpdate = canCreate;
const isReadOnly = !canCreate && !canDelete;
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingConfig, setEditingConfig] = useState<ImageGenerationConfig | null>(null);
const [configToDelete, setConfigToDelete] = useState<ImageGenerationConfig | null>(null);
const isLoading = configsLoading || globalLoading;
const errors = [deleteError, fetchError].filter(Boolean) as Error[];
const openEditDialog = (config: ImageGenerationConfig) => {
setEditingConfig(config);
setIsDialogOpen(true);
};
const openNewDialog = () => {
setEditingConfig(null);
setIsDialogOpen(true);
};
const handleDelete = async () => {
if (!configToDelete) return;
try {
await deleteConfig({ id: configToDelete.id, name: configToDelete.name });
setConfigToDelete(null);
} catch {
// Error handled by mutation
}
};
return (
<div className="space-y-4 md:space-y-6">
{/* Header actions */}
<div className="flex items-center justify-between">
<Button
variant="secondary"
size="sm"
onClick={() => refreshConfigs()}
disabled={isLoading}
className="gap-2"
>
<RefreshCw className={cn("h-3.5 w-3.5", configsLoading && "animate-spin")} />
Refresh
</Button>
{canCreate && (
<Button
variant="outline"
onClick={openNewDialog}
className="gap-2 border-transparent bg-white text-[#1f1f1f] font-medium hover:bg-zinc-100 hover:text-[#1f1f1f] dark:border-transparent dark:bg-white dark:text-[#1f1f1f]"
>
Add Image Model
</Button>
)}
</div>
{/* Errors */}
{errors.map((err) => (
<div key={err?.message}>
<Alert variant="destructive" className="py-3">
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">{err?.message}</AlertDescription>
</Alert>
</div>
))}
{/* Read-only / Limited permissions notice */}
{access && !isLoading && isReadOnly && (
<div>
<Alert>
<Info />
<AlertDescription>
<p>
You have <span className="font-medium">read-only</span> access to image generation
configurations. Contact a space owner to request additional permissions.
</p>
</AlertDescription>
</Alert>
</div>
)}
{access && !isLoading && !isReadOnly && (!canCreate || !canDelete) && (
<div>
<Alert>
<Info />
<AlertDescription>
<p>
You can{" "}
{[canCreate && "create and edit", canDelete && "delete"]
.filter(Boolean)
.join(" and ")}{" "}
image model configurations
{!canDelete && ", but cannot delete them"}.
</p>
</AlertDescription>
</Alert>
</div>
)}
{/* Global info */}
{(isLoading ||
globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0) && (
<Alert>
<Info />
<AlertDescription>
{isLoading ? (
<div className="flex min-h-[1.625em] items-center">
<Skeleton className="h-4 w-60 bg-accent-foreground/15" />
</div>
) : (
<p>
<span className="font-medium">
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length}{" "}
global image{" "}
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length ===
1
? "model"
: "models"}
</span>{" "}
available from your administrator. {(() => {
const nonAuto = globalConfigs.filter(
(g) => !("is_auto_mode" in g && g.is_auto_mode)
);
const premium = nonAuto.filter(
(g) =>
"billing_tier" in g &&
(g as { billing_tier?: string }).billing_tier === "premium"
).length;
const free = nonAuto.length - premium;
if (premium > 0 && free > 0) {
return `${premium} premium, ${free} free.`;
}
if (premium > 0) {
return `All ${premium} premium — debits your shared credit pool.`;
}
return `All ${free} free.`;
})()}
</p>
)}
</AlertDescription>
</Alert>
)}
{/* Global Image Models read-only cards with per-model Free/Premium
badges. Mirrors the badge palette used by the chat role selector
(`llm-role-manager.tsx`) so the meaning is consistent across
every model-configuration surface (chat / image / vision). */}
{!isLoading &&
globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0 && (
<div className="space-y-3">
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
{globalConfigs
.filter((g) => !("is_auto_mode" in g && g.is_auto_mode))
.map((cfg) => {
const billingTier =
("billing_tier" in cfg &&
typeof (cfg as { billing_tier?: string }).billing_tier === "string" &&
(cfg as { billing_tier?: string }).billing_tier) ||
"free";
const isPremium = billingTier === "premium";
return (
<Card
key={cfg.id}
className="group relative overflow-hidden transition-all duration-200 border-accent bg-accent/20 hover:shadow-md h-full"
>
<CardContent className="p-4 flex flex-col gap-3 h-full">
<div className="flex items-center gap-2 min-w-0">
<div className="shrink-0">
{getProviderIcon(cfg.provider, { className: "size-4" })}
</div>
<div className="min-w-0 flex-1 flex items-center gap-1.5">
<h4 className="text-sm font-semibold tracking-tight truncate">
{cfg.name}
</h4>
{isPremium ? (
<Badge
variant="secondary"
className="text-[8px] md:text-[9px] shrink-0 bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300 border-0"
>
Premium
</Badge>
) : (
<Badge
variant="secondary"
className="text-[8px] md:text-[9px] shrink-0 bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300 border-0"
>
Free
</Badge>
)}
</div>
</div>
{cfg.description && (
<p className="text-[11px] text-muted-foreground/70 line-clamp-2">
{cfg.description}
</p>
)}
<div className="mt-auto space-y-2">
<Separator className="bg-accent" />
<div className="flex items-center">
<span className="text-[11px] text-muted-foreground/60 truncate">
{cfg.model_name}
</span>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
)}
{/* Loading Skeleton */}
{isLoading && (
<div className="space-y-4 md:space-y-6">
<div className="space-y-4">
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
<Card key={key} className="border-accent bg-accent/20">
<CardContent className="p-4 flex flex-col gap-3 min-h-32">
<Skeleton className="h-4 w-32 md:w-40 bg-accent" />
<Skeleton className="h-3 w-full bg-accent" />
<Skeleton className="h-3 w-24 md:w-28 bg-accent mt-auto" />
</CardContent>
</Card>
))}
</div>
</div>
</div>
)}
{/* User Configs */}
{!isLoading && (
<div className="space-y-4 md:space-y-6">
{(userConfigs?.length ?? 0) === 0 ? (
<Card className="border-0 bg-transparent shadow-none">
<CardContent className="flex flex-col items-center justify-center py-10 md:py-16 text-center">
<h3 className="text-sm md:text-base font-semibold mb-2">No Image Models Yet</h3>
<p className="text-[11px] md:text-xs text-muted-foreground max-w-sm mb-4">
{canCreate
? "Add your own image generation model (DALL-E 3, GPT Image 1, etc.)"
: "No image models have been added to this space yet. Contact a space owner to add one."}
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
{userConfigs?.map((config) => {
const member = config.user_id ? memberMap.get(config.user_id) : null;
return (
<div key={config.id}>
<Card className="group relative overflow-hidden transition-all duration-200 border-accent bg-accent/20 hover:shadow-md h-full">
<CardContent className="p-4 flex flex-col gap-3 h-full">
{/* Header: Icon + Name + Actions */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<div className="shrink-0">
{getProviderIcon(config.provider, { className: "size-4" })}
</div>
<div className="min-w-0 flex-1">
<h4 className="text-sm font-semibold tracking-tight truncate">
{config.name}
</h4>
{config.description && (
<p className="text-[11px] text-muted-foreground/70 truncate mt-0.5">
{config.description}
</p>
)}
</div>
</div>
{(canUpdate || canDelete) && (
<div className="flex items-center gap-1 shrink-0 sm:w-0 sm:overflow-hidden sm:group-hover:w-auto sm:opacity-0 sm:group-hover:opacity-100 transition-all duration-150">
{canUpdate && (
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => openEditDialog(config)}
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-accent-foreground"
>
<Pencil className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{canDelete && (
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setConfigToDelete(config)}
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)}
</div>
{/* Footer: Date + Creator */}
<div className="mt-auto space-y-2">
<Separator className="bg-accent" />
<div className="flex items-center">
<span className="shrink-0 text-[11px] text-muted-foreground/60 whitespace-nowrap">
{new Date(config.created_at).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</span>
{member && (
<>
<Dot className="h-4 w-4 text-muted-foreground/30 shrink-0" />
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<div className="min-w-0 flex items-center gap-1.5 cursor-default">
<Avatar className="size-4.5 shrink-0">
{member.avatarUrl && (
<AvatarImage src={member.avatarUrl} alt={member.name} />
)}
<AvatarFallback className="text-[9px]">
{getInitials(member.name)}
</AvatarFallback>
</Avatar>
<span className="text-[11px] text-muted-foreground/60 truncate">
{member.name}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{member.email || member.name}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
)}
</div>
</div>
</CardContent>
</Card>
</div>
);
})}
</div>
)}
</div>
)}
{/* Create/Edit Dialog — shared component */}
<ImageConfigDialog
open={isDialogOpen}
onOpenChange={(open) => {
setIsDialogOpen(open);
if (!open) setEditingConfig(null);
}}
config={editingConfig}
isGlobal={false}
searchSpaceId={searchSpaceId}
mode={editingConfig ? "edit" : "create"}
/>
{/* Delete Confirmation */}
<AlertDialog
open={!!configToDelete}
onOpenChange={(open) => !open && setConfigToDelete(null)}
>
<AlertDialogContent className="select-none">
<AlertDialogHeader>
<AlertDialogTitle>Delete Image Model</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{configToDelete?.name}</span>?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
<span className={isDeleting ? "opacity-0" : ""}>Delete</span>
{isDeleting && <Spinner size="sm" className="absolute" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View file

@ -1,443 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import {
AlertCircle,
Bot,
CircleCheck,
CircleDashed,
FileText,
ImageIcon,
RefreshCw,
ScanEye,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
globalImageGenConfigsAtom,
imageGenConfigsAtom,
} from "@/atoms/image-gen-config/image-gen-config-query.atoms";
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
import {
globalNewLLMConfigsAtom,
llmPreferencesAtom,
newLLMConfigsAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import {
globalVisionLLMConfigsAtom,
visionLLMConfigsAtom,
} from "@/atoms/vision-llm-config/vision-llm-config-query.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
const ROLE_DESCRIPTIONS = {
agent: {
icon: Bot,
title: "Chat model",
description: "Primary model for chat interactions and agent operations",
color: "text-muted-foreground",
bgColor: "bg-muted",
prefKey: "agent_llm_id" as const,
configType: "llm" as const,
},
image_generation: {
icon: ImageIcon,
title: "Image Generation Model",
description: "Model used for AI image generation (DALL-E, GPT Image, etc.)",
color: "text-muted-foreground",
bgColor: "bg-muted",
prefKey: "image_generation_config_id" as const,
configType: "image" as const,
},
vision: {
icon: ScanEye,
title: "Vision LLM",
description: "Vision-capable model for screenshot analysis and context extraction",
color: "text-muted-foreground",
bgColor: "bg-muted",
prefKey: "vision_llm_config_id" as const,
configType: "vision" as const,
},
};
interface LLMRoleManagerProps {
searchSpaceId: number;
}
export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
// LLM configs
const {
data: newLLMConfigs = [],
isFetching: configsLoading,
error: configsError,
refetch: refreshConfigs,
} = useAtomValue(newLLMConfigsAtom);
const {
data: globalConfigs = [],
isFetching: globalConfigsLoading,
error: globalConfigsError,
} = useAtomValue(globalNewLLMConfigsAtom);
// Image gen configs
const {
data: userImageConfigs = [],
isFetching: imageConfigsLoading,
error: imageConfigsError,
} = useAtomValue(imageGenConfigsAtom);
const {
data: globalImageConfigs = [],
isFetching: globalImageConfigsLoading,
error: globalImageConfigsError,
} = useAtomValue(globalImageGenConfigsAtom);
// Vision LLM configs
const {
data: userVisionConfigs = [],
isFetching: visionConfigsLoading,
error: visionConfigsError,
} = useAtomValue(visionLLMConfigsAtom);
const {
data: globalVisionConfigs = [],
isFetching: globalVisionConfigsLoading,
error: globalVisionConfigsError,
} = useAtomValue(globalVisionLLMConfigsAtom);
// Preferences
const {
data: preferences = {},
isFetching: preferencesLoading,
error: preferencesError,
} = useAtomValue(llmPreferencesAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
const [assignments, setAssignments] = useState<Record<string, number | null>>(() => ({
agent_llm_id: preferences.agent_llm_id ?? null,
image_generation_config_id: preferences.image_generation_config_id ?? null,
vision_llm_config_id: preferences.vision_llm_config_id ?? null,
}));
// Sync local state when preferences load/change. Without this, the selects
// stay on their initial (often empty) value while the query is in flight,
// so a saved assignment — including Auto mode (id 0) — never appears.
useEffect(() => {
setAssignments({
agent_llm_id: preferences.agent_llm_id ?? null,
image_generation_config_id: preferences.image_generation_config_id ?? null,
vision_llm_config_id: preferences.vision_llm_config_id ?? null,
});
}, [
preferences.agent_llm_id,
preferences.image_generation_config_id,
preferences.vision_llm_config_id,
]);
const [savingRole, setSavingRole] = useState<string | null>(null);
const handleRoleAssignment = useCallback(
async (prefKey: string, configId: string) => {
// "unassigned" clears the role (null). Every other option — including
// Auto mode, whose config id is 0 — must be sent as-is. Using a falsy
// check here (e.g. `value || undefined`) would drop id 0 and silently
// fail to persist Auto mode.
const value = configId === "unassigned" ? null : Number(configId);
setAssignments((prev) => ({ ...prev, [prefKey]: value }));
setSavingRole(prefKey);
try {
await updatePreferences({
search_space_id: searchSpaceId,
data: { [prefKey]: value },
});
toast.success("Role assignment updated");
} finally {
setSavingRole(null);
}
},
[updatePreferences, searchSpaceId]
);
// Combine global and custom LLM configs
const allLLMConfigs = [
...globalConfigs.map((config) => ({ ...config, is_global: true })),
...newLLMConfigs.filter((config) => config.id && config.id.toString().trim() !== ""),
];
// Combine global and custom image gen configs
const allImageConfigs = [
...globalImageConfigs.map((config) => ({ ...config, is_global: true })),
...(userImageConfigs ?? []).filter((config) => config.id && config.id.toString().trim() !== ""),
];
// Combine global and custom vision LLM configs
const allVisionConfigs = [
...globalVisionConfigs.map((config) => ({ ...config, is_global: true })),
...(userVisionConfigs ?? []).filter(
(config) => config.id && config.id.toString().trim() !== ""
),
];
const isLoading =
configsLoading ||
preferencesLoading ||
globalConfigsLoading ||
imageConfigsLoading ||
globalImageConfigsLoading ||
visionConfigsLoading ||
globalVisionConfigsLoading;
const hasError =
configsError ||
preferencesError ||
globalConfigsError ||
imageConfigsError ||
globalImageConfigsError ||
visionConfigsError ||
globalVisionConfigsError;
const hasAnyConfigs = allLLMConfigs.length > 0 || allImageConfigs.length > 0;
return (
<div className="space-y-5 md:space-y-6">
{/* Header actions */}
<div className="flex items-center justify-start">
<Button
variant="secondary"
size="sm"
onClick={() => refreshConfigs()}
disabled={isLoading}
className="gap-2"
>
<RefreshCw className={cn("h-3.5 w-3.5", isLoading && "animate-spin")} />
Refresh
</Button>
</div>
{/* Error Alert */}
{hasError && (
<div>
<Alert variant="destructive" className="py-3 md:py-4">
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
{(configsError?.message ?? "Failed to load LLM configurations") ||
(preferencesError?.message ?? "Failed to load preferences") ||
(globalConfigsError?.message ?? "Failed to load global configurations")}
</AlertDescription>
</Alert>
</div>
)}
{/* Loading Skeleton */}
{isLoading && (
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
<Card key={key} className="border-accent bg-accent/20">
<CardContent className="p-4 flex flex-col gap-3 min-h-32">
<Skeleton className="h-4 w-32 md:w-40 bg-accent" />
<Skeleton className="h-3 w-full bg-accent" />
<Skeleton className="h-3 w-24 md:w-28 bg-accent mt-auto" />
</CardContent>
</Card>
))}
</div>
)}
{/* No configs warning */}
{!isLoading && !hasError && !hasAnyConfigs && (
<Alert variant="destructive" className="py-3 md:py-4">
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
No configurations found. Please add at least one LLM provider or image model in the
respective settings tabs before assigning roles.
</AlertDescription>
</Alert>
)}
{/* Role Assignment Cards */}
{!isLoading && !hasError && hasAnyConfigs && (
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
{Object.entries(ROLE_DESCRIPTIONS).map(([key, role]) => {
const IconComponent = role.icon;
const currentAssignment = assignments[role.prefKey as keyof typeof assignments];
// Pick the right config lists based on role type
const roleGlobalConfigs =
role.configType === "image"
? globalImageConfigs
: role.configType === "vision"
? globalVisionConfigs
: globalConfigs;
const roleUserConfigs =
role.configType === "image"
? (userImageConfigs ?? []).filter((c) => c.id && c.id.toString().trim() !== "")
: role.configType === "vision"
? (userVisionConfigs ?? []).filter((c) => c.id && c.id.toString().trim() !== "")
: newLLMConfigs.filter((c) => c.id && c.id.toString().trim() !== "");
const roleAllConfigs =
role.configType === "image"
? allImageConfigs
: role.configType === "vision"
? allVisionConfigs
: allLLMConfigs;
const assignedConfig = roleAllConfigs.find((config) => config.id === currentAssignment);
const isAssigned = !!assignedConfig;
return (
<div key={key}>
<Card className="group relative overflow-hidden transition-all duration-200 border-accent bg-accent/20 hover:shadow-md h-full">
<CardContent className="p-4 md:p-5 space-y-4">
{/* Role Header */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div
className={cn(
"flex items-center justify-center w-9 h-9 rounded-lg shrink-0",
role.bgColor
)}
>
<IconComponent className={cn("w-4 h-4", role.color)} />
</div>
<div className="min-w-0">
<h4 className="text-sm font-semibold tracking-tight">{role.title}</h4>
<p className="text-[11px] text-muted-foreground/70 mt-0.5">
{role.description}
</p>
</div>
</div>
{savingRole === role.prefKey ? (
<Spinner size="sm" className="shrink-0 mt-0.5 text-muted-foreground" />
) : isAssigned ? (
<CircleCheck className="w-4 h-4 text-muted-foreground/40 shrink-0 mt-0.5" />
) : (
<CircleDashed className="w-4 h-4 text-muted-foreground/40 shrink-0 mt-0.5" />
)}
</div>
{/* Selector */}
<div className="space-y-1.5">
<Label className="text-xs font-medium text-muted-foreground">
Configuration
</Label>
<Select
value={assignedConfig ? assignedConfig.id.toString() : "unassigned"}
onValueChange={(value) => handleRoleAssignment(role.prefKey, value)}
>
<SelectTrigger className="w-full h-9 md:h-10 text-xs md:text-sm">
<SelectValue placeholder="Select a configuration" />
</SelectTrigger>
<SelectContent className="max-w-[calc(100vw-2rem)] select-none">
<SelectItem
value="unassigned"
className="text-xs md:text-sm py-1.5 md:py-2"
>
<span className="text-muted-foreground">Unassigned</span>
</SelectItem>
{/* Global Configurations */}
{roleGlobalConfigs.length > 0 && (
<SelectGroup>
<SelectLabel className="text-[11px] md:text-xs font-semibold text-muted-foreground px-2 py-1 md:py-1.5">
Global Configurations
</SelectLabel>
{roleGlobalConfigs.map((config) => {
const isAuto = "is_auto_mode" in config && config.is_auto_mode;
// Read billing_tier from the global config; default to "free"
// for legacy YAMLs / Auto stub. Premium gets a purple badge,
// free gets an emerald one — same palette as the chat
// model selector so the meaning is consistent across
// surfaces (issues E, H).
const billingTier =
("billing_tier" in config &&
typeof config.billing_tier === "string" &&
config.billing_tier) ||
"free";
const isPremium = billingTier === "premium";
return (
<SelectItem
key={config.id}
value={config.id.toString()}
className="text-xs md:text-sm py-1.5 md:py-2"
textValue={config.name}
>
<div className="flex items-center gap-1 md:gap-1.5 flex-wrap min-w-0">
<span className="truncate text-xs md:text-sm">
{config.name}
</span>
{isAuto ? (
<Badge
variant="secondary"
className="text-[8px] md:text-[9px] shrink-0 bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 [[data-slot=select-trigger]_&]:hidden"
>
Recommended
</Badge>
) : isPremium ? (
<Badge
variant="secondary"
className="text-[8px] md:text-[9px] shrink-0 bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300 border-0 [[data-slot=select-trigger]_&]:hidden"
>
Premium
</Badge>
) : (
<Badge
variant="secondary"
className="text-[8px] md:text-[9px] shrink-0 bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300 border-0 [[data-slot=select-trigger]_&]:hidden"
>
Free
</Badge>
)}
</div>
</SelectItem>
);
})}
</SelectGroup>
)}
{/* Custom Configurations */}
{roleUserConfigs.length > 0 && (
<SelectGroup>
<SelectLabel className="text-[11px] md:text-xs font-semibold text-muted-foreground px-2 py-1 md:py-1.5">
Your Configurations
</SelectLabel>
{roleUserConfigs.map((config) => (
<SelectItem
key={config.id}
value={config.id.toString()}
className="text-xs md:text-sm py-1.5 md:py-2"
>
<div className="flex items-center gap-1 md:gap-1.5 flex-wrap min-w-0">
<span className="truncate text-xs md:text-sm">
{config.name}
</span>
</div>
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
</div>
);
})}
</div>
)}
</div>
);
}

View file

@ -1,486 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, Dot, Info, Pencil, RefreshCw, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
import { deleteVisionLLMConfigMutationAtom } from "@/atoms/vision-llm-config/vision-llm-config-mutation.atoms";
import {
globalVisionLLMConfigsAtom,
visionLLMConfigsAtom,
} from "@/atoms/vision-llm-config/vision-llm-config-query.atoms";
import { VisionConfigDialog } from "@/components/shared/vision-config-dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import type { VisionLLMConfig } from "@/contracts/types/new-llm-config.types";
import { useMediaQuery } from "@/hooks/use-media-query";
import { getProviderIcon } from "@/lib/provider-icons";
import { cn } from "@/lib/utils";
interface VisionModelManagerProps {
searchSpaceId: number;
}
function getInitials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
const isDesktop = useMediaQuery("(min-width: 768px)");
const {
mutateAsync: deleteConfig,
isPending: isDeleting,
error: deleteError,
} = useAtomValue(deleteVisionLLMConfigMutationAtom);
const {
data: userConfigs,
isFetching: configsLoading,
error: fetchError,
refetch: refreshConfigs,
} = useAtomValue(visionLLMConfigsAtom);
const { data: globalConfigs = [], isFetching: globalLoading } = useAtomValue(
globalVisionLLMConfigsAtom
);
const { data: members } = useAtomValue(membersAtom);
const memberMap = useMemo(() => {
const map = new Map<string, { name: string; email?: string; avatarUrl?: string }>();
if (members) {
for (const m of members) {
map.set(m.user_id, {
name: m.user_display_name || m.user_email || "Unknown",
email: m.user_email || undefined,
avatarUrl: m.user_avatar_url || undefined,
});
}
}
return map;
}, [members]);
const { data: access } = useAtomValue(myAccessAtom);
const canCreate = useMemo(() => {
if (!access) return false;
if (access.is_owner) return true;
return access.permissions?.includes("vision_configs:create") ?? false;
}, [access]);
const canDelete = useMemo(() => {
if (!access) return false;
if (access.is_owner) return true;
return access.permissions?.includes("vision_configs:delete") ?? false;
}, [access]);
const canUpdate = canCreate;
const isReadOnly = !canCreate && !canDelete;
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingConfig, setEditingConfig] = useState<VisionLLMConfig | null>(null);
const [configToDelete, setConfigToDelete] = useState<VisionLLMConfig | null>(null);
const isLoading = configsLoading || globalLoading;
const errors = [deleteError, fetchError].filter(Boolean) as Error[];
const openEditDialog = (config: VisionLLMConfig) => {
setEditingConfig(config);
setIsDialogOpen(true);
};
const openNewDialog = () => {
setEditingConfig(null);
setIsDialogOpen(true);
};
const handleDelete = async () => {
if (!configToDelete) return;
try {
await deleteConfig({ id: configToDelete.id, name: configToDelete.name });
setConfigToDelete(null);
} catch {
// Error handled by mutation
}
};
return (
<div className="space-y-4 md:space-y-6">
<div className="flex items-center justify-between">
<Button
variant="secondary"
size="sm"
onClick={() => refreshConfigs()}
disabled={isLoading}
className="gap-2"
>
<RefreshCw className={cn("h-3.5 w-3.5", configsLoading && "animate-spin")} />
Refresh
</Button>
{canCreate && (
<Button
variant="outline"
onClick={openNewDialog}
className="gap-2 border-transparent bg-white text-[#1f1f1f] font-medium hover:bg-zinc-100 hover:text-[#1f1f1f] dark:border-transparent dark:bg-white dark:text-[#1f1f1f]"
>
Add Vision Model
</Button>
)}
</div>
{errors.map((err) => (
<div key={err?.message}>
<Alert variant="destructive" className="py-3">
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">{err?.message}</AlertDescription>
</Alert>
</div>
))}
{access && !isLoading && isReadOnly && (
<div>
<Alert>
<Info />
<AlertDescription>
<p>
You have <span className="font-medium">read-only</span> access to vision model
configurations. Contact a space owner to request additional permissions.
</p>
</AlertDescription>
</Alert>
</div>
)}
{access && !isLoading && !isReadOnly && (!canCreate || !canDelete) && (
<div>
<Alert>
<Info />
<AlertDescription>
<p>
You can{" "}
{[canCreate && "create and edit", canDelete && "delete"]
.filter(Boolean)
.join(" and ")}{" "}
vision model configurations
{!canDelete && ", but cannot delete them"}.
</p>
</AlertDescription>
</Alert>
</div>
)}
{(isLoading ||
globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0) && (
<Alert>
<Info />
<AlertDescription>
{isLoading ? (
<div className="flex min-h-[1.625em] items-center">
<Skeleton className="h-4 w-60 bg-accent-foreground/15" />
</div>
) : (
<p>
<span className="font-medium">
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length}{" "}
global vision{" "}
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length ===
1
? "model"
: "models"}
</span>{" "}
available from your administrator. {(() => {
const nonAuto = globalConfigs.filter(
(g) => !("is_auto_mode" in g && g.is_auto_mode)
);
const premium = nonAuto.filter(
(g) =>
"billing_tier" in g &&
(g as { billing_tier?: string }).billing_tier === "premium"
).length;
const free = nonAuto.length - premium;
if (premium > 0 && free > 0) {
return `${premium} premium, ${free} free.`;
}
if (premium > 0) {
return `All ${premium} premium — debits your shared credit pool.`;
}
return `All ${free} free.`;
})()}
</p>
)}
</AlertDescription>
</Alert>
)}
{/* Global Vision Models read-only cards with per-model Free/Premium
badges. Mirrors the badge palette used by the chat role selector
(`llm-role-manager.tsx`) so the meaning is consistent across
every model-configuration surface (chat / image / vision). */}
{!isLoading &&
globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0 && (
<div className="space-y-3">
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
{globalConfigs
.filter((g) => !("is_auto_mode" in g && g.is_auto_mode))
.map((cfg) => {
const billingTier =
("billing_tier" in cfg &&
typeof (cfg as { billing_tier?: string }).billing_tier === "string" &&
(cfg as { billing_tier?: string }).billing_tier) ||
"free";
const isPremium = billingTier === "premium";
return (
<Card
key={cfg.id}
className="group relative overflow-hidden transition-all duration-200 border-accent bg-accent/20 hover:shadow-md h-full"
>
<CardContent className="p-4 flex flex-col gap-3 h-full">
<div className="flex items-center gap-2 min-w-0">
<div className="shrink-0">
{getProviderIcon(cfg.provider, { className: "size-4" })}
</div>
<div className="min-w-0 flex-1 flex items-center gap-1.5">
<h4 className="text-sm font-semibold tracking-tight truncate">
{cfg.name}
</h4>
{isPremium ? (
<Badge
variant="secondary"
className="text-[8px] md:text-[9px] shrink-0 bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300 border-0"
>
Premium
</Badge>
) : (
<Badge
variant="secondary"
className="text-[8px] md:text-[9px] shrink-0 bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300 border-0"
>
Free
</Badge>
)}
</div>
</div>
{cfg.description && (
<p className="text-[11px] text-muted-foreground/70 line-clamp-2">
{cfg.description}
</p>
)}
<div className="mt-auto space-y-2">
<Separator className="bg-accent" />
<div className="flex items-center">
<span className="text-[11px] text-muted-foreground/60 truncate">
{cfg.model_name}
</span>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
)}
{isLoading && (
<div className="space-y-4 md:space-y-6">
<div className="space-y-4">
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
<Card key={key} className="border-accent bg-accent/20">
<CardContent className="p-4 flex flex-col gap-3 min-h-32">
<Skeleton className="h-4 w-32 md:w-40 bg-accent" />
<Skeleton className="h-3 w-full bg-accent" />
<Skeleton className="h-3 w-24 md:w-28 bg-accent mt-auto" />
</CardContent>
</Card>
))}
</div>
</div>
</div>
)}
{!isLoading && (
<div className="space-y-4 md:space-y-6">
{(userConfigs?.length ?? 0) === 0 ? (
<Card className="border-0 bg-transparent shadow-none">
<CardContent className="flex flex-col items-center justify-center py-10 md:py-16 text-center">
<h3 className="text-sm md:text-base font-semibold mb-2">No Vision Models Yet</h3>
<p className="text-[11px] md:text-xs text-muted-foreground max-w-sm mb-4">
{canCreate
? "Add your own vision-capable model (GPT-4o, Claude, Gemini, etc.)"
: "No vision models have been added to this space yet. Contact a space owner to add one."}
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
{userConfigs?.map((config) => {
const member = config.user_id ? memberMap.get(config.user_id) : null;
return (
<div key={config.id}>
<Card className="group relative overflow-hidden transition-all duration-200 border-accent bg-accent/20 hover:shadow-md h-full">
<CardContent className="p-4 flex flex-col gap-3 h-full">
{/* Header: Icon + Name + Actions */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<div className="shrink-0">
{getProviderIcon(config.provider, { className: "size-4" })}
</div>
<div className="min-w-0 flex-1">
<h4 className="text-sm font-semibold tracking-tight truncate">
{config.name}
</h4>
{config.description && (
<p className="text-[11px] text-muted-foreground/70 truncate mt-0.5">
{config.description}
</p>
)}
</div>
</div>
{(canUpdate || canDelete) && (
<div className="flex items-center gap-1 shrink-0 sm:w-0 sm:overflow-hidden sm:group-hover:w-auto sm:opacity-0 sm:group-hover:opacity-100 transition-all duration-150">
{canUpdate && (
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => openEditDialog(config)}
className="h-6 w-6 text-muted-foreground hover:text-accent-foreground"
>
<Pencil className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{canDelete && (
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setConfigToDelete(config)}
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)}
</div>
{/* Footer: Date + Creator */}
<div className="mt-auto space-y-2">
<Separator className="bg-accent" />
<div className="flex items-center">
<span className="shrink-0 text-[11px] text-muted-foreground/60 whitespace-nowrap">
{new Date(config.created_at).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</span>
{member && (
<>
<Dot className="h-4 w-4 text-muted-foreground/30 shrink-0" />
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<div className="min-w-0 flex items-center gap-1.5 cursor-default">
<Avatar className="size-4.5 shrink-0">
{member.avatarUrl && (
<AvatarImage src={member.avatarUrl} alt={member.name} />
)}
<AvatarFallback className="text-[9px]">
{getInitials(member.name)}
</AvatarFallback>
</Avatar>
<span className="text-[11px] text-muted-foreground/60 truncate">
{member.name}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{member.email || member.name}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
)}
</div>
</div>
</CardContent>
</Card>
</div>
);
})}
</div>
)}
</div>
)}
<VisionConfigDialog
open={isDialogOpen}
onOpenChange={(open) => {
setIsDialogOpen(open);
if (!open) setEditingConfig(null);
}}
config={editingConfig}
isGlobal={false}
searchSpaceId={searchSpaceId}
mode={editingConfig ? "edit" : "create"}
/>
<AlertDialog
open={!!configToDelete}
onOpenChange={(open) => !open && setConfigToDelete(null)}
>
<AlertDialogContent className="select-none">
<AlertDialogHeader>
<AlertDialogTitle>Delete Vision Model</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{configToDelete?.name}</span>?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
<span className={isDeleting ? "opacity-0" : ""}>Delete</span>
{isDeleting && <Spinner size="sm" className="absolute" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View file

@ -1,456 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, Check, ChevronsUpDown } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
createImageGenConfigMutationAtom,
updateImageGenConfigMutationAtom,
} from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { IMAGE_GEN_MODELS, IMAGE_GEN_PROVIDERS } from "@/contracts/enums/image-gen-providers";
import type {
GlobalImageGenConfig,
ImageGenerationConfig,
ImageGenProvider,
} from "@/contracts/types/new-llm-config.types";
import { cn } from "@/lib/utils";
interface ImageConfigDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
config: ImageGenerationConfig | GlobalImageGenConfig | null;
isGlobal: boolean;
searchSpaceId: number;
mode: "create" | "edit" | "view";
defaultProvider?: string;
}
const INITIAL_FORM = {
name: "",
description: "",
provider: "",
model_name: "",
api_key: "",
api_base: "",
api_version: "",
};
export function ImageConfigDialog({
open,
onOpenChange,
config,
isGlobal,
searchSpaceId,
mode,
defaultProvider,
}: ImageConfigDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState(INITIAL_FORM);
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open) {
if (mode === "edit" && config && !isGlobal) {
setFormData({
name: config.name || "",
description: config.description || "",
provider: config.provider || "",
model_name: config.model_name || "",
api_key: (config as ImageGenerationConfig).api_key || "",
api_base: config.api_base || "",
api_version: config.api_version || "",
});
} else if (mode === "create") {
setFormData({ ...INITIAL_FORM, provider: defaultProvider ?? "" });
}
setScrollPos("top");
}
}, [open, mode, config, isGlobal, defaultProvider]);
const { mutateAsync: createConfig } = useAtomValue(createImageGenConfigMutationAtom);
const { mutateAsync: updateConfig } = useAtomValue(updateImageGenConfigMutationAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
}, []);
const suggestedModels = useMemo(() => {
if (!formData.provider) return [];
return IMAGE_GEN_MODELS.filter((m) => m.provider === formData.provider);
}, [formData.provider]);
const getTitle = () => {
if (mode === "create") return "Add Image Model";
if (isGlobal) return "View Global Image Model";
return "Edit Image Model";
};
const getSubtitle = () => {
if (mode === "create") return "Set up a new image generation provider";
if (isGlobal) return "Read-only global configuration";
return "Update your image model settings";
};
const handleSubmit = useCallback(async () => {
setIsSubmitting(true);
try {
if (mode === "create") {
const result = await createConfig({
name: formData.name,
provider: formData.provider as ImageGenProvider,
model_name: formData.model_name,
api_key: formData.api_key,
api_base: formData.api_base || undefined,
api_version: formData.api_version || undefined,
description: formData.description || undefined,
search_space_id: searchSpaceId,
});
if (result?.id) {
await updatePreferences({
search_space_id: searchSpaceId,
data: { image_generation_config_id: result.id },
});
}
onOpenChange(false);
} else if (!isGlobal && config) {
await updateConfig({
id: config.id,
data: {
name: formData.name,
description: formData.description || undefined,
provider: formData.provider as ImageGenProvider,
model_name: formData.model_name,
api_key: formData.api_key,
api_base: formData.api_base || undefined,
api_version: formData.api_version || undefined,
},
});
onOpenChange(false);
}
} catch (error) {
console.error("Failed to save image config:", error);
toast.error("Failed to save image model");
} finally {
setIsSubmitting(false);
}
}, [
mode,
isGlobal,
config,
formData,
searchSpaceId,
createConfig,
updateConfig,
updatePreferences,
onOpenChange,
]);
const handleUseGlobalConfig = useCallback(async () => {
if (!config || !isGlobal) return;
setIsSubmitting(true);
try {
await updatePreferences({
search_space_id: searchSpaceId,
data: { image_generation_config_id: config.id },
});
toast.success(`Now using ${config.name}`);
onOpenChange(false);
} catch (error) {
console.error("Failed to set image model:", error);
toast.error("Failed to set image model");
} finally {
setIsSubmitting(false);
}
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
const isFormValid = formData.name && formData.provider && formData.model_name && formData.api_key;
const selectedProvider = IMAGE_GEN_PROVIDERS.find((p) => p.value === formData.provider);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-lg h-[85vh] flex flex-col p-0 gap-0 overflow-hidden"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogTitle className="sr-only">{getTitle()}</DialogTitle>
{/* Header */}
<div className="flex items-start justify-between px-6 pt-6 pb-4 pr-14">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
{isGlobal && mode !== "create" && (
<Badge variant="secondary" className="text-[10px]">
Global
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
{config && mode !== "create" && (
<p className="text-xs font-mono text-muted-foreground/70">{config.model_name}</p>
)}
</div>
</div>
{/* Scrollable content */}
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto px-6 py-5"
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"})`,
}}
>
{isGlobal && config && (
<>
<Alert className="mb-5 border-amber-500/30 bg-amber-500/5">
<AlertCircle className="size-4 text-amber-500" />
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
Global configurations are read-only. To customize, create a new model.
</AlertDescription>
</Alert>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Name
</div>
<p className="text-sm font-medium">{config.name}</p>
</div>
{config.description && (
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Description
</div>
<p className="text-sm text-muted-foreground">{config.description}</p>
</div>
)}
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Provider
</div>
<p className="text-sm font-medium">{config.provider}</p>
</div>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Model
</div>
<p className="text-sm font-medium font-mono">{config.model_name}</p>
</div>
</div>
</div>
</>
)}
{(mode === "create" || (mode === "edit" && !isGlobal)) && (
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-medium">Name *</Label>
<Input
placeholder="e.g., My DALL-E 3"
value={formData.name}
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Description</Label>
<Input
placeholder="Optional description"
value={formData.description}
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
/>
</div>
<Separator className="bg-popover-border" />
<div className="space-y-2">
<Label className="text-sm font-medium">Provider *</Label>
<Select
value={formData.provider}
onValueChange={(val) =>
setFormData((p) => ({ ...p, provider: val, model_name: "" }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{IMAGE_GEN_PROVIDERS.map((p) => (
<SelectItem key={p.value} value={p.value} description={p.example}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Model Name *</Label>
{suggestedModels.length > 0 ? (
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal bg-transparent"
>
{formData.model_name || "Select or type a model..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command className="bg-transparent">
<CommandInput
placeholder="Search or type model..."
value={formData.model_name}
onValueChange={(val) => setFormData((p) => ({ ...p, model_name: val }))}
/>
<CommandList>
<CommandEmpty>
<span className="text-xs text-muted-foreground">
Type a custom model name
</span>
</CommandEmpty>
<CommandGroup>
{suggestedModels.map((m) => (
<CommandItem
key={m.value}
value={m.value}
onSelect={() => {
setFormData((p) => ({ ...p, model_name: m.value }));
setModelComboboxOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.model_name === m.value ? "opacity-100" : "opacity-0"
)}
/>
<span className="font-mono text-sm">{m.value}</span>
<span className="ml-2 text-xs text-muted-foreground">
{m.label}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
placeholder="e.g., dall-e-3"
value={formData.model_name}
onChange={(e) => setFormData((p) => ({ ...p, model_name: e.target.value }))}
/>
)}
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">API Key *</Label>
<Input
type="password"
placeholder="sk-..."
value={formData.api_key}
onChange={(e) => setFormData((p) => ({ ...p, api_key: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">API Base URL</Label>
<Input
placeholder={selectedProvider?.apiBase || "Optional"}
value={formData.api_base}
onChange={(e) => setFormData((p) => ({ ...p, api_base: e.target.value }))}
/>
</div>
{formData.provider === "AZURE_OPENAI" && (
<div className="space-y-2">
<Label className="text-sm font-medium">API Version (Azure)</Label>
<Input
placeholder="2024-02-15-preview"
value={formData.api_version}
onChange={(e) => setFormData((p) => ({ ...p, api_version: e.target.value }))}
/>
</div>
)}
</div>
)}
</div>
{/* Fixed footer */}
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
className="text-sm h-9"
>
Cancel
</Button>
{mode === "create" || (mode === "edit" && !isGlobal) ? (
<Button
onClick={handleSubmit}
disabled={isSubmitting || !isFormValid}
className="relative text-sm h-9 min-w-[120px]"
>
<span className={isSubmitting ? "opacity-0" : ""}>
{mode === "edit" ? "Save Changes" : "Add Model"}
</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>
) : isGlobal && config ? (
<Button
className="relative text-sm h-9"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
<span className={isSubmitting ? "opacity-0" : ""}>Use This Model</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>
) : null}
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -1,527 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { Check, ChevronDown, ChevronsUpDown } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { type Resolver, useForm } from "react-hook-form";
import { z } from "zod";
import {
defaultSystemInstructionsAtom,
modelListAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers";
import type { CreateNewLLMConfigRequest } from "@/contracts/types/new-llm-config.types";
import { cn } from "@/lib/utils";
import InferenceParamsEditor from "../inference-params-editor";
// Form schema with zod
const formSchema = z.object({
name: z.string().min(1, "Name is required").max(100),
description: z.string().max(500).optional().nullable(),
provider: z.string().min(1, "Provider is required"),
custom_provider: z.string().max(100).optional().nullable(),
model_name: z.string().min(1, "Model name is required").max(100),
api_key: z.string().min(1, "API key is required"),
api_base: z.string().max(500).optional().nullable(),
litellm_params: z.record(z.string(), z.any()).optional().nullable(),
system_instructions: z.string().default(""),
use_default_system_instructions: z.boolean().default(true),
citations_enabled: z.boolean().default(true),
search_space_id: z.number(),
});
type FormValues = z.infer<typeof formSchema>;
export type LLMConfigFormData = CreateNewLLMConfigRequest;
interface LLMConfigFormProps {
initialData?: Partial<LLMConfigFormData>;
searchSpaceId: number;
onSubmit: (data: LLMConfigFormData) => Promise<void>;
mode?: "create" | "edit";
showAdvanced?: boolean;
formId?: string;
}
export function LLMConfigForm({
initialData,
searchSpaceId,
onSubmit,
mode = "create",
showAdvanced = true,
formId,
}: LLMConfigFormProps) {
const { data: defaultInstructions, isSuccess: defaultInstructionsLoaded } = useAtomValue(
defaultSystemInstructionsAtom
);
const { data: dynamicModels } = useAtomValue(modelListAtom);
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [systemInstructionsOpen, setSystemInstructionsOpen] = useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema) as Resolver<FormValues>,
defaultValues: {
name: initialData?.name ?? "",
description: initialData?.description ?? "",
provider: initialData?.provider ?? "",
custom_provider: initialData?.custom_provider ?? "",
model_name: initialData?.model_name ?? "",
api_key: initialData?.api_key ?? "",
api_base: initialData?.api_base ?? "",
litellm_params: initialData?.litellm_params ?? {},
system_instructions: initialData?.system_instructions ?? "",
use_default_system_instructions: initialData?.use_default_system_instructions ?? true,
citations_enabled: initialData?.citations_enabled ?? true,
search_space_id: searchSpaceId,
},
});
// Load default instructions when available (only for new configs)
useEffect(() => {
if (
mode === "create" &&
defaultInstructionsLoaded &&
defaultInstructions?.default_system_instructions &&
!form.getValues("system_instructions")
) {
form.setValue("system_instructions", defaultInstructions.default_system_instructions);
}
}, [defaultInstructionsLoaded, defaultInstructions, mode, form]);
const watchProvider = form.watch("provider");
const selectedProvider = LLM_PROVIDERS.find((p) => p.value === watchProvider);
const availableModels = useMemo(
() => (dynamicModels ?? []).filter((m) => m.provider === watchProvider),
[dynamicModels, watchProvider]
);
const handleProviderChange = (value: string) => {
form.setValue("provider", value);
form.setValue("model_name", "");
// Auto-fill API base for certain providers
const provider = LLM_PROVIDERS.find((p) => p.value === value);
if (provider?.apiBase) {
form.setValue("api_base", provider.apiBase);
}
};
const handleFormSubmit = async (values: FormValues) => {
await onSubmit(values as LLMConfigFormData);
};
return (
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(handleFormSubmit)} className="space-y-6">
{/* Model Configuration Section */}
<div className="space-y-4">
<div className="text-xs sm:text-sm font-medium text-muted-foreground">
Model Configuration
</div>
{/* Name & Description */}
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Configuration Name</FormLabel>
<FormControl>
<Input placeholder="e.g., My GPT-4 Agent" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground text-xs sm:text-sm">
Description
<Badge variant="outline" className="ml-2 text-[10px]">
Optional
</Badge>
</FormLabel>
<FormControl>
<Input placeholder="Brief description" {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Provider Selection */}
<FormField
control={form.control}
name="provider"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">LLM Provider</FormLabel>
<Select value={field.value} onValueChange={handleProviderChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
</FormControl>
<SelectContent className="max-h-[300px]">
{LLM_PROVIDERS.map((provider) => (
<SelectItem
key={provider.value}
value={provider.value}
description={provider.description}
>
{provider.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Custom Provider (conditional) */}
{watchProvider === "CUSTOM" && (
<FormField
control={form.control}
name="custom_provider"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Custom Provider Name</FormLabel>
<FormControl>
<Input placeholder="my-custom-provider" {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{/* Model Name with Combobox */}
<FormField
control={form.control}
name="model_name"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="text-xs sm:text-sm">Model Name</FormLabel>
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
aria-expanded={modelComboboxOpen}
className={cn(
"w-full justify-between border-popover-border bg-transparent font-normal",
!field.value && "text-muted-foreground"
)}
>
{field.value || "Select a model"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command shouldFilter={false} className="bg-transparent">
<CommandInput
placeholder={selectedProvider?.example || "Search model name"}
value={field.value}
onValueChange={field.onChange}
/>
<CommandList className="max-h-[300px]">
<CommandEmpty>
<div className="py-3 text-center text-sm text-muted-foreground">
{field.value ? `Using: "${field.value}"` : "Type your model name"}
</div>
</CommandEmpty>
{availableModels.length > 0 && (
<CommandGroup heading="Suggested Models">
{availableModels
.filter(
(model) =>
!field.value ||
model.value.toLowerCase().includes(field.value.toLowerCase()) ||
model.label.toLowerCase().includes(field.value.toLowerCase())
)
.slice(0, 50)
.map((model) => (
<CommandItem
key={model.value}
value={model.value}
onSelect={(value) => {
field.onChange(value);
setModelComboboxOpen(false);
}}
className="py-2"
>
<Check
className={cn(
"mr-2 h-4 w-4",
field.value === model.value ? "opacity-100" : "opacity-0"
)}
/>
<div>
<div className="font-medium">{model.label}</div>
{model.contextWindow && (
<div className="text-xs text-muted-foreground">
Context: {model.contextWindow}
</div>
)}
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedProvider?.example && (
<FormDescription className="text-[10px] sm:text-xs">
Example: {selectedProvider.example}
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
{/* API Credentials */}
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">API Key</FormLabel>
<FormControl>
<Input
type="password"
placeholder={watchProvider === "OLLAMA" ? "Any value" : "sk-..."}
{...field}
/>
</FormControl>
{watchProvider === "OLLAMA" && (
<FormDescription className="text-[10px] sm:text-xs">
Ollama doesn&apos;t require auth enter any value
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_base"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2 text-xs sm:text-sm">
API Base URL
{selectedProvider?.apiBase && (
<Badge variant="secondary" className="text-[10px]">
Auto-filled
</Badge>
)}
</FormLabel>
<FormControl>
<Input
placeholder={selectedProvider?.apiBase || "https://api.example.com/v1"}
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Ollama Quick Actions */}
{watchProvider === "OLLAMA" && (
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => form.setValue("api_base", "http://localhost:11434")}
>
localhost:11434
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => form.setValue("api_base", "http://host.docker.internal:11434")}
>
Docker
</Button>
</div>
)}
</div>
{/* Advanced Parameters */}
{showAdvanced && (
<>
<Separator className="bg-popover-border" />
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-between px-0 py-2 text-xs font-medium text-muted-foreground hover:bg-transparent hover:text-accent-foreground sm:text-sm"
>
<span>Advanced Parameters</span>
<ChevronDown
className={cn(
"h-4 w-4 transition-transform duration-200",
advancedOpen && "rotate-180"
)}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
<FormField
control={form.control}
name="litellm_params"
render={({ field }) => (
<FormItem>
<FormControl>
<InferenceParamsEditor
params={field.value || {}}
setParams={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CollapsibleContent>
</Collapsible>
</>
)}
{/* System Instructions & Citations Section */}
<Separator className="bg-popover-border" />
<Collapsible open={systemInstructionsOpen} onOpenChange={setSystemInstructionsOpen}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-between px-0 py-2 text-xs font-medium text-muted-foreground hover:bg-transparent hover:text-accent-foreground sm:text-sm"
>
<span>System Instructions</span>
<ChevronDown
className={cn(
"h-4 w-4 transition-transform duration-200",
systemInstructionsOpen && "rotate-180"
)}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{/* System Instructions */}
<FormField
control={form.control}
name="system_instructions"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel className="text-xs sm:text-sm">Instructions for the AI</FormLabel>
{defaultInstructions && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
field.onChange(defaultInstructions.default_system_instructions)
}
className="h-7 text-[10px] sm:text-xs text-muted-foreground hover:text-accent-foreground"
>
Reset to Default
</Button>
)}
</div>
<FormControl>
<Textarea
placeholder="Enter system instructions for the AI..."
rows={6}
className="font-mono text-[11px] sm:text-xs resize-none"
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Use {"{resolved_today}"} to include today&apos;s date dynamically
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Citations Toggle */}
<FormField
control={form.control}
name="citations_enabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3 bg-muted/30">
<div className="space-y-0.5">
<FormLabel className="text-xs sm:text-sm font-medium">
Enable Citations
</FormLabel>
<FormDescription className="text-[10px] sm:text-xs">
Include [citation:id] references to source documents
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
</CollapsibleContent>
</Collapsible>
</form>
</Form>
);
}

View file

@ -1,339 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { toast } from "sonner";
import {
createNewLLMConfigMutationAtom,
updateLLMPreferencesMutationAtom,
updateNewLLMConfigMutationAtom,
} from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import type {
GlobalNewLLMConfig,
LiteLLMProvider,
NewLLMConfigPublic,
} from "@/contracts/types/new-llm-config.types";
interface ModelConfigDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
config: NewLLMConfigPublic | GlobalNewLLMConfig | null;
isGlobal: boolean;
searchSpaceId: number;
mode: "create" | "edit" | "view";
defaultProvider?: string;
}
export function ModelConfigDialog({
open,
onOpenChange,
config,
isGlobal,
searchSpaceId,
mode,
defaultProvider,
}: ModelConfigDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
const scrollRef = useRef<HTMLDivElement>(null);
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
}, []);
const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom);
const { mutateAsync: updateConfig } = useAtomValue(updateNewLLMConfigMutationAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
const getTitle = () => {
if (mode === "create") return "Add New Configuration";
if (isGlobal) return "View Global Configuration";
return "Edit Configuration";
};
const getSubtitle = () => {
if (mode === "create") return "Set up a new LLM provider for this search space";
if (isGlobal) return "Read-only global configuration";
return "Update your configuration settings";
};
const handleSubmit = useCallback(
async (data: LLMConfigFormData) => {
setIsSubmitting(true);
try {
if (mode === "create") {
const result = await createConfig({
...data,
search_space_id: searchSpaceId,
});
if (result?.id) {
await updatePreferences({
search_space_id: searchSpaceId,
data: {
agent_llm_id: result.id,
},
});
}
onOpenChange(false);
} else if (!isGlobal && config) {
await updateConfig({
id: config.id,
data: {
name: data.name,
description: data.description,
provider: data.provider,
custom_provider: data.custom_provider,
model_name: data.model_name,
api_key: data.api_key,
api_base: data.api_base,
litellm_params: data.litellm_params,
system_instructions: data.system_instructions,
use_default_system_instructions: data.use_default_system_instructions,
citations_enabled: data.citations_enabled,
},
});
onOpenChange(false);
}
} catch (error) {
console.error("Failed to save configuration:", error);
} finally {
setIsSubmitting(false);
}
},
[
mode,
isGlobal,
config,
searchSpaceId,
createConfig,
updateConfig,
updatePreferences,
onOpenChange,
]
);
const handleUseGlobalConfig = useCallback(async () => {
if (!config || !isGlobal) return;
setIsSubmitting(true);
try {
await updatePreferences({
search_space_id: searchSpaceId,
data: {
agent_llm_id: config.id,
},
});
toast.success(`Now using ${config.name}`);
onOpenChange(false);
} catch (error) {
console.error("Failed to set model:", error);
} finally {
setIsSubmitting(false);
}
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-lg h-[85vh] flex flex-col p-0 gap-0 overflow-hidden"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogTitle className="sr-only">{getTitle()}</DialogTitle>
{/* Header */}
<div className="flex items-start justify-between px-6 pt-6 pb-4 pr-14">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
{isGlobal && mode !== "create" && (
<Badge variant="secondary" className="text-[10px]">
Global
</Badge>
)}
{!isGlobal && mode !== "create" && (
<Badge variant="outline" className="text-[10px]">
Custom
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
{config && mode !== "create" && (
<p className="text-xs font-mono text-muted-foreground/70">{config.model_name}</p>
)}
</div>
</div>
{/* Scrollable content */}
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto px-6 py-5"
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"})`,
}}
>
{isGlobal && mode !== "create" && (
<Alert className="mb-5 border-amber-500/30 bg-amber-500/5">
<AlertCircle className="size-4 text-amber-500" />
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
Global configurations are read-only. To customize settings, create a new
configuration based on this template.
</AlertDescription>
</Alert>
)}
{mode === "create" ? (
<LLMConfigForm
key={defaultProvider ?? "no-provider"}
searchSpaceId={searchSpaceId}
onSubmit={handleSubmit}
mode="create"
formId="model-config-form"
initialData={
defaultProvider ? { provider: defaultProvider as LiteLLMProvider } : undefined
}
/>
) : isGlobal && config ? (
<div className="space-y-6">
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Configuration Name
</div>
<p className="text-sm font-medium">{config.name}</p>
</div>
{config.description && (
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Description
</div>
<p className="text-sm text-muted-foreground">{config.description}</p>
</div>
)}
</div>
<div className="h-px bg-border/50" />
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Provider
</div>
<p className="text-sm font-medium">{config.provider}</p>
</div>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Model
</div>
<p className="text-sm font-medium font-mono">{config.model_name}</p>
</div>
</div>
<div className="h-px bg-border/50" />
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Citations
</div>
<Badge
variant={config.citations_enabled ? "default" : "secondary"}
className="w-fit"
>
{config.citations_enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
</div>
{config.system_instructions && (
<>
<div className="h-px bg-border/50" />
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
System Instructions
</div>
<div className="p-3 rounded-lg bg-muted/50 border border-border/50">
<p className="text-xs font-mono text-muted-foreground whitespace-pre-wrap line-clamp-10">
{config.system_instructions}
</p>
</div>
</div>
</>
)}
</div>
</div>
) : config ? (
<LLMConfigForm
searchSpaceId={searchSpaceId}
initialData={{
name: config.name,
description: config.description,
provider: config.provider as LiteLLMProvider,
custom_provider: config.custom_provider,
model_name: config.model_name,
api_key: "api_key" in config ? (config.api_key as string) : "",
api_base: config.api_base,
litellm_params: config.litellm_params,
system_instructions: config.system_instructions,
use_default_system_instructions: config.use_default_system_instructions,
citations_enabled: config.citations_enabled,
search_space_id: searchSpaceId,
}}
onSubmit={handleSubmit}
mode="edit"
formId="model-config-form"
/>
) : null}
</div>
{/* Fixed footer */}
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
className="text-sm h-9"
>
Cancel
</Button>
{mode === "create" || (!isGlobal && config) ? (
<Button
type="submit"
form="model-config-form"
disabled={isSubmitting}
className="relative text-sm h-9 min-w-[120px]"
>
<span className={isSubmitting ? "opacity-0" : ""}>
{mode === "edit" ? "Save Changes" : "Add Model"}
</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>
) : isGlobal && config ? (
<Button
className="relative text-sm h-9"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
<span className={isSubmitting ? "opacity-0" : ""}>Use This Model</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>
) : null}
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -1,478 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, Check, ChevronsUpDown } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
import {
createVisionLLMConfigMutationAtom,
updateVisionLLMConfigMutationAtom,
} from "@/atoms/vision-llm-config/vision-llm-config-mutation.atoms";
import { visionModelListAtom } from "@/atoms/vision-llm-config/vision-llm-config-query.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { VISION_PROVIDERS } from "@/contracts/enums/vision-providers";
import type {
GlobalVisionLLMConfig,
VisionLLMConfig,
VisionProvider,
} from "@/contracts/types/new-llm-config.types";
import { cn } from "@/lib/utils";
interface VisionConfigDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
config: VisionLLMConfig | GlobalVisionLLMConfig | null;
isGlobal: boolean;
searchSpaceId: number;
mode: "create" | "edit" | "view";
defaultProvider?: string;
}
const INITIAL_FORM = {
name: "",
description: "",
provider: "",
model_name: "",
api_key: "",
api_base: "",
api_version: "",
};
export function VisionConfigDialog({
open,
onOpenChange,
config,
isGlobal,
searchSpaceId,
mode,
defaultProvider,
}: VisionConfigDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState(INITIAL_FORM);
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open) {
if (mode === "edit" && config && !isGlobal) {
setFormData({
name: config.name || "",
description: config.description || "",
provider: config.provider || "",
model_name: config.model_name || "",
api_key: (config as VisionLLMConfig).api_key || "",
api_base: config.api_base || "",
api_version: (config as VisionLLMConfig).api_version || "",
});
} else if (mode === "create") {
setFormData({ ...INITIAL_FORM, provider: defaultProvider ?? "" });
}
setScrollPos("top");
}
}, [open, mode, config, isGlobal, defaultProvider]);
const { mutateAsync: createConfig } = useAtomValue(createVisionLLMConfigMutationAtom);
const { mutateAsync: updateConfig } = useAtomValue(updateVisionLLMConfigMutationAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
}, []);
const getTitle = () => {
if (mode === "create") return "Add Vision Model";
if (isGlobal) return "View Global Vision Model";
return "Edit Vision Model";
};
const getSubtitle = () => {
if (mode === "create") return "Set up a new vision-capable LLM provider";
if (isGlobal) return "Read-only global configuration";
return "Update your vision model settings";
};
const handleSubmit = useCallback(async () => {
setIsSubmitting(true);
try {
if (mode === "create") {
const result = await createConfig({
name: formData.name,
provider: formData.provider as VisionProvider,
model_name: formData.model_name,
api_key: formData.api_key,
api_base: formData.api_base || undefined,
api_version: formData.api_version || undefined,
description: formData.description || undefined,
search_space_id: searchSpaceId,
});
if (result?.id) {
await updatePreferences({
search_space_id: searchSpaceId,
data: { vision_llm_config_id: result.id },
});
}
onOpenChange(false);
} else if (!isGlobal && config) {
await updateConfig({
id: config.id,
data: {
name: formData.name,
description: formData.description || undefined,
provider: formData.provider as VisionProvider,
model_name: formData.model_name,
api_key: formData.api_key,
api_base: formData.api_base || undefined,
api_version: formData.api_version || undefined,
},
});
onOpenChange(false);
}
} catch (error) {
console.error("Failed to save vision config:", error);
toast.error("Failed to save vision model");
} finally {
setIsSubmitting(false);
}
}, [
mode,
isGlobal,
config,
formData,
searchSpaceId,
createConfig,
updateConfig,
updatePreferences,
onOpenChange,
]);
const handleUseGlobalConfig = useCallback(async () => {
if (!config || !isGlobal) return;
setIsSubmitting(true);
try {
await updatePreferences({
search_space_id: searchSpaceId,
data: { vision_llm_config_id: config.id },
});
toast.success(`Now using ${config.name}`);
onOpenChange(false);
} catch (error) {
console.error("Failed to set vision model:", error);
toast.error("Failed to set vision model");
} finally {
setIsSubmitting(false);
}
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
const { data: dynamicModels } = useAtomValue(visionModelListAtom);
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
const availableModels = useMemo(
() => (dynamicModels ?? []).filter((m) => m.provider === formData.provider),
[dynamicModels, formData.provider]
);
const isFormValid = formData.name && formData.provider && formData.model_name && formData.api_key;
const selectedProvider = VISION_PROVIDERS.find((p) => p.value === formData.provider);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-lg h-[85vh] flex flex-col p-0 gap-0 overflow-hidden"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogTitle className="sr-only">{getTitle()}</DialogTitle>
<div className="flex items-start justify-between px-6 pt-6 pb-4 pr-14">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
{isGlobal && mode !== "create" && (
<Badge variant="secondary" className="text-[10px]">
Global
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
{config && mode !== "create" && (
<p className="text-xs font-mono text-muted-foreground/70">{config.model_name}</p>
)}
</div>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto px-6 py-5"
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"})`,
}}
>
{isGlobal && config && (
<>
<Alert className="mb-5 border-amber-500/30 bg-amber-500/5">
<AlertCircle className="size-4 text-amber-500" />
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
Global configurations are read-only. To customize, create a new model.
</AlertDescription>
</Alert>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Name
</div>
<p className="text-sm font-medium">{config.name}</p>
</div>
{config.description && (
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Description
</div>
<p className="text-sm text-muted-foreground">{config.description}</p>
</div>
)}
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Provider
</div>
<p className="text-sm font-medium">{config.provider}</p>
</div>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Model
</div>
<p className="text-sm font-medium font-mono">{config.model_name}</p>
</div>
</div>
</div>
</>
)}
{(mode === "create" || (mode === "edit" && !isGlobal)) && (
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-medium">Name *</Label>
<Input
placeholder="e.g., My GPT-4o Vision"
value={formData.name}
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Description</Label>
<Input
placeholder="Optional description"
value={formData.description}
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
/>
</div>
<Separator className="bg-popover-border" />
<div className="space-y-2">
<Label className="text-sm font-medium">Provider *</Label>
<Select
value={formData.provider}
onValueChange={(val) =>
setFormData((p) => ({ ...p, provider: val, model_name: "" }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{VISION_PROVIDERS.map((p) => (
<SelectItem key={p.value} value={p.value} description={p.example}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Model Name *</Label>
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modelComboboxOpen}
className={cn(
"w-full justify-between font-normal bg-transparent",
!formData.model_name && "text-muted-foreground"
)}
>
{formData.model_name || "Select a model"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command shouldFilter={false} className="bg-transparent">
<CommandInput
placeholder={selectedProvider?.example || "Search model name"}
value={formData.model_name}
onValueChange={(val) => setFormData((p) => ({ ...p, model_name: val }))}
/>
<CommandList className="max-h-[300px]">
<CommandEmpty>
<div className="py-3 text-center text-sm text-muted-foreground">
{formData.model_name
? `Using: "${formData.model_name}"`
: "Type your model name"}
</div>
</CommandEmpty>
{availableModels.length > 0 && (
<CommandGroup heading="Suggested Models">
{availableModels
.filter(
(model) =>
!formData.model_name ||
model.value
.toLowerCase()
.includes(formData.model_name.toLowerCase()) ||
model.label
.toLowerCase()
.includes(formData.model_name.toLowerCase())
)
.slice(0, 50)
.map((model) => (
<CommandItem
key={model.value}
value={model.value}
onSelect={(value) => {
setFormData((p) => ({
...p,
model_name: value,
}));
setModelComboboxOpen(false);
}}
className="py-2"
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.model_name === model.value
? "opacity-100"
: "opacity-0"
)}
/>
<div>
<div className="font-medium">{model.label}</div>
{model.contextWindow && (
<div className="text-xs text-muted-foreground">
Context: {model.contextWindow}
</div>
)}
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">API Key *</Label>
<Input
type="password"
placeholder="sk-..."
value={formData.api_key}
onChange={(e) => setFormData((p) => ({ ...p, api_key: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">API Base URL</Label>
<Input
placeholder={selectedProvider?.apiBase || "Optional"}
value={formData.api_base}
onChange={(e) => setFormData((p) => ({ ...p, api_base: e.target.value }))}
/>
</div>
{formData.provider === "AZURE_OPENAI" && (
<div className="space-y-2">
<Label className="text-sm font-medium">API Version (Azure)</Label>
<Input
placeholder="2024-02-15-preview"
value={formData.api_version}
onChange={(e) => setFormData((p) => ({ ...p, api_version: e.target.value }))}
/>
</div>
)}
</div>
)}
</div>
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
className="text-sm h-9"
>
Cancel
</Button>
{mode === "create" || (mode === "edit" && !isGlobal) ? (
<Button
onClick={handleSubmit}
disabled={isSubmitting || !isFormValid}
className="relative text-sm h-9 min-w-[120px]"
>
<span className={isSubmitting ? "opacity-0" : ""}>
{mode === "edit" ? "Save Changes" : "Add Model"}
</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>
) : isGlobal && config ? (
<Button
className="relative text-sm h-9"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
<span className={isSubmitting ? "opacity-0" : ""}>Use This Model</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>
) : null}
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -1,105 +0,0 @@
export interface ImageGenProvider {
value: string;
label: string;
example: string;
description: string;
apiBase?: string;
}
/**
* Image generation providers supported by LiteLLM.
* See: https://docs.litellm.ai/docs/image_generation#supported-providers
*/
export const IMAGE_GEN_PROVIDERS: ImageGenProvider[] = [
{
value: "OPENAI",
label: "OpenAI",
example: "dall-e-3, gpt-image-1, dall-e-2",
description: "DALL-E and GPT Image models",
},
{
value: "AZURE_OPENAI",
label: "Azure OpenAI",
example: "azure/dall-e-3, azure/gpt-image-1",
description: "OpenAI image models on Azure",
},
{
value: "GOOGLE",
label: "Google AI Studio",
example: "gemini/imagen-3.0-generate-002",
description: "Google AI Studio image generation",
},
{
value: "VERTEX_AI",
label: "Google Vertex AI",
example: "vertex_ai/imagegeneration@006",
description: "Vertex AI image generation models",
},
{
value: "BEDROCK",
label: "AWS Bedrock",
example: "bedrock/stability.stable-diffusion-xl-v0",
description: "Stable Diffusion on AWS Bedrock",
},
{
value: "RECRAFT",
label: "Recraft",
example: "recraft/recraftv3",
description: "AI-powered design and image generation",
},
{
value: "OPENROUTER",
label: "OpenRouter",
example: "openrouter/google/gemini-2.5-flash-image",
description: "Image generation via OpenRouter",
},
{
value: "XINFERENCE",
label: "Xinference",
example: "xinference/stable-diffusion-xl",
description: "Self-hosted Stable Diffusion models",
},
{
value: "NSCALE",
label: "Nscale",
example: "nscale/flux.1-schnell",
description: "Nscale image generation",
},
];
/**
* Image generation models organized by provider.
*/
export interface ImageGenModel {
value: string;
label: string;
provider: string;
}
export const IMAGE_GEN_MODELS: ImageGenModel[] = [
// OpenAI
{ value: "gpt-image-1", label: "GPT Image 1", provider: "OPENAI" },
{ value: "dall-e-3", label: "DALL-E 3", provider: "OPENAI" },
{ value: "dall-e-2", label: "DALL-E 2", provider: "OPENAI" },
// Azure OpenAI
{ value: "azure/dall-e-3", label: "DALL-E 3 (Azure)", provider: "AZURE_OPENAI" },
{ value: "azure/gpt-image-1", label: "GPT Image 1 (Azure)", provider: "AZURE_OPENAI" },
// Recraft
{ value: "recraft/recraftv3", label: "Recraft V3", provider: "RECRAFT" },
// Bedrock
{
value: "bedrock/stability.stable-diffusion-xl-v0",
label: "Stable Diffusion XL",
provider: "BEDROCK",
},
// Vertex AI
{
value: "vertex_ai/imagegeneration@006",
label: "Imagen 3",
provider: "VERTEX_AI",
},
];
export function getImageGenModelsByProvider(provider: string): ImageGenModel[] {
return IMAGE_GEN_MODELS.filter((m) => m.provider === provider);
}

File diff suppressed because it is too large Load diff

View file

@ -1,197 +0,0 @@
export interface LLMProvider {
value: string;
label: string;
example: string;
description: string;
apiBase?: string;
}
export const LLM_PROVIDERS: LLMProvider[] = [
{
value: "OPENAI",
label: "OpenAI",
example: "gpt-4o, gpt-4o-mini, o1, o3-mini",
description: "Industry-leading GPT models",
},
{
value: "ANTHROPIC",
label: "Anthropic",
example: "claude-3-5-sonnet, claude-3-opus, claude-4-sonnet",
description: "Claude models with strong reasoning",
},
{
value: "GOOGLE",
label: "Google (Gemini)",
example: "gemini-2.5-flash, gemini-2.5-pro, gemini-1.5-pro",
description: "Gemini models with multimodal capabilities",
},
{
value: "AZURE_OPENAI",
label: "Azure OpenAI",
example: "azure/gpt-4o, azure/gpt-4o-mini",
description: "OpenAI models on Azure",
},
{
value: "BEDROCK",
label: "AWS Bedrock",
example: "anthropic.claude-3-5-sonnet, meta.llama3-70b",
description: "Foundation models on AWS",
},
{
value: "VERTEX_AI",
label: "Google Vertex AI",
example: "vertex_ai/claude-3-5-sonnet, vertex_ai/gemini-2.5-pro",
description: "Models on Google Cloud Vertex AI",
},
{
value: "GROQ",
label: "Groq",
example: "groq/llama-3.3-70b-versatile, groq/mixtral-8x7b",
description: "Ultra-fast inference",
},
{
value: "COHERE",
label: "Cohere",
example: "command-a-03-2025, command-r-plus",
description: "Enterprise NLP models",
},
{
value: "MISTRAL",
label: "Mistral AI",
example: "mistral-large-latest, mistral-medium-latest",
description: "European open-source models",
},
{
value: "DEEPSEEK",
label: "DeepSeek",
example: "deepseek-chat, deepseek-reasoner",
description: "High-performance reasoning models",
apiBase: "https://api.deepseek.com",
},
{
value: "XAI",
label: "xAI (Grok)",
example: "grok-4, grok-3, grok-3-mini",
description: "Grok models from xAI",
},
{
value: "OPENROUTER",
label: "OpenRouter",
example: "openrouter/anthropic/claude-4-opus",
description: "Unified API for multiple providers",
},
{
value: "TOGETHER_AI",
label: "Together AI",
example: "together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo",
description: "Fast open-source models",
},
{
value: "FIREWORKS_AI",
label: "Fireworks AI",
example: "fireworks_ai/accounts/fireworks/models/llama-v3p3-70b-instruct",
description: "Scalable inference platform",
},
{
value: "REPLICATE",
label: "Replicate",
example: "replicate/meta/llama-3-70b-instruct",
description: "ML model hosting platform",
},
{
value: "PERPLEXITY",
label: "Perplexity",
example: "perplexity/sonar-pro, perplexity/sonar-reasoning",
description: "Search-augmented models",
},
{
value: "OLLAMA",
label: "Ollama",
example: "ollama/llama3.1, ollama/mistral",
description: "Run models locally",
apiBase: "http://localhost:11434",
},
{
value: "ALIBABA_QWEN",
label: "Alibaba Qwen",
example: "dashscope/qwen-plus, dashscope/qwen-turbo",
description: "Qwen series models",
apiBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
},
{
value: "MOONSHOT",
label: "Moonshot (Kimi)",
example: "moonshot/kimi-latest, moonshot/kimi-k2-thinking",
description: "Kimi AI models",
apiBase: "https://api.moonshot.cn/v1",
},
{
value: "ZHIPU",
label: "Zhipu (GLM)",
example: "glm-4.6, glm-4.6:exacto",
description: "GLM series models",
apiBase: "https://open.bigmodel.cn/api/paas/v4",
},
{
value: "ANYSCALE",
label: "Anyscale",
example: "anyscale/meta-llama/Meta-Llama-3-70B-Instruct",
description: "Ray-based inference platform",
},
{
value: "DEEPINFRA",
label: "DeepInfra",
example: "deepinfra/meta-llama/Meta-Llama-3.3-70B-Instruct",
description: "Serverless GPU inference",
},
{
value: "CEREBRAS",
label: "Cerebras",
example: "cerebras/llama-3.3-70b, cerebras/qwen-3-32b",
description: "Fastest inference with Wafer-Scale Engine",
},
{
value: "SAMBANOVA",
label: "SambaNova",
example: "sambanova/Meta-Llama-3.3-70B-Instruct",
description: "AI inference platform",
},
{
value: "AI21",
label: "AI21 Labs",
example: "jamba-1.5-large, jamba-1.5-mini",
description: "Jamba series models",
},
{
value: "CLOUDFLARE",
label: "Cloudflare Workers AI",
example: "cloudflare/@cf/meta/llama-2-7b-chat",
description: "AI on Cloudflare edge network",
},
{
value: "DATABRICKS",
label: "Databricks",
example: "databricks/databricks-meta-llama-3-3-70b-instruct",
description: "Databricks Model Serving",
},
{
value: "GITHUB_MODELS",
label: "GitHub Models",
example: "openai/gpt-5, meta/llama-3.1-405b-instruct",
description: "AI models from GitHub Marketplace",
apiBase: "https://models.github.ai/inference",
},
{
value: "MINIMAX",
label: "MiniMax",
example: "MiniMax-M3, MiniMax-M2.7",
description: "High-performance models with up to 512K context",
apiBase: "https://api.minimax.io/v1",
},
{
value: "CUSTOM",
label: "Custom Provider",
example: "your-custom-model",
description: "Custom OpenAI-compatible endpoint",
},
];

View file

@ -1,168 +0,0 @@
import type { LLMModel } from "./llm-models";
export interface VisionProviderInfo {
value: string;
label: string;
example: string;
description: string;
apiBase?: string;
}
export const VISION_PROVIDERS: VisionProviderInfo[] = [
{
value: "OPENAI",
label: "OpenAI",
example: "gpt-4o, gpt-4o-mini",
description: "GPT-4o vision models",
},
{
value: "ANTHROPIC",
label: "Anthropic",
example: "claude-sonnet-4-20250514",
description: "Claude vision models",
},
{
value: "GOOGLE",
label: "Google AI Studio",
example: "gemini-2.5-flash, gemini-2.0-flash",
description: "Gemini vision models",
},
{
value: "AZURE_OPENAI",
label: "Azure OpenAI",
example: "azure/gpt-4o",
description: "OpenAI vision models on Azure",
},
{
value: "VERTEX_AI",
label: "Google Vertex AI",
example: "vertex_ai/gemini-2.5-flash",
description: "Gemini vision models on Vertex AI",
},
{
value: "BEDROCK",
label: "AWS Bedrock",
example: "bedrock/anthropic.claude-sonnet-4-20250514-v1:0",
description: "Vision models on AWS Bedrock",
},
{
value: "XAI",
label: "xAI",
example: "grok-2-vision",
description: "Grok vision models",
},
{
value: "OPENROUTER",
label: "OpenRouter",
example: "openrouter/openai/gpt-4o",
description: "Vision models via OpenRouter",
},
{
value: "OLLAMA",
label: "Ollama",
example: "llava, bakllava",
description: "Local vision models via Ollama",
apiBase: "http://localhost:11434",
},
{
value: "GROQ",
label: "Groq",
example: "llama-4-scout-17b-16e-instruct",
description: "Vision models on Groq",
},
{
value: "TOGETHER_AI",
label: "Together AI",
example: "meta-llama/Llama-4-Scout-17B-16E-Instruct",
description: "Vision models on Together AI",
},
{
value: "FIREWORKS_AI",
label: "Fireworks AI",
example: "fireworks_ai/phi-3-vision-128k-instruct",
description: "Vision models on Fireworks AI",
},
{
value: "DEEPSEEK",
label: "DeepSeek",
example: "deepseek-chat",
description: "DeepSeek vision models",
apiBase: "https://api.deepseek.com",
},
{
value: "MISTRAL",
label: "Mistral",
example: "pixtral-large-latest",
description: "Pixtral vision models",
},
{
value: "CUSTOM",
label: "Custom Provider",
example: "custom/my-vision-model",
description: "Custom OpenAI-compatible vision endpoint",
},
];
export const VISION_MODELS: LLMModel[] = [
{ value: "gpt-4o", label: "GPT-4o", provider: "OPENAI", contextWindow: "128K" },
{ value: "gpt-4o-mini", label: "GPT-4o Mini", provider: "OPENAI", contextWindow: "128K" },
{ value: "gpt-4-turbo", label: "GPT-4 Turbo", provider: "OPENAI", contextWindow: "128K" },
{
value: "claude-sonnet-4-20250514",
label: "Claude Sonnet 4",
provider: "ANTHROPIC",
contextWindow: "200K",
},
{
value: "claude-3-7-sonnet-20250219",
label: "Claude 3.7 Sonnet",
provider: "ANTHROPIC",
contextWindow: "200K",
},
{
value: "claude-3-5-sonnet-20241022",
label: "Claude 3.5 Sonnet",
provider: "ANTHROPIC",
contextWindow: "200K",
},
{
value: "claude-3-opus-20240229",
label: "Claude 3 Opus",
provider: "ANTHROPIC",
contextWindow: "200K",
},
{
value: "claude-3-haiku-20240307",
label: "Claude 3 Haiku",
provider: "ANTHROPIC",
contextWindow: "200K",
},
{ value: "gemini-2.5-flash", label: "Gemini 2.5 Flash", provider: "GOOGLE", contextWindow: "1M" },
{ value: "gemini-2.5-pro", label: "Gemini 2.5 Pro", provider: "GOOGLE", contextWindow: "1M" },
{ value: "gemini-2.0-flash", label: "Gemini 2.0 Flash", provider: "GOOGLE", contextWindow: "1M" },
{ value: "gemini-1.5-pro", label: "Gemini 1.5 Pro", provider: "GOOGLE", contextWindow: "1M" },
{ value: "gemini-1.5-flash", label: "Gemini 1.5 Flash", provider: "GOOGLE", contextWindow: "1M" },
{
value: "pixtral-large-latest",
label: "Pixtral Large",
provider: "MISTRAL",
contextWindow: "128K",
},
{ value: "pixtral-12b-2409", label: "Pixtral 12B", provider: "MISTRAL", contextWindow: "128K" },
{ value: "grok-2-vision-1212", label: "Grok 2 Vision", provider: "XAI", contextWindow: "32K" },
{ value: "llava", label: "LLaVA", provider: "OLLAMA" },
{ value: "bakllava", label: "BakLLaVA", provider: "OLLAMA" },
{ value: "llava-llama3", label: "LLaVA Llama 3", provider: "OLLAMA" },
{
value: "llama-4-scout-17b-16e-instruct",
label: "Llama 4 Scout 17B",
provider: "GROQ",
contextWindow: "128K",
},
{
value: "meta-llama/Llama-4-Scout-17B-16E-Instruct",
label: "Llama 4 Scout 17B",
provider: "TOGETHER_AI",
contextWindow: "128K",
},
];

View file

@ -1,476 +0,0 @@
import { z } from "zod";
/**
* LiteLLM Provider enum - all supported LLM providers
*/
export const liteLLMProviderEnum = z.enum([
"OPENAI",
"ANTHROPIC",
"GOOGLE",
"AZURE_OPENAI",
"BEDROCK",
"VERTEX_AI",
"GROQ",
"COHERE",
"MISTRAL",
"DEEPSEEK",
"XAI",
"OPENROUTER",
"TOGETHER_AI",
"FIREWORKS_AI",
"REPLICATE",
"PERPLEXITY",
"OLLAMA",
"ALIBABA_QWEN",
"MOONSHOT",
"ZHIPU",
"ANYSCALE",
"DEEPINFRA",
"CEREBRAS",
"SAMBANOVA",
"AI21",
"CLOUDFLARE",
"DATABRICKS",
"COMETAPI",
"HUGGINGFACE",
"GITHUB_MODELS",
"MINIMAX",
"CUSTOM",
]);
export type LiteLLMProvider = z.infer<typeof liteLLMProviderEnum>;
/**
* NewLLMConfig - combines model settings with prompt configuration
*/
export const newLLMConfig = z.object({
id: z.number(),
name: z.string().max(100),
description: z.string().max(500).nullable().optional(),
// Model Configuration
provider: liteLLMProviderEnum,
custom_provider: z.string().max(100).nullable().optional(),
model_name: z.string().max(100),
api_key: z.string(),
api_base: z.string().max(500).nullable().optional(),
litellm_params: z.record(z.string(), z.any()).nullable().optional(),
// Prompt Configuration
system_instructions: z.string().default(""),
use_default_system_instructions: z.boolean().default(true),
citations_enabled: z.boolean().default(true),
// Metadata
created_at: z.string(),
search_space_id: z.number(),
user_id: z.string(),
// Capability flag — derived server-side at the route boundary from
// LiteLLM's authoritative model map. There is no DB column. Default
// `true` is the conservative-allow stance for unknown / unmapped
// BYOK rows; the streaming-task safety net is the only place a
// `false` actually blocks a request.
supports_image_input: z.boolean().default(true),
});
/**
* Public version without api_key (for list views)
*/
export const newLLMConfigPublic = newLLMConfig.omit({ api_key: true });
/**
* Create NewLLMConfig
*
* `supports_image_input` is omitted because it is derived server-side
* from LiteLLM's model map at read time there is no DB column to
* persist a client-supplied value into.
*/
export const createNewLLMConfigRequest = newLLMConfig.omit({
id: true,
created_at: true,
user_id: true,
supports_image_input: true,
});
export const createNewLLMConfigResponse = newLLMConfig;
/**
* Get NewLLMConfigs list
*/
export const getNewLLMConfigsRequest = z.object({
search_space_id: z.number(),
skip: z.number().optional(),
limit: z.number().optional(),
});
export const getNewLLMConfigsResponse = z.array(newLLMConfig);
/**
* Get single NewLLMConfig
*/
export const getNewLLMConfigRequest = z.object({
id: z.number(),
});
export const getNewLLMConfigResponse = newLLMConfig;
/**
* Update NewLLMConfig
*/
export const updateNewLLMConfigRequest = z.object({
id: z.number(),
data: newLLMConfig
.omit({
id: true,
created_at: true,
search_space_id: true,
user_id: true,
// Derived server-side; not part of the writable surface.
supports_image_input: true,
})
.partial(),
});
export const updateNewLLMConfigResponse = newLLMConfig;
/**
* Delete NewLLMConfig
*/
export const deleteNewLLMConfigRequest = z.object({
id: z.number(),
});
export const deleteNewLLMConfigResponse = z.object({
message: z.string(),
id: z.number(),
});
/**
* Get default system instructions
*/
export const getDefaultSystemInstructionsResponse = z.object({
default_system_instructions: z.string(),
});
/**
* Global NewLLMConfig - from YAML, has negative IDs
* ID 0 is reserved for "Auto" mode which uses LiteLLM Router for load balancing
*/
export const globalNewLLMConfig = z.object({
id: z.number(), // 0 for Auto mode, negative IDs for global configs
name: z.string(),
description: z.string().nullable().optional(),
// Model Configuration (no api_key)
provider: z.string(), // String because YAML doesn't enforce enum, "AUTO" for Auto mode
custom_provider: z.string().nullable().optional(),
model_name: z.string(),
api_base: z.string().nullable().optional(),
litellm_params: z.record(z.string(), z.any()).nullable().optional(),
// Prompt Configuration
system_instructions: z.string().default(""),
use_default_system_instructions: z.boolean().default(true),
citations_enabled: z.boolean().default(true),
is_global: z.literal(true),
is_auto_mode: z.boolean().optional().default(false), // True only for Auto mode (ID 0)
// Token quota and billing policy
billing_tier: z.string().default("free"),
is_premium: z.boolean().default(false),
anonymous_enabled: z.boolean().default(false),
seo_enabled: z.boolean().default(false),
seo_slug: z.string().nullable().optional(),
seo_title: z.string().nullable().optional(),
seo_description: z.string().nullable().optional(),
quota_reserve_tokens: z.number().nullable().optional(),
// Capability flag — true when the model can accept image inputs.
// Resolved server-side (OpenRouter dynamic configs use the OR
// `architecture.input_modalities` field; YAML / BYOK use LiteLLM's
// authoritative `supports_vision` map). The chat selector renders
// an amber "No image" hint when this is false and there are
// pending image attachments, but does not block selection — the
// backend safety net only rejects when LiteLLM *explicitly* marks
// the model as text-only, so unknown / new models still flow
// through. Default `true` matches that conservative-allow stance.
supports_image_input: z.boolean().default(true),
});
export const getGlobalNewLLMConfigsResponse = z.array(globalNewLLMConfig);
// =============================================================================
// Image Generation Config (separate table from NewLLMConfig)
// =============================================================================
/**
* ImageGenProvider enum - only providers that support image generation
* See: https://docs.litellm.ai/docs/image_generation#supported-providers
*/
export const imageGenProviderEnum = z.enum([
"OPENAI",
"AZURE_OPENAI",
"GOOGLE",
"VERTEX_AI",
"BEDROCK",
"RECRAFT",
"OPENROUTER",
"XINFERENCE",
"NSCALE",
]);
export type ImageGenProvider = z.infer<typeof imageGenProviderEnum>;
/**
* ImageGenerationConfig - user-created image gen model configs
* Separate from NewLLMConfig: no system_instructions, no citations_enabled.
*/
export const imageGenerationConfig = z.object({
id: z.number(),
name: z.string().max(100),
description: z.string().max(500).nullable().optional(),
provider: imageGenProviderEnum,
custom_provider: z.string().max(100).nullable().optional(),
model_name: z.string().max(100),
api_key: z.string(),
api_base: z.string().max(500).nullable().optional(),
api_version: z.string().max(50).nullable().optional(),
litellm_params: z.record(z.string(), z.any()).nullable().optional(),
created_at: z.string(),
search_space_id: z.number(),
user_id: z.string(),
});
export const createImageGenConfigRequest = imageGenerationConfig.omit({
id: true,
created_at: true,
user_id: true,
});
export const createImageGenConfigResponse = imageGenerationConfig;
export const getImageGenConfigsResponse = z.array(imageGenerationConfig);
export const updateImageGenConfigRequest = z.object({
id: z.number(),
data: imageGenerationConfig
.omit({ id: true, created_at: true, search_space_id: true, user_id: true })
.partial(),
});
export const updateImageGenConfigResponse = imageGenerationConfig;
export const deleteImageGenConfigResponse = z.object({
message: z.string(),
id: z.number(),
});
/**
* Global Image Generation Config - from YAML, has negative IDs
* ID 0 is reserved for "Auto" mode (LiteLLM Router load balancing)
*/
export const globalImageGenConfig = z.object({
id: z.number(),
name: z.string(),
description: z.string().nullable().optional(),
provider: z.string(),
custom_provider: z.string().nullable().optional(),
model_name: z.string(),
api_base: z.string().nullable().optional(),
api_version: z.string().nullable().optional(),
litellm_params: z.record(z.string(), z.any()).nullable().optional(),
is_global: z.literal(true),
is_auto_mode: z.boolean().optional().default(false),
billing_tier: z.string().default("free"),
// Mirrors `globalNewLLMConfig.is_premium` so the new-chat selector's
// Free/Premium badge logic lights up automatically for image-gen too.
is_premium: z.boolean().default(false),
quota_reserve_micros: z.number().nullable().optional(),
});
export const getGlobalImageGenConfigsResponse = z.array(globalImageGenConfig);
// =============================================================================
// Vision LLM Config (separate table for vision-capable models)
// =============================================================================
export const visionProviderEnum = z.enum([
"OPENAI",
"ANTHROPIC",
"GOOGLE",
"AZURE_OPENAI",
"VERTEX_AI",
"BEDROCK",
"XAI",
"OPENROUTER",
"OLLAMA",
"GROQ",
"TOGETHER_AI",
"FIREWORKS_AI",
"DEEPSEEK",
"MISTRAL",
"CUSTOM",
]);
export type VisionProvider = z.infer<typeof visionProviderEnum>;
export const visionLLMConfig = z.object({
id: z.number(),
name: z.string().max(100),
description: z.string().max(500).nullable().optional(),
provider: visionProviderEnum,
custom_provider: z.string().max(100).nullable().optional(),
model_name: z.string().max(100),
api_key: z.string(),
api_base: z.string().max(500).nullable().optional(),
api_version: z.string().max(50).nullable().optional(),
litellm_params: z.record(z.string(), z.any()).nullable().optional(),
created_at: z.string(),
search_space_id: z.number(),
user_id: z.string(),
});
export const createVisionLLMConfigRequest = visionLLMConfig.omit({
id: true,
created_at: true,
user_id: true,
});
export const createVisionLLMConfigResponse = visionLLMConfig;
export const getVisionLLMConfigsResponse = z.array(visionLLMConfig);
export const updateVisionLLMConfigRequest = z.object({
id: z.number(),
data: visionLLMConfig
.omit({ id: true, created_at: true, search_space_id: true, user_id: true })
.partial(),
});
export const updateVisionLLMConfigResponse = visionLLMConfig;
export const deleteVisionLLMConfigResponse = z.object({
message: z.string(),
id: z.number(),
});
export const globalVisionLLMConfig = z.object({
id: z.number(),
name: z.string(),
description: z.string().nullable().optional(),
provider: z.string(),
custom_provider: z.string().nullable().optional(),
model_name: z.string(),
api_base: z.string().nullable().optional(),
api_version: z.string().nullable().optional(),
litellm_params: z.record(z.string(), z.any()).nullable().optional(),
is_global: z.literal(true),
is_auto_mode: z.boolean().optional().default(false),
billing_tier: z.string().default("free"),
// Mirrors `globalNewLLMConfig.is_premium` so the new-chat selector's
// Free/Premium badge logic lights up automatically for vision too.
is_premium: z.boolean().default(false),
quota_reserve_tokens: z.number().nullable().optional(),
input_cost_per_token: z.number().nullable().optional(),
output_cost_per_token: z.number().nullable().optional(),
});
export const getGlobalVisionLLMConfigsResponse = z.array(globalVisionLLMConfig);
// =============================================================================
// LLM Preferences (Role Assignments)
// =============================================================================
export const llmPreferences = z.object({
agent_llm_id: z.union([z.number(), z.null()]).optional(),
image_generation_config_id: z.union([z.number(), z.null()]).optional(),
vision_llm_config_id: z.union([z.number(), z.null()]).optional(),
agent_llm: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(),
image_generation_config: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(),
vision_llm_config: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(),
});
/**
* Get LLM preferences
*/
export const getLLMPreferencesRequest = z.object({
search_space_id: z.number(),
});
export const getLLMPreferencesResponse = llmPreferences;
/**
* Update LLM preferences
*/
export const updateLLMPreferencesRequest = z.object({
search_space_id: z.number(),
data: llmPreferences.pick({
agent_llm_id: true,
image_generation_config_id: true,
vision_llm_config_id: true,
}),
});
export const updateLLMPreferencesResponse = llmPreferences;
// =============================================================================
// Model List (dynamic catalogue from OpenRouter API)
// =============================================================================
export const modelListItem = z.object({
value: z.string(),
label: z.string(),
provider: z.string(),
context_window: z.string().nullable().optional(),
});
export const getModelListResponse = z.array(modelListItem);
export type ModelListItem = z.infer<typeof modelListItem>;
export type GetModelListResponse = z.infer<typeof getModelListResponse>;
// =============================================================================
// Type Exports
// =============================================================================
export type NewLLMConfig = z.infer<typeof newLLMConfig>;
export type NewLLMConfigPublic = z.infer<typeof newLLMConfigPublic>;
export type CreateNewLLMConfigRequest = z.infer<typeof createNewLLMConfigRequest>;
export type CreateNewLLMConfigResponse = z.infer<typeof createNewLLMConfigResponse>;
export type GetNewLLMConfigsRequest = z.infer<typeof getNewLLMConfigsRequest>;
export type GetNewLLMConfigsResponse = z.infer<typeof getNewLLMConfigsResponse>;
export type GetNewLLMConfigRequest = z.infer<typeof getNewLLMConfigRequest>;
export type GetNewLLMConfigResponse = z.infer<typeof getNewLLMConfigResponse>;
export type UpdateNewLLMConfigRequest = z.infer<typeof updateNewLLMConfigRequest>;
export type UpdateNewLLMConfigResponse = z.infer<typeof updateNewLLMConfigResponse>;
export type DeleteNewLLMConfigRequest = z.infer<typeof deleteNewLLMConfigRequest>;
export type DeleteNewLLMConfigResponse = z.infer<typeof deleteNewLLMConfigResponse>;
export type GetDefaultSystemInstructionsResponse = z.infer<
typeof getDefaultSystemInstructionsResponse
>;
export type GlobalNewLLMConfig = z.infer<typeof globalNewLLMConfig>;
export type GetGlobalNewLLMConfigsResponse = z.infer<typeof getGlobalNewLLMConfigsResponse>;
export type ImageGenerationConfig = z.infer<typeof imageGenerationConfig>;
export type CreateImageGenConfigRequest = z.infer<typeof createImageGenConfigRequest>;
export type CreateImageGenConfigResponse = z.infer<typeof createImageGenConfigResponse>;
export type GetImageGenConfigsResponse = z.infer<typeof getImageGenConfigsResponse>;
export type UpdateImageGenConfigRequest = z.infer<typeof updateImageGenConfigRequest>;
export type UpdateImageGenConfigResponse = z.infer<typeof updateImageGenConfigResponse>;
export type DeleteImageGenConfigResponse = z.infer<typeof deleteImageGenConfigResponse>;
export type GlobalImageGenConfig = z.infer<typeof globalImageGenConfig>;
export type GetGlobalImageGenConfigsResponse = z.infer<typeof getGlobalImageGenConfigsResponse>;
export type VisionLLMConfig = z.infer<typeof visionLLMConfig>;
export type CreateVisionLLMConfigRequest = z.infer<typeof createVisionLLMConfigRequest>;
export type CreateVisionLLMConfigResponse = z.infer<typeof createVisionLLMConfigResponse>;
export type GetVisionLLMConfigsResponse = z.infer<typeof getVisionLLMConfigsResponse>;
export type UpdateVisionLLMConfigRequest = z.infer<typeof updateVisionLLMConfigRequest>;
export type UpdateVisionLLMConfigResponse = z.infer<typeof updateVisionLLMConfigResponse>;
export type DeleteVisionLLMConfigResponse = z.infer<typeof deleteVisionLLMConfigResponse>;
export type GlobalVisionLLMConfig = z.infer<typeof globalVisionLLMConfig>;
export type GetGlobalVisionLLMConfigsResponse = z.infer<typeof getGlobalVisionLLMConfigsResponse>;
export type LLMPreferences = z.infer<typeof llmPreferences>;
export type GetLLMPreferencesRequest = z.infer<typeof getLLMPreferencesRequest>;
export type GetLLMPreferencesResponse = z.infer<typeof getLLMPreferencesResponse>;
export type UpdateLLMPreferencesRequest = z.infer<typeof updateLLMPreferencesRequest>;
export type UpdateLLMPreferencesResponse = z.infer<typeof updateLLMPreferencesResponse>;

View file

@ -1,81 +0,0 @@
import {
type CreateImageGenConfigRequest,
createImageGenConfigRequest,
createImageGenConfigResponse,
deleteImageGenConfigResponse,
getGlobalImageGenConfigsResponse,
getImageGenConfigsResponse,
type UpdateImageGenConfigRequest,
updateImageGenConfigRequest,
updateImageGenConfigResponse,
} from "@/contracts/types/new-llm-config.types";
import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service";
class ImageGenConfigApiService {
/**
* Get all global image generation configs (from YAML, negative IDs)
*/
getGlobalConfigs = async () => {
return baseApiService.get(
`/api/v1/global-image-generation-configs`,
getGlobalImageGenConfigsResponse
);
};
/**
* Create a new image generation config for a search space
*/
createConfig = async (request: CreateImageGenConfigRequest) => {
const parsed = createImageGenConfigRequest.safeParse(request);
if (!parsed.success) {
const msg = parsed.error.issues.map((i) => i.message).join(", ");
throw new ValidationError(`Invalid request: ${msg}`);
}
return baseApiService.post(`/api/v1/image-generation-configs`, createImageGenConfigResponse, {
body: parsed.data,
});
};
/**
* Get image generation configs for a search space
*/
getConfigs = async (searchSpaceId: number) => {
const params = new URLSearchParams({
search_space_id: String(searchSpaceId),
}).toString();
return baseApiService.get(
`/api/v1/image-generation-configs?${params}`,
getImageGenConfigsResponse
);
};
/**
* Update an existing image generation config
*/
updateConfig = async (request: UpdateImageGenConfigRequest) => {
const parsed = updateImageGenConfigRequest.safeParse(request);
if (!parsed.success) {
const msg = parsed.error.issues.map((i) => i.message).join(", ");
throw new ValidationError(`Invalid request: ${msg}`);
}
const { id, data } = parsed.data;
return baseApiService.put(
`/api/v1/image-generation-configs/${id}`,
updateImageGenConfigResponse,
{ body: data }
);
};
/**
* Delete an image generation config
*/
deleteConfig = async (id: number) => {
return baseApiService.delete(
`/api/v1/image-generation-configs/${id}`,
deleteImageGenConfigResponse
);
};
}
export const imageGenConfigApiService = new ImageGenConfigApiService();

View file

@ -1,178 +0,0 @@
import {
type CreateNewLLMConfigRequest,
createNewLLMConfigRequest,
createNewLLMConfigResponse,
type DeleteNewLLMConfigRequest,
deleteNewLLMConfigRequest,
deleteNewLLMConfigResponse,
type GetNewLLMConfigRequest,
type GetNewLLMConfigsRequest,
getDefaultSystemInstructionsResponse,
getGlobalNewLLMConfigsResponse,
getLLMPreferencesResponse,
getModelListResponse,
getNewLLMConfigRequest,
getNewLLMConfigResponse,
getNewLLMConfigsRequest,
getNewLLMConfigsResponse,
type UpdateLLMPreferencesRequest,
type UpdateNewLLMConfigRequest,
updateLLMPreferencesRequest,
updateLLMPreferencesResponse,
updateNewLLMConfigRequest,
updateNewLLMConfigResponse,
} from "@/contracts/types/new-llm-config.types";
import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service";
class NewLLMConfigApiService {
/**
* Get all global NewLLMConfigs available to all users
*/
getGlobalConfigs = async () => {
return baseApiService.get(`/api/v1/global-new-llm-configs`, getGlobalNewLLMConfigsResponse);
};
/**
* Get default system instructions template
*/
getDefaultSystemInstructions = async () => {
return baseApiService.get(
`/api/v1/new-llm-configs/default-system-instructions`,
getDefaultSystemInstructionsResponse
);
};
/**
* Create a new NewLLMConfig for a search space
*/
createConfig = async (request: CreateNewLLMConfigRequest) => {
const parsedRequest = createNewLLMConfigRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.post(`/api/v1/new-llm-configs`, createNewLLMConfigResponse, {
body: parsedRequest.data,
});
};
/**
* Get a list of NewLLMConfigs for a search space
*/
getConfigs = async (request: GetNewLLMConfigsRequest) => {
const parsedRequest = getNewLLMConfigsRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
const queryParams = new URLSearchParams({
search_space_id: String(parsedRequest.data.search_space_id),
...(parsedRequest.data.skip !== undefined && { skip: String(parsedRequest.data.skip) }),
...(parsedRequest.data.limit !== undefined && { limit: String(parsedRequest.data.limit) }),
}).toString();
return baseApiService.get(`/api/v1/new-llm-configs?${queryParams}`, getNewLLMConfigsResponse);
};
/**
* Get a single NewLLMConfig by ID
*/
getConfig = async (request: GetNewLLMConfigRequest) => {
const parsedRequest = getNewLLMConfigRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.get(
`/api/v1/new-llm-configs/${parsedRequest.data.id}`,
getNewLLMConfigResponse
);
};
/**
* Update an existing NewLLMConfig
*/
updateConfig = async (request: UpdateNewLLMConfigRequest) => {
const parsedRequest = updateNewLLMConfigRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
const { id, data } = parsedRequest.data;
return baseApiService.put(`/api/v1/new-llm-configs/${id}`, updateNewLLMConfigResponse, {
body: data,
});
};
/**
* Delete a NewLLMConfig
*/
deleteConfig = async (request: DeleteNewLLMConfigRequest) => {
const parsedRequest = deleteNewLLMConfigRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.delete(
`/api/v1/new-llm-configs/${parsedRequest.data.id}`,
deleteNewLLMConfigResponse
);
};
/**
* Get LLM preferences for a search space
*/
getLLMPreferences = async (searchSpaceId: number) => {
return baseApiService.get(
`/api/v1/search-spaces/${searchSpaceId}/llm-preferences`,
getLLMPreferencesResponse
);
};
/**
* Get the dynamic model catalogue (sourced from OpenRouter API)
*/
getModels = async () => {
return baseApiService.get(`/api/v1/models`, getModelListResponse);
};
/**
* Update LLM preferences for a search space
*/
updateLLMPreferences = async (request: UpdateLLMPreferencesRequest) => {
const parsedRequest = updateLLMPreferencesRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
const { search_space_id, data } = parsedRequest.data;
return baseApiService.put(
`/api/v1/search-spaces/${search_space_id}/llm-preferences`,
updateLLMPreferencesResponse,
{ body: data }
);
};
}
export const newLLMConfigApiService = new NewLLMConfigApiService();

View file

@ -1,63 +0,0 @@
import {
type CreateVisionLLMConfigRequest,
createVisionLLMConfigRequest,
createVisionLLMConfigResponse,
deleteVisionLLMConfigResponse,
getGlobalVisionLLMConfigsResponse,
getModelListResponse,
getVisionLLMConfigsResponse,
type UpdateVisionLLMConfigRequest,
updateVisionLLMConfigRequest,
updateVisionLLMConfigResponse,
} from "@/contracts/types/new-llm-config.types";
import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service";
class VisionLLMConfigApiService {
getModels = async () => {
return baseApiService.get(`/api/v1/vision-models`, getModelListResponse);
};
getGlobalConfigs = async () => {
return baseApiService.get(
`/api/v1/global-vision-llm-configs`,
getGlobalVisionLLMConfigsResponse
);
};
createConfig = async (request: CreateVisionLLMConfigRequest) => {
const parsed = createVisionLLMConfigRequest.safeParse(request);
if (!parsed.success) {
const msg = parsed.error.issues.map((i) => i.message).join(", ");
throw new ValidationError(`Invalid request: ${msg}`);
}
return baseApiService.post(`/api/v1/vision-llm-configs`, createVisionLLMConfigResponse, {
body: parsed.data,
});
};
getConfigs = async (searchSpaceId: number) => {
const params = new URLSearchParams({
search_space_id: String(searchSpaceId),
}).toString();
return baseApiService.get(`/api/v1/vision-llm-configs?${params}`, getVisionLLMConfigsResponse);
};
updateConfig = async (request: UpdateVisionLLMConfigRequest) => {
const parsed = updateVisionLLMConfigRequest.safeParse(request);
if (!parsed.success) {
const msg = parsed.error.issues.map((i) => i.message).join(", ");
throw new ValidationError(`Invalid request: ${msg}`);
}
const { id, data } = parsed.data;
return baseApiService.put(`/api/v1/vision-llm-configs/${id}`, updateVisionLLMConfigResponse, {
body: data,
});
};
deleteConfig = async (id: number) => {
return baseApiService.delete(`/api/v1/vision-llm-configs/${id}`, deleteVisionLLMConfigResponse);
};
}
export const visionLLMConfigApiService = new VisionLLMConfigApiService();

View file

@ -36,31 +36,12 @@ export const cacheKeys = {
withQueryParams: (queries: GetLogsRequest["queryParams"]) =>
["logs", "with-query-params", ...stableEntries(queries)] as const,
},
newLLMConfigs: {
all: (searchSpaceId: number) => ["new-llm-configs", searchSpaceId] as const,
byId: (configId: number) => ["new-llm-configs", "detail", configId] as const,
preferences: (searchSpaceId: number) => ["llm-preferences", searchSpaceId] as const,
defaultInstructions: () => ["new-llm-configs", "default-instructions"] as const,
global: () => ["new-llm-configs", "global"] as const,
modelList: () => ["models", "catalogue"] as const,
},
modelConnections: {
all: (searchSpaceId: number) => ["model-connections", searchSpaceId] as const,
global: () => ["model-connections", "global"] as const,
providers: () => ["model-connections", "providers"] as const,
roles: (searchSpaceId: number) => ["model-roles", searchSpaceId] as const,
},
imageGenConfigs: {
all: (searchSpaceId: number) => ["image-gen-configs", searchSpaceId] as const,
byId: (configId: number) => ["image-gen-configs", "detail", configId] as const,
global: () => ["image-gen-configs", "global"] as const,
},
visionLLMConfigs: {
all: (searchSpaceId: number) => ["vision-llm-configs", searchSpaceId] as const,
byId: (configId: number) => ["vision-llm-configs", "detail", configId] as const,
global: () => ["vision-llm-configs", "global"] as const,
modelList: () => ["vision-models", "catalogue"] as const,
},
auth: {
user: ["auth", "user"] as const,
},

View file

@ -476,9 +476,7 @@
"title": "Settings",
"subtitle": "Manage your LLM configurations and role assignments for this search space.",
"back_to_dashboard": "Back to Dashboard",
"model_configs": "Model Configs",
"models": "Models",
"llm_roles": "LLM Roles",
"roles": "Roles",
"llm_role_management": "LLM Role Management",
"llm_role_desc": "Assign your LLM configurations to specific roles for different purposes.",
@ -746,12 +744,6 @@
"nav_models": "Models",
"nav_agent_models": "Chat Models",
"nav_agent_models_desc": "Models with prompts & citations",
"nav_role_assignments": "Role Assignments",
"nav_role_assignments_desc": "Assign configs to agent roles",
"nav_image_models": "Image Models",
"nav_image_models_desc": "Configure image generation models",
"nav_vision_models": "Vision Models",
"nav_vision_models_desc": "Configure vision-capable LLM models",
"nav_system_instructions": "System Instructions",
"nav_system_instructions_desc": "SearchSpace-wide AI instructions",
"nav_public_links": "Public Chat Links",

View file

@ -476,9 +476,7 @@
"title": "Configuración",
"subtitle": "Administra tus configuraciones de LLM y asignaciones de roles para este espacio de búsqueda.",
"back_to_dashboard": "Volver al panel de control",
"model_configs": "Configuraciones de modelos",
"models": "Modelos",
"llm_roles": "Roles de LLM",
"roles": "Roles",
"llm_role_management": "Gestión de roles de LLM",
"llm_role_desc": "Asigna tus configuraciones de LLM a roles específicos para diferentes propósitos.",
@ -743,14 +741,9 @@
"back_to_app": "Volver a la app",
"nav_general": "General",
"nav_general_desc": "Nombre, descripción e información básica",
"nav_models": "Modelos",
"nav_agent_models": "Modelos de chat",
"nav_agent_models_desc": "Modelos LLM con prompts y citas",
"nav_role_assignments": "Asignaciones de roles",
"nav_role_assignments_desc": "Asignar configuraciones a roles de agente",
"nav_image_models": "Modelos de imagen",
"nav_image_models_desc": "Configurar modelos de generación de imágenes",
"nav_vision_models": "Modelos de visión",
"nav_vision_models_desc": "Configurar modelos LLM con capacidad de visión",
"nav_system_instructions": "Instrucciones del sistema",
"nav_system_instructions_desc": "Instrucciones de IA a nivel del espacio de búsqueda",
"nav_public_links": "Enlaces de chat públicos",
@ -766,7 +759,27 @@
"general_reset": "Restablecer cambios",
"general_save": "Guardar cambios",
"general_saving": "Guardando",
"general_unsaved_changes": "Tienes cambios sin guardar. Haz clic en \"Guardar cambios\" para aplicarlos."
"general_unsaved_changes": "Tienes cambios sin guardar. Haz clic en \"Guardar cambios\" para aplicarlos.",
"nav_web_search": "Búsqueda web",
"nav_web_search_desc": "Configuración de búsqueda web integrada",
"web_search_title": "Búsqueda web",
"web_search_description": "La búsqueda web funciona con una instancia SearXNG integrada. Todas las consultas se procesan a través de tu servidor; no se envían datos a terceros.",
"web_search_enabled_label": "Activar búsqueda web",
"web_search_enabled_description": "Cuando está activada, el agente de IA puede buscar en la web información en tiempo real como noticias, precios y eventos actuales.",
"web_search_status_healthy": "El servicio de búsqueda web está funcionando",
"web_search_status_unhealthy": "El servicio de búsqueda web no está disponible",
"web_search_status_not_configured": "El servicio de búsqueda web no está configurado",
"web_search_engines_label": "Motores de búsqueda",
"web_search_engines_placeholder": "google,brave,duckduckgo",
"web_search_engines_description": "Lista separada por comas de motores SearXNG a usar. Déjalo vacío para usar los valores predeterminados.",
"web_search_language_label": "Idioma preferido",
"web_search_language_placeholder": "es",
"web_search_language_description": "Etiqueta de idioma IETF (por ejemplo, es, es-ES). Déjalo vacío para detección automática.",
"web_search_safesearch_label": "Nivel de SafeSearch",
"web_search_safesearch_description": "0 = desactivado, 1 = moderado, 2 = estricto",
"web_search_save": "Guardar configuración de búsqueda web",
"web_search_saving": "Guardando...",
"web_search_saved": "Configuración de búsqueda web guardada"
},
"homepage": {
"hero_title_part1": "El espacio de trabajo con IA",

View file

@ -476,9 +476,7 @@
"title": "सेटिंग्स",
"subtitle": "इस सर्च स्पेस के लिए अपनी LLM कॉन्फ़िगरेशन और भूमिका असाइनमेंट प्रबंधित करें।",
"back_to_dashboard": "डैशबोर्ड पर वापस जाएं",
"model_configs": "मॉडल कॉन्फ़िगरेशन",
"models": "मॉडल",
"llm_roles": "LLM भूमिकाएं",
"roles": "भूमिकाएं",
"llm_role_management": "LLM भूमिका प्रबंधन",
"llm_role_desc": "विभिन्न उद्देश्यों के लिए अपनी LLM कॉन्फ़िगरेशन को विशिष्ट भूमिकाओं में असाइन करें।",
@ -743,14 +741,9 @@
"back_to_app": "ऐप पर वापस जाएं",
"nav_general": "सामान्य",
"nav_general_desc": "नाम, विवरण और बुनियादी जानकारी",
"nav_models": "मॉडल",
"nav_agent_models": "चैट मॉडल",
"nav_agent_models_desc": "प्रॉम्प्ट और उद्धरण के साथ LLM मॉडल",
"nav_role_assignments": "भूमिका असाइनमेंट",
"nav_role_assignments_desc": "एजेंट भूमिकाओं को कॉन्फ़िगरेशन असाइन करें",
"nav_image_models": "इमेज मॉडल",
"nav_image_models_desc": "इमेज जनरेशन मॉडल कॉन्फ़िगर करें",
"nav_vision_models": "विज़न मॉडल",
"nav_vision_models_desc": "विज़न-सक्षम LLM मॉडल कॉन्फ़िगर करें",
"nav_system_instructions": "सिस्टम निर्देश",
"nav_system_instructions_desc": "सर्च स्पेस-व्यापी AI निर्देश",
"nav_public_links": "सार्वजनिक चैट लिंक",
@ -766,7 +759,27 @@
"general_reset": "परिवर्तन रीसेट करें",
"general_save": "परिवर्तन सहेजें",
"general_saving": "सहेजा जा रहा है",
"general_unsaved_changes": "आपके पास सहेजे नहीं गए परिवर्तन हैं। उन्हें लागू करने के लिए \"परिवर्तन सहेजें\" पर क्लिक करें।"
"general_unsaved_changes": "आपके पास सहेजे नहीं गए परिवर्तन हैं। उन्हें लागू करने के लिए \"परिवर्तन सहेजें\" पर क्लिक करें।",
"nav_web_search": "वेब खोज",
"nav_web_search_desc": "बिल्ट-इन वेब खोज सेटिंग्स",
"web_search_title": "वेब खोज",
"web_search_description": "वेब खोज एक बिल्ट-इन SearXNG इंस्टेंस द्वारा संचालित है। सभी क्वेरी आपके सर्वर के माध्यम से प्रॉक्सी की जाती हैं; कोई डेटा तृतीय पक्षों को नहीं भेजा जाता।",
"web_search_enabled_label": "वेब खोज सक्षम करें",
"web_search_enabled_description": "सक्षम होने पर, AI एजेंट समाचार, कीमतों और वर्तमान घटनाओं जैसी वास्तविक समय की जानकारी के लिए वेब खोज सकता है।",
"web_search_status_healthy": "वेब खोज सेवा स्वस्थ है",
"web_search_status_unhealthy": "वेब खोज सेवा उपलब्ध नहीं है",
"web_search_status_not_configured": "वेब खोज सेवा कॉन्फ़िगर नहीं है",
"web_search_engines_label": "खोज इंजन",
"web_search_engines_placeholder": "google,brave,duckduckgo",
"web_search_engines_description": "उपयोग करने के लिए SearXNG इंजनों की कॉमा-सेपरेटेड सूची। डिफ़ॉल्ट के लिए खाली छोड़ें।",
"web_search_language_label": "पसंदीदा भाषा",
"web_search_language_placeholder": "hi",
"web_search_language_description": "IETF भाषा टैग (जैसे hi, hi-IN)। ऑटो-डिटेक्ट के लिए खाली छोड़ें।",
"web_search_safesearch_label": "SafeSearch स्तर",
"web_search_safesearch_description": "0 = बंद, 1 = मध्यम, 2 = सख्त",
"web_search_save": "वेब खोज सेटिंग्स सहेजें",
"web_search_saving": "सहेजा जा रहा है...",
"web_search_saved": "वेब खोज सेटिंग्स सहेजी गईं"
},
"homepage": {
"hero_title_part1": "AI कार्यक्षेत्र",

View file

@ -476,9 +476,7 @@
"title": "Configurações",
"subtitle": "Gerencie suas configurações de LLM e atribuições de funções para este espaço de pesquisa.",
"back_to_dashboard": "Voltar ao painel",
"model_configs": "Configurações de modelos",
"models": "Modelos",
"llm_roles": "Funções de LLM",
"roles": "Funções",
"llm_role_management": "Gerenciamento de funções de LLM",
"llm_role_desc": "Atribua suas configurações de LLM a funções específicas para diferentes propósitos.",
@ -743,14 +741,9 @@
"back_to_app": "Voltar ao app",
"nav_general": "Geral",
"nav_general_desc": "Nome, descrição e informações básicas",
"nav_models": "Modelos",
"nav_agent_models": "Modelos de chat",
"nav_agent_models_desc": "Modelos LLM com prompts e citações",
"nav_role_assignments": "Atribuições de funções",
"nav_role_assignments_desc": "Atribuir configurações a funções do agente",
"nav_image_models": "Modelos de imagem",
"nav_image_models_desc": "Configurar modelos de geração de imagens",
"nav_vision_models": "Modelos de visão",
"nav_vision_models_desc": "Configurar modelos LLM com capacidade de visão",
"nav_system_instructions": "Instruções do sistema",
"nav_system_instructions_desc": "Instruções de IA em nível do espaço de pesquisa",
"nav_public_links": "Links de chat públicos",
@ -766,7 +759,27 @@
"general_reset": "Redefinir alterações",
"general_save": "Salvar alterações",
"general_saving": "Salvando",
"general_unsaved_changes": "Você tem alterações não salvas. Clique em \"Salvar alterações\" para aplicá-las."
"general_unsaved_changes": "Você tem alterações não salvas. Clique em \"Salvar alterações\" para aplicá-las.",
"nav_web_search": "Pesquisa na web",
"nav_web_search_desc": "Configurações integradas de pesquisa na web",
"web_search_title": "Pesquisa na web",
"web_search_description": "A pesquisa na web é alimentada por uma instância SearXNG integrada. Todas as consultas passam pelo seu servidor; nenhum dado é enviado a terceiros.",
"web_search_enabled_label": "Ativar pesquisa na web",
"web_search_enabled_description": "Quando ativado, o agente de IA pode pesquisar na web informações em tempo real, como notícias, preços e eventos atuais.",
"web_search_status_healthy": "O serviço de pesquisa na web está saudável",
"web_search_status_unhealthy": "O serviço de pesquisa na web está indisponível",
"web_search_status_not_configured": "O serviço de pesquisa na web não está configurado",
"web_search_engines_label": "Mecanismos de pesquisa",
"web_search_engines_placeholder": "google,brave,duckduckgo",
"web_search_engines_description": "Lista separada por vírgulas de mecanismos SearXNG a usar. Deixe vazio para os padrões.",
"web_search_language_label": "Idioma preferido",
"web_search_language_placeholder": "pt",
"web_search_language_description": "Tag de idioma IETF (por exemplo, pt, pt-BR). Deixe vazio para detecção automática.",
"web_search_safesearch_label": "Nível de SafeSearch",
"web_search_safesearch_description": "0 = desativado, 1 = moderado, 2 = rigoroso",
"web_search_save": "Salvar configurações de pesquisa na web",
"web_search_saving": "Salvando...",
"web_search_saved": "Configurações de pesquisa na web salvas"
},
"homepage": {
"hero_title_part1": "O espaço de trabalho com IA",

View file

@ -96,6 +96,10 @@
"create_new_search_space": "创建新的搜索空间",
"delete_title": "删除搜索空间",
"delete_confirm": "您确定要删除「{name}」吗?此操作无法撤销,将永久删除所有数据。",
"leave": "退出",
"leave_title": "退出搜索空间",
"leave_confirm": "您确定要退出「{name}」吗?您将无法访问此搜索空间中的所有文档和对话。",
"leaving": "退出中...",
"welcome_title": "欢迎使用 SurfSense",
"welcome_description": "创建您的第一个搜索空间开始组织知识、连接数据源并与AI对话。",
"create_first_button": "创建第一个搜索空间"
@ -104,6 +108,17 @@
"title": "用户设置",
"description": "管理您的账户设置和API访问",
"back_to_app": "返回应用",
"profile_nav_label": "个人资料",
"profile_nav_description": "管理您的显示名称和头像",
"profile_title": "个人资料",
"profile_description": "更新您的个人信息",
"profile_avatar": "个人头像",
"profile_display_name": "显示名称",
"profile_display_name_hint": "这是您的名称在应用中的显示方式",
"profile_email": "电子邮件",
"profile_save": "保存更改",
"profile_saved": "个人资料已成功更新",
"profile_save_error": "无法更新个人资料",
"api_key_nav_label": "API密钥",
"api_key_nav_description": "管理您的API访问令牌",
"api_key_title": "API密钥",
@ -460,9 +475,7 @@
"title": "设置",
"subtitle": "管理此搜索空间的 LLM 配置和角色分配。",
"back_to_dashboard": "返回仪表盘",
"model_configs": "模型配置",
"models": "模型",
"llm_roles": "LLM 角色",
"roles": "角色",
"llm_role_management": "LLM 角色管理",
"llm_role_desc": "为不同用途分配您的 LLM 配置到特定角色。",
@ -727,14 +740,9 @@
"back_to_app": "返回应用",
"nav_general": "常规",
"nav_general_desc": "名称、描述和基本信息",
"nav_models": "模型",
"nav_agent_models": "聊天模型",
"nav_agent_models_desc": "LLM 模型配置提示词和引用",
"nav_role_assignments": "角色分配",
"nav_role_assignments_desc": "为代理角色分配配置",
"nav_image_models": "图像模型",
"nav_image_models_desc": "配置图像生成模型",
"nav_vision_models": "视觉模型",
"nav_vision_models_desc": "配置具有视觉能力的LLM模型",
"nav_system_instructions": "系统指令",
"nav_system_instructions_desc": "搜索空间级别的 AI 指令",
"nav_public_links": "公开聊天链接",
@ -750,7 +758,27 @@
"general_reset": "重置更改",
"general_save": "保存更改",
"general_saving": "保存中...",
"general_unsaved_changes": "您有未保存的更改。点击\"保存更改\"以应用它们。"
"general_unsaved_changes": "您有未保存的更改。点击\"保存更改\"以应用它们。",
"nav_web_search": "网页搜索",
"nav_web_search_desc": "内置网页搜索设置",
"web_search_title": "网页搜索",
"web_search_description": "网页搜索由内置 SearXNG 实例提供支持。所有查询都通过您的服务器代理;不会向第三方发送数据。",
"web_search_enabled_label": "启用网页搜索",
"web_search_enabled_description": "启用后AI 代理可以搜索网页以获取新闻、价格和当前事件等实时信息。",
"web_search_status_healthy": "网页搜索服务运行正常",
"web_search_status_unhealthy": "网页搜索服务不可用",
"web_search_status_not_configured": "网页搜索服务未配置",
"web_search_engines_label": "搜索引擎",
"web_search_engines_placeholder": "google,brave,duckduckgo",
"web_search_engines_description": "要使用的 SearXNG 引擎的逗号分隔列表。留空则使用默认值。",
"web_search_language_label": "首选语言",
"web_search_language_placeholder": "zh",
"web_search_language_description": "IETF 语言标签(例如 zh、zh-CN。留空则自动检测。",
"web_search_safesearch_label": "SafeSearch 级别",
"web_search_safesearch_description": "0 = 关闭1 = 中等2 = 严格",
"web_search_save": "保存网页搜索设置",
"web_search_saving": "保存中...",
"web_search_saved": "网页搜索设置已保存"
},
"homepage": {
"hero_title_part1": "AI 工作空间",