"use client"
import {
AlertCircle,
Check,
ChevronRight,
Clock,
Eye,
EyeOff,
Key,
Loader2,
Plus,
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 {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
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 { formatMessage } from "@/lib/i18n/utils"
import type { 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 (
)
}
// 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("")
const [validatingModelIndex, setValidatingModelIndex] = useState<
number | null
>(null)
const [duplicateError, setDuplicateError] = useState("")
const [editError, setEditError] = useState<{
modelId: string
message: string
} | null>(null)
const {
config,
addProvider,
updateProvider,
deleteProvider,
addModel,
updateModel,
deleteModel,
} = modelConfig
// Get selected provider
const selectedProvider = config.providers.find(
(p) => p.id === selectedProviderId,
)
// Cleanup validation reset timeout on unmount
useEffect(() => {
return () => {
if (validationResetTimeoutRef.current) {
clearTimeout(validationResetTimeoutRef.current)
}
}
}, [])
// Get suggested models for current provider
const suggestedModels = selectedProvider
? SUGGESTED_MODELS[selectedProvider.provider] || []
: []
// Filter out already-added models from suggestions
const existingModelIds =
selectedProvider?.models.map((m) => m.modelId) || []
const availableSuggestions = suggestedModels.filter(
(modelId) => !existingModelIds.includes(modelId),
)
// Handle adding a new provider
const handleAddProvider = (providerType: ProviderName) => {
const newProvider = addProvider(providerType)
setSelectedProviderId(newProvider.id)
setValidationStatus("idle")
}
// Handle provider field updates
const handleProviderUpdate = (
field: keyof ProviderConfig,
value: string | boolean,
) => {
if (!selectedProviderId) return
updateProvider(selectedProviderId, { [field]: value })
// Reset validation when credentials change
const credentialFields = [
"apiKey",
"baseUrl",
"awsAccessKeyId",
"awsSecretAccessKey",
"awsRegion",
"vertexApiKey",
]
if (credentialFields.includes(field)) {
setValidationStatus("idle")
updateProvider(selectedProviderId, { validated: false })
}
}
// 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
// Validate each model
for (let i = 0; i < selectedProvider.models.length; i++) {
const model = selectedProvider.models[i]
setValidatingModelIndex(i)
try {
// For EdgeOne, construct baseUrl from current origin
const baseUrl = isEdgeOne
? `${window.location.origin}/api/edgeai`
: selectedProvider.baseUrl
const response = await fetch("/api/validate-model", {
method: "POST",
headers: { "Content-Type": "application/json" },
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,
// Vertex AI credentials (Express Mode)
vertexApiKey: selectedProvider.vertexApiKey,
}),
})
const data = await response.json()
if (data.valid) {
updateModel(selectedProviderId, model.id, {
validated: true,
validationError: undefined,
})
} else {
allValid = false
errorCount++
updateModel(selectedProviderId, model.id, {
validated: false,
validationError: data.error || "Validation failed",
})
}
} catch {
allValid = false
errorCount++
updateModel(selectedProviderId, model.id, {
validated: false,
validationError: "Network error",
})
}
}
setValidatingModelIndex(null)
if (allValid) {
setValidationStatus("success")
updateProvider(selectedProviderId, { validated: true })
// Reset to idle after showing success briefly (with cleanup)
if (validationResetTimeoutRef.current) {
clearTimeout(validationResetTimeoutRef.current)
}
validationResetTimeoutRef.current = setTimeout(() => {
setValidationStatus("idle")
validationResetTimeoutRef.current = null
}, 1500)
} else {
setValidationStatus("error")
setValidationError(`${errorCount} model(s) failed validation`)
}
}, [selectedProvider, selectedProviderId, updateProvider, updateModel])
// 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 === "validating" ? (
) : validationStatus === "success" ? (
<>
{dict.modelConfig.verified}
>
) : (
dict.modelConfig.test
)}
{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"
/>
setShowApiKey(!showApiKey)}
aria-label={
showApiKey
? dict.modelConfig.hideValue
: dict.modelConfig.showValue
}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded"
>
{showApiKey ? (
) : (
)}
)
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) => (
{
setSelectedProviderId(
provider.id,
)
setValidationStatus("idle")
setShowApiKey(false)
}}
className={cn(
"group flex items-center gap-3 px-3 py-2.5 rounded-xl w-full",
"text-left text-sm transition-all duration-150 border border-transparent",
"hover:bg-interactive-hover",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
selectedProviderId ===
provider.id &&
"bg-surface-0 shadow-sm border-border-subtle",
)}
>
{getProviderDisplayName(
provider,
)}
{provider.validated ? (
) : (
)}
))
)}
{/* Add Provider */}
handleAddProvider(v as ProviderName)
}
>
{availableProviders.map((p) => (
))}
{/* 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}
)}
setDeleteConfirmOpen(true)
}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
{dict.modelConfig.deleteProvider}
{/* 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}
)}
{
if (
customModelInput.trim()
) {
const success =
handleAddModel(
customModelInput.trim(),
)
if (success) {
setCustomModelInput(
"",
)
}
}
}}
disabled={
!customModelInput.trim()
}
>
{
if (value) {
handleAddModel(
value,
)
}
}}
disabled={
availableSuggestions.length ===
0
}
>
{availableSuggestions.length ===
0
? dict
.modelConfig
.allAdded
: dict
.modelConfig
.suggested}
{availableSuggestions.map(
(modelId) => (
{modelId}
),
)}
}
>
{/* Model List */}
{selectedProvider.models.length ===
0 ? (
{
dict.modelConfig
.noModelsConfigured
}
) : (
{selectedProvider.models.map(
(model, index) => (
{/* Status icon */}
{validatingModelIndex !==
null &&
index ===
validatingModelIndex ? (
// Currently validating
) : validatingModelIndex !==
null &&
index >
validatingModelIndex &&
model.validated ===
undefined ? (
// Queued
) : model.validated ===
true ? (
// Valid
) : 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,
)
}
if (
selectedProviderId
) {
updateModel(
selectedProviderId,
model.id,
{
modelId:
e
.target
.value,
validated:
undefined,
validationError:
undefined,
},
)
}
}}
onKeyDown={(
e,
) => {
if (
e.key ===
"Enter"
) {
e.currentTarget.blur()
}
}}
onBlur={(
e,
) => {
const newModelId =
e.target.value.trim()
// 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,
)
}}
className="flex-1 min-w-0 font-mono text-sm h-8 border-0 bg-transparent focus-visible:bg-background focus-visible:ring-1"
/>
handleDeleteModel(
model.id,
)
}
aria-label={`Delete ${model.modelId}`}
>
{/* Show validation error inline */}
{model.validated ===
false &&
model.validationError && (
{
model.validationError
}
)}
{/* Show edit error inline */}
{editError?.modelId ===
model.id && (
{
editError.message
}
)}
),
)}
)}
) : (
{dict.modelConfig.configureProviders}
{dict.modelConfig.selectProviderHint}
)}
{/* Footer */}
{dict.modelConfig.showUnvalidatedModels}
{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 && (
{formatMessage(
dict.modelConfig.typeToConfirm,
{
name:
selectedProvider.name ||
PROVIDER_INFO[
selectedProvider.provider
].label,
},
)}
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}
)
}