"use client" import { AlertCircle, Check, ChevronRight, Eye, EyeOff, Key, Loader2, Plus, RefreshCw, Server, Settings2, Sparkles, Trash2, X, Zap, } from "lucide-react" import { useCallback, useEffect, useRef, useState } from "react" import { ProviderCredentialsFields, type SecretField, } from "@/components/provider-credentials-fields" import { ProviderLogo } from "@/components/provider-logo" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog" import { Button } from "@/components/ui/button" import { Command, CommandEmpty, CommandInput, CommandItem, CommandList, } from "@/components/ui/command" import { Dialog, DialogContent, DialogDescription, DialogHeader, 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 { ScrollArea } from "@/components/ui/scroll-area" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { Switch } from "@/components/ui/switch" import { useDictionary } from "@/hooks/use-dictionary" import type { UseModelConfigReturn } from "@/hooks/use-model-config" import { getApiEndpoint } from "@/lib/base-path" import { formatMessage } from "@/lib/i18n/utils" import type { ListedModel } from "@/lib/provider-models" import { STORAGE_KEYS } from "@/lib/storage" import type { ModelConfig, ProviderConfig, ProviderName, } from "@/lib/types/model-config" import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config" import { cn } from "@/lib/utils" interface ModelConfigDialogProps { open: boolean onOpenChange: (open: boolean) => void modelConfig: UseModelConfigReturn } type ValidationStatus = "idle" | "validating" | "success" | "error" // Configuration section with title and optional action function ConfigSection({ title, icon: Icon, action, children, }: { title: string icon: React.ComponentType<{ className?: string }> action?: React.ReactNode children: React.ReactNode }) { return (
{title}
{action}
{children}
) } // Card wrapper with subtle depth function ConfigCard({ children }: { children: React.ReactNode }) { return (
{children}
) } export function ModelConfigDialog({ open, onOpenChange, modelConfig, }: ModelConfigDialogProps) { const dict = useDictionary() const [selectedProviderId, setSelectedProviderId] = useState( null, ) const [showApiKey, setShowApiKey] = useState(false) const [validationStatus, setValidationStatus] = useState("idle") const [validationError, setValidationError] = useState("") const [customModelInput, setCustomModelInput] = useState("") const scrollRef = useRef(null) const validationResetTimeoutRef = useRef | null>(null) const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) const [deleteConfirmText, setDeleteConfirmText] = useState("") // Models whose test is running (they are all tested at once) const [validatingModelIds, setValidatingModelIds] = useState>( () => new Set(), ) const [duplicateError, setDuplicateError] = useState("") const [editError, setEditError] = useState<{ modelId: string message: string } | null>(null) // Model ID being typed; written to the config only when valid on blur const [modelIdDraft, setModelIdDraft] = useState<{ id: string value: string } | null>(null) // Models fetched from the provider, per provider config const [fetchedModels, setFetchedModels] = useState< Record >({}) const [fetchingModels, setFetchingModels] = useState(false) const [fetchModelsError, setFetchModelsError] = useState("") const [modelPickerOpen, setModelPickerOpen] = useState(false) // models.dev data for hints, loaded with the dialog (it is ~180 KB) const [getModelInfo, setGetModelInfo] = useState< typeof import("@/lib/model-catalog").getModelInfo | null >(null) const { config, addProvider, updateProvider, deleteProvider, addModel, updateModel, deleteModel, } = modelConfig // Get selected provider const selectedProvider = config.providers.find( (p) => p.id === selectedProviderId, ) // For requests that finish after the user switched provider or edited // a model id const selectedProviderIdRef = useRef(selectedProviderId) selectedProviderIdRef.current = selectedProviderId const configRef = useRef(config) configRef.current = config // Number of the latest Test click: only that test may reset the busy // state when its credentials changed meanwhile const validationRunRef = useRef(0) // A model list or test result belongs to the credentials it was asked // with; they can change meanwhile, here or in another tab const credentialsOf = (providerId: string) => { const p = configRef.current.providers.find((x) => x.id === providerId) return JSON.stringify([ p?.provider, p?.apiKey, p?.baseUrl, p?.awsAccessKeyId, p?.awsSecretAccessKey, p?.awsRegion, p?.awsSessionToken, p?.vertexApiKey, ]) } // Discard an unfinished model ID edit when the dialog closes useEffect(() => { if (!open) setModelIdDraft(null) }, [open]) // Cleanup validation reset timeout on unmount useEffect(() => { return () => { if (validationResetTimeoutRef.current) { clearTimeout(validationResetTimeoutRef.current) } } }, []) useEffect(() => { if (!open || getModelInfo) return import("@/lib/model-catalog").then((catalog) => setGetModelInfo(() => catalog.getModelInfo), ) }, [open, getModelInfo]) const handleFetchModels = async () => { if (!selectedProvider) return const providerId = selectedProvider.id const askedWith = credentialsOf(providerId) setFetchingModels(true) setFetchModelsError("") try { const response = await fetch( getApiEndpoint("/api/provider-models"), { method: "POST", headers: { "Content-Type": "application/json", "x-access-code": localStorage.getItem(STORAGE_KEYS.accessCode) || "", }, body: JSON.stringify({ provider: selectedProvider.provider, apiKey: selectedProvider.apiKey, baseUrl: selectedProvider.baseUrl, }), }, ) const data = await response.json().catch(() => ({})) if (credentialsOf(providerId) !== askedWith) return // The picker and the error belong to the provider shown const stillShown = selectedProviderIdRef.current === providerId if (Array.isArray(data.models)) { setFetchedModels((current) => ({ ...current, [providerId]: data.models, })) if (stillShown) setModelPickerOpen(true) } else if (stillShown) { const hints = dict.errors.llm as Record setFetchModelsError( [hints[data.code], data.error].filter(Boolean).join(" ") || `Request failed (${response.status})`, ) } } catch { if ( selectedProviderIdRef.current === providerId && credentialsOf(providerId) === askedWith ) { setFetchModelsError(dict.errors.networkError) } } finally { setFetchingModels(false) } } // The provider's own list once fetched, else the suggested models const suggestedModels: ListedModel[] = selectedProvider ? fetchedModels[selectedProvider.id] || (SUGGESTED_MODELS[selectedProvider.provider] || []).map((id) => ({ id, })) : [] // Tool calls are what drawing needs: false when known to be missing const supportsTools = (model: ListedModel) => selectedProvider ? (model.tools ?? getModelInfo?.(selectedProvider.provider, model.id)?.tools) : undefined // Filter out already-added models from suggestions const existingModelIds = selectedProvider?.models.map((m) => m.modelId) || [] const availableSuggestions = suggestedModels.filter( (model) => !existingModelIds.includes(model.id), ) const emptyStateSuggestions = selectedProvider ? (SUGGESTED_MODELS[selectedProvider.provider] || []) .filter((modelId) => !existingModelIds.includes(modelId)) .slice(0, 4) : [] // Handle adding a new provider const handleAddProvider = (providerType: ProviderName) => { const newProvider = addProvider(providerType) setSelectedProviderId(newProvider.id) setValidationStatus("idle") setFetchModelsError("") setModelPickerOpen(false) } // Handle provider field updates const handleProviderUpdate = ( field: keyof ProviderConfig, value: string | boolean, ) => { if (!selectedProviderId || !selectedProvider) return const updates: Partial = { [field]: value } // Reset validation of the provider and its models when credentials change const credentialFields = [ "apiKey", "baseUrl", "awsAccessKeyId", "awsSecretAccessKey", "awsRegion", "vertexApiKey", ] if (credentialFields.includes(field)) { setValidationStatus("idle") setValidatingModelIds(new Set()) setFetchedModels(({ [selectedProviderId]: _, ...rest }) => rest) setFetchModelsError("") updates.validated = false updates.models = selectedProvider.models.map((m) => ({ ...m, validated: undefined, validationError: undefined, validationWarning: undefined, responseTime: undefined, })) } updateProvider(selectedProviderId, updates) } // Handle adding a model to current provider // Returns true if model was added successfully, false otherwise const handleAddModel = (modelId: string): boolean => { if (!selectedProviderId || !selectedProvider) return false // Prevent duplicate model IDs if (existingModelIds.includes(modelId)) { setDuplicateError(`Model "${modelId}" already exists`) return false } setDuplicateError("") addModel(selectedProviderId, modelId) return true } // Handle deleting a model const handleDeleteModel = (modelConfigId: string) => { if (!selectedProviderId) return deleteModel(selectedProviderId, modelConfigId) } // Handle deleting the provider const handleDeleteProvider = () => { if (!selectedProviderId) return deleteProvider(selectedProviderId) setSelectedProviderId(null) setValidationStatus("idle") setDeleteConfirmOpen(false) } // Validate all models const handleValidate = useCallback(async () => { if (!selectedProvider || !selectedProviderId) return // Check credentials based on provider type const isBedrock = selectedProvider.provider === "bedrock" const isEdgeOne = selectedProvider.provider === "edgeone" const isOllama = selectedProvider.provider === "ollama" const isVertexAI = selectedProvider.provider === "vertexai" if (isBedrock) { if ( !selectedProvider.awsAccessKeyId || !selectedProvider.awsSecretAccessKey || !selectedProvider.awsRegion ) { return } } else if (isVertexAI) { // Vertex AI requires vertexApiKey for Express Mode if (!selectedProvider.vertexApiKey) { return } } else if (!isEdgeOne && !isOllama && !selectedProvider.apiKey) { return } // Need at least one model to validate if (selectedProvider.models.length === 0) { setValidationError("Add at least one model to validate") setValidationStatus("error") return } setValidationStatus("validating") setValidationError("") let allValid = true let errorCount = 0 let idChanged = false const askedWith = credentialsOf(selectedProviderId) const run = ++validationRunRef.current // For EdgeOne, construct baseUrl from current origin const baseUrl = isEdgeOne ? `${window.location.origin}/api/edgeai` : selectedProvider.baseUrl // Test every model at once; each row updates when its answer arrives setValidatingModelIds(new Set(selectedProvider.models.map((m) => m.id))) await Promise.all( selectedProvider.models.map(async (model) => { let update: Partial try { const response = await fetch( getApiEndpoint("/api/validate-model"), { method: "POST", headers: { "Content-Type": "application/json", "x-access-code": localStorage.getItem( STORAGE_KEYS.accessCode, ) || "", }, body: JSON.stringify({ provider: selectedProvider.provider, apiKey: selectedProvider.apiKey, baseUrl, modelId: model.modelId, // AWS Bedrock credentials awsAccessKeyId: selectedProvider.awsAccessKeyId, awsSecretAccessKey: selectedProvider.awsSecretAccessKey, awsRegion: selectedProvider.awsRegion, // Temporary AWS credentials, as the chat sends awsSessionToken: selectedProvider.awsSessionToken, // Vertex AI credentials (Express Mode) vertexApiKey: selectedProvider.vertexApiKey, }), }, ) const data = await response.json().catch(() => ({})) update = data.valid ? { validated: true, validationError: undefined, validationWarning: data.warning, responseTime: data.responseTime, } : { validated: false, // The hint for the error's kind, then the // provider's own message validationError: [ ( dict.errors.llm as Record< string, string > )[data.code], data.error, ] .filter(Boolean) .join(" ") || (response.ok ? "Validation failed" : `Request failed (${response.status})`), validationWarning: undefined, } } catch { update = { validated: false, validationError: "Network error", validationWarning: undefined, } } // A newer test started: its own results and spinners count, // whatever the credentials are now (they may have come back) if (run !== validationRunRef.current) return // Credentials changed during the test: drop the result. A // change in another tab left the spinner on, so clear it // (model ids are unique, whatever provider is shown). if (credentialsOf(selectedProviderId) !== askedWith) { setValidatingModelIds((prev) => { const next = new Set(prev) next.delete(model.id) return next }) return } // So did this model's id: the result is for the old one const current = configRef.current.providers .find((p) => p.id === selectedProviderId) ?.models.find((m) => m.id === model.id) if (current?.modelId !== model.modelId) { idChanged = true setValidatingModelIds((prev) => { const next = new Set(prev) next.delete(model.id) return next }) return } if (update.validated === false) { allValid = false errorCount++ } updateModel(selectedProviderId, model.id, update) setValidatingModelIds((prev) => { const next = new Set(prev) next.delete(model.id) return next }) }), ) if (run !== validationRunRef.current) return if (credentialsOf(selectedProviderId) !== askedWith) { // The status line is about the provider shown now if (selectedProviderIdRef.current === selectedProviderId) { setValidationStatus("idle") } return } // A model whose id changed was not tested if (allValid && !idChanged) { updateProvider(selectedProviderId, { validated: true }) } // The status line is about the provider shown now if (selectedProviderIdRef.current !== selectedProviderId) return if (idChanged) { setValidationStatus("idle") } else if (allValid) { setValidationStatus("success") // Reset to idle after showing success briefly (with cleanup) if (validationResetTimeoutRef.current) { clearTimeout(validationResetTimeoutRef.current) } validationResetTimeoutRef.current = setTimeout(() => { validationResetTimeoutRef.current = null if (run !== validationRunRef.current) return setValidationStatus("idle") }, 1500) } else { setValidationStatus("error") setValidationError(`${errorCount} model(s) failed validation`) } }, [ selectedProvider, selectedProviderId, updateProvider, updateModel, dict, ]) // Get all available provider types const availableProviders = Object.keys(PROVIDER_INFO) as ProviderName[] // Get display name for provider const getProviderDisplayName = (provider: ProviderConfig) => { return provider.name || PROVIDER_INFO[provider.provider].label } // Inline Test button + error, shared across credential layouts. Disabled // until the relevant credentials are present. const renderTestButton = (canValidate: boolean) => (
{validationStatus === "error" && validationError && (

{validationError}

)}
) // Plaintext secret input with show/hide toggle (the user dialog stores // keys client-side, so values are shown directly — unlike the masked // admin panel). The primary key field carries the inline Test button. const renderProviderSecret = (field: SecretField, id: string) => { if (!selectedProvider) return null const value = (selectedProvider[field] as string | undefined) ?? "" // The "primary" credential sits beside the Test button; for Bedrock // the test lives below the region, so its inputs have no inline test. const isBedrock = selectedProvider.provider === "bedrock" const withInlineTest = !isBedrock && (field === "apiKey" || field === "vertexApiKey") const canValidate = field === "vertexApiKey" ? !!selectedProvider.vertexApiKey : selectedProvider.provider === "ollama" || !!selectedProvider.apiKey const input = (
handleProviderUpdate(field, e.target.value) } placeholder={ field === "awsSecretAccessKey" ? dict.modelConfig.enterSecretKey : field === "awsAccessKeyId" ? "AKIA..." : dict.modelConfig.enterApiKey } className="h-9 pr-10 font-mono text-xs" />
) if (!withInlineTest) return input return (
{input} {renderTestButton(canValidate)}
) } return ( {/* Header */}
{dict.modelConfig?.title || "AI Model Configuration"}
{dict.modelConfig?.description || "Configure multiple AI providers and models for your workspace"}
{/* Provider List (Left Sidebar) */}
{dict.modelConfig.providers}
{config.providers.length === 0 ? (

{dict.modelConfig.addProviderHint}

) : ( config.providers.map((provider) => ( )) )}
{/* Add Provider */}
{/* Always empty so picking the same type again still fires */}
{/* Provider Details (Right Panel) */}
{selectedProvider ? (
{/* Provider Header */}

{ PROVIDER_INFO[ selectedProvider .provider ].label }

{selectedProvider.models .length === 0 ? dict.modelConfig .noModelsConfigured : formatMessage( dict.modelConfig .modelsConfiguredCount, { count: selectedProvider .models .length, }, )}

{selectedProvider.validated && (
{dict.modelConfig.verified}
)}
{/* Configuration Section */} handleProviderUpdate( field, value, ) } renderSecret={({ field, id }) => renderProviderSecret( field, id, ) } footer={ selectedProvider.provider === "bedrock" ? renderTestButton( !!selectedProvider.awsAccessKeyId && !!selectedProvider.awsSecretAccessKey && !!selectedProvider.awsRegion, ) : selectedProvider.provider === "edgeone" ? renderTestButton( true, ) : undefined } /> {/* Models Section */}
{ setCustomModelInput( e.target.value, ) if ( duplicateError ) { setDuplicateError( "", ) } }} onKeyDown={(e) => { if ( e.key === "Enter" && customModelInput.trim() ) { const success = handleAddModel( customModelInput.trim(), ) if (success) { setCustomModelInput( "", ) } } }} className={cn( "h-8 w-44 rounded-lg font-mono text-xs", duplicateError && "border-destructive focus-visible:ring-destructive", )} /> {duplicateError && (

{duplicateError}

)}
{PROVIDER_INFO[ selectedProvider.provider ].modelList && ( )} {/* modal: the dialog blocks the wheel outside itself, and the list is rendered outside it */} { dict .modelConfig .noModelsFound } {availableSuggestions.map( (model) => ( { handleAddModel( model.id, ) setModelPickerOpen( false, ) }} className="font-mono text-xs" > { model.id } {supportsTools( model, ) === false && ( { dict .modelConfig .noTools } )} ), )}
} > {fetchModelsError && (

{fetchModelsError}

)} {/* Model List */}
{selectedProvider.models.length === 0 ? (

{ dict.modelConfig .noModelsConfigured }

{emptyStateSuggestions.length > 0 && (
{emptyStateSuggestions.map( (modelId) => ( ), )}
)}
) : (
{selectedProvider.models.map( (model) => (
{/* Status icon */}
{validatingModelIds.has( model.id, ) ? ( // Currently validating
) : model.validated === true ? ( // Valid, with the time the test took
) : model.validated === false ? ( // Invalid
) : ( // Not validated yet
)}
{ // Allow free typing - validation happens on blur // Clear edit error when typing if ( editError?.modelId === model.id ) { setEditError( null, ) } setModelIdDraft( { id: model.id, value: e .target .value, }, ) }} onKeyDown={( e, ) => { if ( e.key === "Enter" ) { e.currentTarget.blur() } }} onBlur={( e, ) => { const newModelId = e.target.value.trim() // Drop the draft; an invalid ID falls back to the saved one setModelIdDraft( null, ) // Helper to show error with shake const showError = ( message: string, ) => { setEditError( { modelId: model.id, message, }, ) e.target.animate( [ { transform: "translateX(0)", }, { transform: "translateX(-4px)", }, { transform: "translateX(4px)", }, { transform: "translateX(-4px)", }, { transform: "translateX(4px)", }, { transform: "translateX(0)", }, ], { duration: 400, easing: "ease-in-out", }, ) e.target.focus() } // Check for empty model name if ( !newModelId ) { showError( dict .modelConfig .modelIdEmpty, ) return } // Check for duplicate const otherModelIds = selectedProvider?.models .filter( ( m, ) => m.id !== model.id, ) .map( ( m, ) => m.modelId, ) || [] if ( otherModelIds.includes( newModelId, ) ) { showError( dict .modelConfig .modelIdExists, ) return } // Clear error on valid blur setEditError( null, ) if ( selectedProviderId && newModelId !== model.modelId ) { updateModel( selectedProviderId, model.id, { modelId: newModelId, validated: undefined, validationError: undefined, validationWarning: undefined, responseTime: undefined, }, ) } }} className="flex-1 min-w-0 font-mono text-sm h-8 border-0 bg-transparent focus-visible:bg-background focus-visible:ring-1" />
{/* Show validation error inline */} {model.validated === false && model.validationError && (

{ model.validationError }

)} {!model.validationWarning && getModelInfo?.( selectedProvider.provider, model.modelId, )?.tools === false && (

{ dict .modelConfig .mayNotDraw }

)} {model.validated && model.validationWarning && (

{ model.validationWarning }

)} {/* Show edit error inline */} {editError?.modelId === model.id && (

{ editError.message }

)}
), )}
)}
) : (

{dict.modelConfig.configureProviders}

{dict.modelConfig.selectProviderHint}

)}
{/* Footer */}

{dict.modelConfig.apiKeyStored}

{/* Delete Confirmation Dialog */} { setDeleteConfirmOpen(open) if (!open) setDeleteConfirmText("") }} >
{dict.modelConfig.deleteProvider} {formatMessage(dict.modelConfig.deleteConfirmDesc, { name: selectedProvider ? selectedProvider.name || PROVIDER_INFO[selectedProvider.provider] .label : "this provider", })}
{selectedProvider && selectedProvider.models.length >= 3 && (
setDeleteConfirmText(e.target.value) } placeholder={ dict.modelConfig.typeProviderName } className="h-9" />
)} {dict.modelConfig.cancel} = 3 && deleteConfirmText !== (selectedProvider.name || PROVIDER_INFO[selectedProvider.provider] .label) } className="bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50" > {dict.modelConfig.delete}
) }