diff --git a/apps/gui/src/app/actions.ts b/apps/gui/src/app/actions.ts index eefc5c1..966c199 100644 --- a/apps/gui/src/app/actions.ts +++ b/apps/gui/src/app/actions.ts @@ -7,8 +7,10 @@ import type { SimSnapshot } from "@/lib/simulation"; import { ProviderManager, ModelProviderInstance, - AVAILABLE_PROVIDERS, + getAvailableProviders as listAvailableProviders, ModelProviderMeta, + ModelLister, + ModelInfo, } from "@omnia/llm"; function resolveScenarioPath(relative: string): string { @@ -254,6 +256,7 @@ export async function createProviderInstance( modelName?: string, type: "generative" | "embedding" = "generative", maxContext?: number, + endpointUrl?: string, ): Promise { return ProviderManager.create( name, @@ -262,6 +265,7 @@ export async function createProviderInstance( modelName, type, maxContext, + endpointUrl, ); } @@ -281,6 +285,7 @@ export async function updateProviderInstance( modelName?: string, type: "generative" | "embedding" = "generative", maxContext?: number, + endpointUrl?: string, ): Promise { ProviderManager.update( id, @@ -290,6 +295,7 @@ export async function updateProviderInstance( modelName, type, maxContext, + endpointUrl, ); } @@ -305,7 +311,7 @@ export async function setProviderMapping( } export async function getAvailableProviders(): Promise { - return AVAILABLE_PROVIDERS; + return listAvailableProviders(); } export async function regenerateEmbeddings( @@ -313,3 +319,32 @@ export async function regenerateEmbeddings( ): Promise { await simulationManager.regenerateAllEmbeddings(newProviderInstanceId); } + +/** + * Fetch available models for a provider given its credentials. + * Used when creating a new instance (before it's saved to the DB). + */ +export async function fetchAvailableModels( + providerName: string, + apiKey: string, + endpointUrl?: string, +): Promise { + return ModelLister.listModels(providerName, apiKey, endpointUrl); +} + +/** + * Fetch available models for an existing saved provider instance. + * The API key is retrieved from the DB server-side — never sent to the client. + */ +export async function fetchAvailableModelsForInstance( + instanceId: string, +): Promise { + const instances = ProviderManager.list(); + const inst = instances.find((i) => i.id === instanceId); + if (!inst) return []; + return ModelLister.listModels( + inst.providerName, + inst.apiKey, + inst.endpointUrl, + ); +} diff --git a/apps/gui/src/components/config/ProviderInstancesConfig.tsx b/apps/gui/src/components/config/ProviderInstancesConfig.tsx index 4b9da0e..c70a98a 100644 --- a/apps/gui/src/components/config/ProviderInstancesConfig.tsx +++ b/apps/gui/src/components/config/ProviderInstancesConfig.tsx @@ -1,14 +1,20 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { createProviderInstance, updateProviderInstance, setActiveProviderInstance, regenerateEmbeddings, deleteProviderInstance, + fetchAvailableModels, + fetchAvailableModelsForInstance, } from "@/app/actions"; -import type { ModelProviderInstance, ModelProviderMeta } from "@omnia/llm"; +import type { + ModelInfo, + ModelProviderInstance, + ModelProviderMeta, +} from "@omnia/llm"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -22,6 +28,14 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; import { Card, CardHeader, @@ -38,6 +52,7 @@ import { } from "@/components/ui/item"; import { Empty, EmptyTitle, EmptyDescription } from "@/components/ui/empty"; import { cn } from "@/lib/utils"; +import { RefreshCwIcon } from "lucide-react"; interface ProviderInstancesConfigProps { instances: ModelProviderInstance[]; @@ -64,9 +79,65 @@ export function ProviderInstancesConfig({ "generative", ); const [editMaxContext, setEditMaxContext] = useState(32768); + const [editEndpointUrl, setEditEndpointUrl] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + // Model listing state + const [availableModels, setAvailableModels] = useState([]); + const [modelsLoading, setModelsLoading] = useState(false); + const debounceTimer = useRef | null>(null); + + // Fetch models for an existing saved instance + const fetchModelsForExistingInstance = async (instanceId: string) => { + setModelsLoading(true); + setAvailableModels([]); + try { + const models = await fetchAvailableModelsForInstance(instanceId); + setAvailableModels(models); + } catch { + setAvailableModels([]); + } finally { + setModelsLoading(false); + } + }; + + // Fetch models for a new instance (using live key/endpoint from form) + const fetchModelsForNewInstance = ( + provider: string, + apiKey: string, + endpointUrl: string, + ) => { + if (debounceTimer.current) clearTimeout(debounceTimer.current); + + const isOllama = provider === "ollama"; + const hasCredentials = isOllama + ? endpointUrl.trim().length > 0 + : apiKey.trim().length > 8; // don't spam API with partial keys + + if (!hasCredentials) { + setAvailableModels([]); + return; + } + + debounceTimer.current = setTimeout(async () => { + setModelsLoading(true); + setAvailableModels([]); + try { + const models = await fetchAvailableModels( + provider, + isOllama ? "none" : apiKey, + isOllama ? endpointUrl : undefined, + ); + setAvailableModels(models); + } catch { + setAvailableModels([]); + } finally { + setModelsLoading(false); + } + }, 600); + }; + useEffect(() => { if (selectedInstanceId === null) { setEditName(""); @@ -76,6 +147,8 @@ export function ProviderInstancesConfig({ setEditIsActive(false); setEditType("generative"); setEditMaxContext(32768); + setEditEndpointUrl(""); + setAvailableModels([]); } else if (selectedInstanceId === "new") { setEditName(""); const defaultProvider = "google-genai"; @@ -86,6 +159,8 @@ export function ProviderInstancesConfig({ setEditModel(pMeta?.defaultModel || "gemini-2.5-flash"); setEditIsActive(false); setEditMaxContext(32768); + setEditEndpointUrl(""); + setAvailableModels([]); } else { const inst = instances.find((i) => i.id === selectedInstanceId); if (inst) { @@ -109,10 +184,22 @@ export function ProviderInstancesConfig({ ? inst.maxContext : 32768, ); + setEditEndpointUrl(inst.endpointUrl || ""); + setAvailableModels([]); + // Auto-fetch models for existing instances + fetchModelsForExistingInstance(selectedInstanceId); } } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedInstanceId, instances, availableProviders]); + // Re-fetch models when provider/key/endpoint changes on new instance form + useEffect(() => { + if (selectedInstanceId !== "new") return; + fetchModelsForNewInstance(editProvider, editKey, editEndpointUrl); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editProvider, editKey, editEndpointUrl, selectedInstanceId]); + const handleProviderChange = (providerId: string | null) => { if (!providerId) return; setEditProvider(providerId); @@ -122,6 +209,7 @@ export function ProviderInstancesConfig({ ? pMeta?.defaultEmbeddingModel || "" : pMeta?.defaultModel || "", ); + setAvailableModels([]); }; const handleTypeChange = (type: "generative" | "embedding") => { @@ -134,6 +222,14 @@ export function ProviderInstancesConfig({ ); }; + const handleRefreshModels = () => { + if (selectedInstanceId && selectedInstanceId !== "new") { + fetchModelsForExistingInstance(selectedInstanceId); + } else { + fetchModelsForNewInstance(editProvider, editKey, editEndpointUrl); + } + }; + const handleSave = async (e: React.FormEvent) => { e.preventDefault(); if (!editName.trim()) { @@ -149,7 +245,7 @@ export function ProviderInstancesConfig({ let targetInstanceId = selectedInstanceId; if (selectedInstanceId === "new") { - if (!editKey.trim()) { + if (editProvider !== "ollama" && !editKey.trim()) { setError("API Key is required for new instances."); setLoading(false); return; @@ -157,10 +253,13 @@ export function ProviderInstancesConfig({ const created = await createProviderInstance( editName, editProvider, - editKey, + editProvider === "ollama" ? "none" : editKey, editModel || undefined, editType, editType === "generative" ? editMaxContext : 0, + editProvider === "ollama" + ? editEndpointUrl || "http://localhost:11434" + : undefined, ); if (editIsActive) { await setActiveProviderInstance(created.id); @@ -194,10 +293,13 @@ export function ProviderInstancesConfig({ selectedInstanceId, editName, editProvider, - editKey || undefined, + editProvider === "ollama" ? "none" : editKey || undefined, editModel || undefined, editType, editType === "generative" ? editMaxContext : 0, + editProvider === "ollama" + ? editEndpointUrl || "http://localhost:11434" + : undefined, ); if (editIsActive) { await setActiveProviderInstance(selectedInstanceId); @@ -336,11 +438,11 @@ export function ProviderInstancesConfig({ } items={[ { - label: "Generative (Text Completion)", + label: "Generative (Text Generation)", value: "generative", }, { - label: "Embedding (Vector generation)", + label: "Embedding (Vector Embeddings)", value: "embedding", }, ]} @@ -351,10 +453,10 @@ export function ProviderInstancesConfig({ - Generative (Chat / Text Completion) + Generative (Text Generation) - Embedding (Vector generation) + Embedding (Vector Embeddings) @@ -398,30 +500,109 @@ export function ProviderInstancesConfig({ )} -
- - setEditKey(e.target.value)} - placeholder={ - selectedInstanceId === "new" - ? "AIzaSy..." - : "•••••••• (unchanged)" - } - required={selectedInstanceId === "new"} - /> -
+ {editProvider !== "ollama" && ( +
+ + setEditKey(e.target.value)} + placeholder={ + selectedInstanceId === "new" + ? "AIzaSy..." + : "•••••••• (unchanged)" + } + required={selectedInstanceId === "new"} + /> +
+ )} + + {editProvider === "ollama" && ( +
+ + setEditEndpointUrl(e.target.value)} + placeholder="e.g. http://localhost:11434" + required + /> +
+ )}
- - + + +
+ setEditModel(e.target.value)} - placeholder="e.g. gemini-2.5-flash, gemini-2.5-pro" - /> + onValueChange={(v) => { + if (v) setEditModel(v); + }} + items={availableModels.map((m) => m.id)} + > + 0 + ? "Search or select a model…" + : "e.g. gemini-2.5-flash" + } + disabled={modelsLoading} + showClear={false} + value={editModel} + onChange={(e) => + setEditModel((e.target as HTMLInputElement).value) + } + className="w-full" + /> + + + {modelsLoading + ? "Fetching models…" + : "No models found. Type a custom model name above."} + + + {(modelId: string) => { + const model = availableModels.find( + (m) => m.id === modelId, + ); + return ( + + {modelId} + {model?.ownedBy && ( + + {model.ownedBy} + + )} + + ); + }} + + + {editType === "generative" && ( diff --git a/apps/gui/src/components/ui/combobox.tsx b/apps/gui/src/components/ui/combobox.tsx new file mode 100644 index 0000000..52d5399 --- /dev/null +++ b/apps/gui/src/components/ui/combobox.tsx @@ -0,0 +1,300 @@ +"use client"; + +import * as React from "react"; +import { Combobox as ComboboxPrimitive } from "@base-ui/react"; +import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@/components/ui/input-group"; + +const Combobox = ComboboxPrimitive.Root; + +function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) { + return ; +} + +function ComboboxTrigger({ + className, + children, + ...props +}: ComboboxPrimitive.Trigger.Props) { + return ( + + {children} + + + ); +} + +function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) { + return ( + } + className={cn(className)} + {...props} + > + + + ); +} + +function ComboboxInput({ + className, + children, + disabled = false, + showTrigger = true, + showClear = false, + ...props +}: ComboboxPrimitive.Input.Props & { + showTrigger?: boolean; + showClear?: boolean; +}) { + return ( + + } + {...props} + /> + + {showTrigger && ( + } + data-slot="input-group-button" + className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent" + disabled={disabled} + /> + )} + {showClear && } + + {children} + + ); +} + +function ComboboxContent({ + className, + side = "bottom", + sideOffset = 6, + align = "start", + alignOffset = 0, + anchor, + ...props +}: ComboboxPrimitive.Popup.Props & + Pick< + ComboboxPrimitive.Positioner.Props, + "side" | "align" | "sideOffset" | "alignOffset" | "anchor" + >) { + return ( + + + + + + ); +} + +function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) { + return ( + + ); +} + +function ComboboxItem({ + className, + children, + ...props +}: ComboboxPrimitive.Item.Props) { + return ( + + {children} + + } + > + + + + ); +} + +function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) { + return ( + + ); +} + +function ComboboxLabel({ + className, + ...props +}: ComboboxPrimitive.GroupLabel.Props) { + return ( + + ); +} + +function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) { + return ( + + ); +} + +function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) { + return ( + + ); +} + +function ComboboxSeparator({ + className, + ...props +}: ComboboxPrimitive.Separator.Props) { + return ( + + ); +} + +function ComboboxChips({ + className, + ...props +}: React.ComponentPropsWithRef & + ComboboxPrimitive.Chips.Props) { + return ( + + ); +} + +function ComboboxChip({ + className, + children, + showRemove = true, + ...props +}: ComboboxPrimitive.Chip.Props & { + showRemove?: boolean; +}) { + return ( + + {children} + {showRemove && ( + } + className="-ml-1 opacity-50 hover:opacity-100" + data-slot="combobox-chip-remove" + > + + + )} + + ); +} + +function ComboboxChipsInput({ + className, + ...props +}: ComboboxPrimitive.Input.Props) { + return ( + + ); +} + +function useComboboxAnchor() { + return React.useRef(null); +} + +export { + Combobox, + ComboboxInput, + ComboboxContent, + ComboboxList, + ComboboxItem, + ComboboxGroup, + ComboboxLabel, + ComboboxCollection, + ComboboxEmpty, + ComboboxSeparator, + ComboboxChips, + ComboboxChip, + ComboboxChipsInput, + ComboboxTrigger, + ComboboxValue, + useComboboxAnchor, +}; diff --git a/apps/gui/src/components/ui/input-group.tsx b/apps/gui/src/components/ui/input-group.tsx new file mode 100644 index 0000000..d005a09 --- /dev/null +++ b/apps/gui/src/components/ui/input-group.tsx @@ -0,0 +1,172 @@ +"use client"; + +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; + +function InputGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5", + className, + )} + {...props} + /> + ); +} + +const inputGroupAddonVariants = cva( + "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4", + { + variants: { + align: { + "inline-start": + "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]", + "inline-end": + "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]", + "block-start": + "order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2", + "block-end": + "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2", + }, + }, + defaultVariants: { + align: "inline-start", + }, + }, +); + +function InputGroupAddon({ + className, + align = "inline-start", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
{ + if ((e.target as HTMLElement).closest("button")) { + return; + } + e.currentTarget.parentElement?.querySelector("input")?.focus(); + }} + {...props} + /> + ); +} + +const inputGroupButtonVariants = cva( + "flex items-center gap-2 text-sm shadow-none", + { + variants: { + size: { + xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5", + sm: "", + "icon-xs": + "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0", + "icon-sm": "size-8 p-0 has-[>svg]:p-0", + }, + }, + defaultVariants: { + size: "xs", + }, + }, +); + +function InputGroupButton({ + className, + type = "button", + variant = "ghost", + size = "xs", + render, + ...props +}: Omit, "size" | "type"> & + VariantProps & { + type?: "button" | "submit" | "reset"; + render?: React.ReactElement; + }) { + if (render) { + return React.cloneElement(render, { + className: cn( + inputGroupButtonVariants({ size }), + (render.props as any)?.className, + className, + ), + type, + ...props, + } as any); + } + + return ( +