mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-05 01:07:56 +08:00
Found by the PR review: - The built-in examples showed a finished card and an empty canvas. They are answered in the browser, never reach the tool handler, and relied on the final redraw that an earlier commit removed. The example branch now loads its diagram itself. - When the request failed while an edit was streaming (a provider error, a lost connection), its preview stayed on the canvas. The error handler now restores the diagram from before the preview. - The model picker could not scroll with the wheel or touch: the settings dialog blocks those events outside itself, and the picker is rendered outside it. The popover is modal now. - A fetch error and the open picker stayed when switching providers. - Editing a model id kept the old test warning and response time, which also hid the "may not be able to draw" hint for the new id.
1520 lines
88 KiB
TypeScript
1520 lines
88 KiB
TypeScript
"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 (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
|
{title}
|
|
</span>
|
|
</div>
|
|
{action}
|
|
</div>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Card wrapper with subtle depth
|
|
function ConfigCard({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div className="rounded-2xl border border-border-subtle bg-surface-2/50 p-5 space-y-5">
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function ModelConfigDialog({
|
|
open,
|
|
onOpenChange,
|
|
modelConfig,
|
|
}: ModelConfigDialogProps) {
|
|
const dict = useDictionary()
|
|
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(
|
|
null,
|
|
)
|
|
const [showApiKey, setShowApiKey] = useState(false)
|
|
const [validationStatus, setValidationStatus] =
|
|
useState<ValidationStatus>("idle")
|
|
const [validationError, setValidationError] = useState<string>("")
|
|
const [customModelInput, setCustomModelInput] = useState("")
|
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
const validationResetTimeoutRef = useRef<ReturnType<
|
|
typeof setTimeout
|
|
> | 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<Set<string>>(
|
|
() => new Set(),
|
|
)
|
|
const [duplicateError, setDuplicateError] = useState<string>("")
|
|
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)
|
|
// Bumped on every credential edit so a running test can tell that its
|
|
// results belong to the old credentials
|
|
const credentialsVersionRef = useRef(0)
|
|
// Models fetched from the provider, per provider config
|
|
const [fetchedModels, setFetchedModels] = useState<
|
|
Record<string, ListedModel[]>
|
|
>({})
|
|
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,
|
|
)
|
|
|
|
// 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
|
|
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 (Array.isArray(data.models)) {
|
|
setFetchedModels((current) => ({
|
|
...current,
|
|
[providerId]: data.models,
|
|
}))
|
|
setModelPickerOpen(true)
|
|
} else {
|
|
const hints = dict.errors.llm as Record<string, string>
|
|
setFetchModelsError(
|
|
[hints[data.code], data.error].filter(Boolean).join(" ") ||
|
|
`Request failed (${response.status})`,
|
|
)
|
|
}
|
|
} catch {
|
|
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<ProviderConfig> = { [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)) {
|
|
credentialsVersionRef.current++
|
|
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
|
|
const credentialsVersion = credentialsVersionRef.current
|
|
|
|
// For EdgeOne, construct baseUrl from current origin
|
|
const baseUrl = isEdgeOne
|
|
? `${window.location.origin}${getApiEndpoint("/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<ModelConfig>
|
|
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,
|
|
// 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,
|
|
}
|
|
}
|
|
// Credentials changed during the test: drop the result
|
|
if (credentialsVersionRef.current !== credentialsVersion) 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 (credentialsVersionRef.current !== credentialsVersion) return
|
|
|
|
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,
|
|
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) => (
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant={validationStatus === "success" ? "outline" : "default"}
|
|
size="sm"
|
|
onClick={handleValidate}
|
|
disabled={!canValidate || validationStatus === "validating"}
|
|
className={cn(
|
|
"h-9 px-4",
|
|
validationStatus === "success" &&
|
|
"text-success border-success/30 bg-success-muted hover:bg-success-muted",
|
|
)}
|
|
>
|
|
{validationStatus === "validating" ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : validationStatus === "success" ? (
|
|
<>
|
|
<Check className="h-4 w-4 mr-1.5 animate-check-pop" />
|
|
{dict.modelConfig.verified}
|
|
</>
|
|
) : (
|
|
dict.modelConfig.test
|
|
)}
|
|
</Button>
|
|
{validationStatus === "error" && validationError && (
|
|
<p className="text-xs text-destructive flex items-center gap-1">
|
|
<X className="h-3 w-3" />
|
|
{validationError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
|
|
// 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 = (
|
|
<div className="relative flex-1">
|
|
<Input
|
|
id={id}
|
|
type={showApiKey ? "text" : "password"}
|
|
value={value}
|
|
onChange={(e) =>
|
|
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"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => 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 ? (
|
|
<EyeOff className="h-4 w-4" />
|
|
) : (
|
|
<Eye className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
)
|
|
if (!withInlineTest) return input
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex gap-2">
|
|
{input}
|
|
{renderTestButton(canValidate)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-4xl h-[80vh] max-h-[800px] overflow-hidden flex flex-col gap-0 p-0">
|
|
{/* Header */}
|
|
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
|
|
<DialogTitle className="flex items-center gap-3">
|
|
<div className="p-2 rounded-xl bg-surface-2">
|
|
<Server className="h-5 w-5 text-primary" />
|
|
</div>
|
|
{dict.modelConfig?.title || "AI Model Configuration"}
|
|
</DialogTitle>
|
|
<DialogDescription className="mt-1">
|
|
{dict.modelConfig?.description ||
|
|
"Configure multiple AI providers and models for your workspace"}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex flex-1 min-h-0 overflow-hidden border-t border-border-subtle">
|
|
{/* Provider List (Left Sidebar) */}
|
|
<div className="w-60 shrink-0 flex flex-col bg-surface-1/50 border-r border-border-subtle">
|
|
<div className="px-4 py-3">
|
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
|
{dict.modelConfig.providers}
|
|
</span>
|
|
</div>
|
|
|
|
<ScrollArea className="flex-1 px-2 min-h-0">
|
|
<div className="space-y-1 pb-2">
|
|
{config.providers.length === 0 ? (
|
|
<div className="px-3 py-8 text-center">
|
|
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-surface-2 mb-3">
|
|
<Plus className="h-5 w-5 text-muted-foreground" />
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{dict.modelConfig.addProviderHint}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
config.providers.map((provider) => (
|
|
<button
|
|
key={provider.id}
|
|
type="button"
|
|
onClick={() => {
|
|
setSelectedProviderId(
|
|
provider.id,
|
|
)
|
|
setValidationStatus("idle")
|
|
setShowApiKey(false)
|
|
// These belong to the
|
|
// provider shown before
|
|
setFetchModelsError("")
|
|
setModelPickerOpen(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",
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"w-8 h-8 rounded-lg flex items-center justify-center",
|
|
"bg-surface-2 transition-colors duration-150",
|
|
selectedProviderId ===
|
|
provider.id &&
|
|
"bg-primary/10",
|
|
)}
|
|
>
|
|
<ProviderLogo
|
|
provider={provider.provider}
|
|
className="flex-shrink-0"
|
|
/>
|
|
</div>
|
|
<span className="flex-1 truncate font-medium">
|
|
{getProviderDisplayName(
|
|
provider,
|
|
)}
|
|
</span>
|
|
{provider.validated ? (
|
|
<div className="flex-shrink-0 flex items-center justify-center w-5 h-5 rounded-full bg-success-muted">
|
|
<Check className="h-3 w-3 text-success" />
|
|
</div>
|
|
) : (
|
|
<ChevronRight
|
|
className={cn(
|
|
"h-4 w-4 text-muted-foreground/50 transition-transform duration-150",
|
|
selectedProviderId ===
|
|
provider.id &&
|
|
"translate-x-0.5",
|
|
)}
|
|
/>
|
|
)}
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
{/* Add Provider */}
|
|
<div className="p-3 border-t border-border-subtle">
|
|
{/* Always empty so picking the same type again still fires */}
|
|
<Select
|
|
value=""
|
|
onValueChange={(v) =>
|
|
handleAddProvider(v as ProviderName)
|
|
}
|
|
>
|
|
<SelectTrigger className="w-full h-9 rounded-xl bg-surface-0 border-border-subtle hover:bg-interactive-hover">
|
|
<Plus className="h-4 w-4 mr-2 text-muted-foreground" />
|
|
<SelectValue
|
|
placeholder={
|
|
dict.modelConfig.addProvider
|
|
}
|
|
/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableProviders.map((p) => (
|
|
<SelectItem
|
|
key={p}
|
|
value={p}
|
|
className="cursor-pointer"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<ProviderLogo provider={p} />
|
|
<span>
|
|
{PROVIDER_INFO[p].label}
|
|
</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Provider Details (Right Panel) */}
|
|
<div className="flex-1 min-w-0 flex flex-col overflow-auto scrollbar-thin">
|
|
{selectedProvider ? (
|
|
<ScrollArea className="flex-1" ref={scrollRef}>
|
|
<div className="p-6 space-y-8">
|
|
{/* Provider Header */}
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-surface-2">
|
|
<ProviderLogo
|
|
provider={
|
|
selectedProvider.provider
|
|
}
|
|
className="h-6 w-6"
|
|
/>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="font-semibold text-lg tracking-tight">
|
|
{
|
|
PROVIDER_INFO[
|
|
selectedProvider
|
|
.provider
|
|
].label
|
|
}
|
|
</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
{selectedProvider.models
|
|
.length === 0
|
|
? dict.modelConfig
|
|
.noModelsConfigured
|
|
: formatMessage(
|
|
dict.modelConfig
|
|
.modelsConfiguredCount,
|
|
{
|
|
count: selectedProvider
|
|
.models
|
|
.length,
|
|
},
|
|
)}
|
|
</p>
|
|
</div>
|
|
{selectedProvider.validated && (
|
|
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-success-muted text-success">
|
|
<Check className="h-3.5 w-3.5 animate-check-pop" />
|
|
<span className="text-xs font-medium">
|
|
{dict.modelConfig.verified}
|
|
</span>
|
|
</div>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() =>
|
|
setDeleteConfirmOpen(true)
|
|
}
|
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
>
|
|
<Trash2 className="h-4 w-4 mr-1.5" />
|
|
{dict.modelConfig.deleteProvider}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Configuration Section */}
|
|
<ConfigSection
|
|
title={dict.modelConfig.configuration}
|
|
icon={Settings2}
|
|
>
|
|
<ConfigCard>
|
|
<ProviderCredentialsFields
|
|
provider={
|
|
selectedProvider.provider
|
|
}
|
|
name={selectedProvider.name}
|
|
baseUrl={
|
|
selectedProvider.baseUrl
|
|
}
|
|
awsRegion={
|
|
selectedProvider.awsRegion
|
|
}
|
|
onChange={(field, value) =>
|
|
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
|
|
}
|
|
/>
|
|
</ConfigCard>
|
|
</ConfigSection>
|
|
|
|
{/* Models Section */}
|
|
<ConfigSection
|
|
title={dict.modelConfig.models}
|
|
icon={Sparkles}
|
|
action={
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative">
|
|
<Input
|
|
placeholder={
|
|
dict.modelConfig
|
|
.customModelId
|
|
}
|
|
value={customModelInput}
|
|
onChange={(e) => {
|
|
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 && (
|
|
<p className="absolute top-full left-0 mt-1 text-[11px] text-destructive">
|
|
{duplicateError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 rounded-lg"
|
|
onClick={() => {
|
|
if (
|
|
customModelInput.trim()
|
|
) {
|
|
const success =
|
|
handleAddModel(
|
|
customModelInput.trim(),
|
|
)
|
|
if (success) {
|
|
setCustomModelInput(
|
|
"",
|
|
)
|
|
}
|
|
}
|
|
}}
|
|
disabled={
|
|
!customModelInput.trim()
|
|
}
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
</Button>
|
|
{PROVIDER_INFO[
|
|
selectedProvider.provider
|
|
].modelList && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 rounded-lg"
|
|
onClick={
|
|
handleFetchModels
|
|
}
|
|
disabled={
|
|
fetchingModels
|
|
}
|
|
title={
|
|
dict.modelConfig
|
|
.fetchModels
|
|
}
|
|
aria-label={
|
|
dict.modelConfig
|
|
.fetchModels
|
|
}
|
|
>
|
|
{fetchingModels ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="h-3.5 w-3.5" />
|
|
)}
|
|
</Button>
|
|
)}
|
|
{/* modal: the dialog blocks the
|
|
wheel outside itself, and the
|
|
list is rendered outside it */}
|
|
<Popover
|
|
modal
|
|
open={modelPickerOpen}
|
|
onOpenChange={
|
|
setModelPickerOpen
|
|
}
|
|
>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-28 h-8 rounded-lg text-xs"
|
|
disabled={
|
|
availableSuggestions.length ===
|
|
0
|
|
}
|
|
>
|
|
{availableSuggestions.length ===
|
|
0
|
|
? dict
|
|
.modelConfig
|
|
.allAdded
|
|
: dict
|
|
.modelConfig
|
|
.suggested}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="w-80 p-0"
|
|
align="end"
|
|
>
|
|
<Command>
|
|
<CommandInput
|
|
placeholder={
|
|
dict
|
|
.modelConfig
|
|
.searchModels
|
|
}
|
|
/>
|
|
<CommandList className="max-h-72">
|
|
<CommandEmpty>
|
|
{
|
|
dict
|
|
.modelConfig
|
|
.noModelsFound
|
|
}
|
|
</CommandEmpty>
|
|
{availableSuggestions.map(
|
|
(model) => (
|
|
<CommandItem
|
|
key={
|
|
model.id
|
|
}
|
|
value={
|
|
model.id
|
|
}
|
|
onSelect={() => {
|
|
handleAddModel(
|
|
model.id,
|
|
)
|
|
setModelPickerOpen(
|
|
false,
|
|
)
|
|
}}
|
|
className="font-mono text-xs"
|
|
>
|
|
<span className="truncate">
|
|
{
|
|
model.id
|
|
}
|
|
</span>
|
|
{supportsTools(
|
|
model,
|
|
) ===
|
|
false && (
|
|
<span className="ml-auto shrink-0 font-sans text-[10px] text-amber-600 dark:text-amber-400">
|
|
{
|
|
dict
|
|
.modelConfig
|
|
.noTools
|
|
}
|
|
</span>
|
|
)}
|
|
</CommandItem>
|
|
),
|
|
)}
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
}
|
|
>
|
|
{fetchModelsError && (
|
|
<p className="mb-2 text-xs text-destructive">
|
|
{fetchModelsError}
|
|
</p>
|
|
)}
|
|
{/* Model List */}
|
|
<div className="rounded-2xl border border-border-subtle bg-surface-2/30 overflow-hidden min-h-[120px]">
|
|
{selectedProvider.models.length ===
|
|
0 ? (
|
|
<div className="p-6 text-center h-full flex flex-col items-center justify-center">
|
|
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-surface-2 mb-3">
|
|
<ProviderLogo
|
|
provider={
|
|
selectedProvider.provider
|
|
}
|
|
className="size-5 text-muted-foreground"
|
|
/>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
{
|
|
dict.modelConfig
|
|
.noModelsConfigured
|
|
}
|
|
</p>
|
|
{emptyStateSuggestions.length >
|
|
0 && (
|
|
<div className="mt-4 flex max-w-full flex-wrap items-center justify-center gap-2">
|
|
{emptyStateSuggestions.map(
|
|
(modelId) => (
|
|
<Button
|
|
key={
|
|
modelId
|
|
}
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 max-w-[220px] rounded-lg px-2 font-mono text-[11px]"
|
|
onClick={() =>
|
|
handleAddModel(
|
|
modelId,
|
|
)
|
|
}
|
|
>
|
|
<Plus className="h-3 w-3 shrink-0" />
|
|
<span className="truncate">
|
|
{
|
|
modelId
|
|
}
|
|
</span>
|
|
</Button>
|
|
),
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-border-subtle">
|
|
{selectedProvider.models.map(
|
|
(model) => (
|
|
<div
|
|
key={model.id}
|
|
className={cn(
|
|
"transition-colors duration-150 hover:bg-interactive-hover/50",
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-3 p-3 min-w-0">
|
|
{/* Status icon */}
|
|
<div className="flex items-center justify-center w-8 h-8 rounded-lg flex-shrink-0">
|
|
{validatingModelIds.has(
|
|
model.id,
|
|
) ? (
|
|
// Currently validating
|
|
<div className="w-full h-full rounded-lg bg-blue-500/10 flex items-center justify-center">
|
|
<Loader2 className="h-4 w-4 text-blue-500 animate-spin" />
|
|
</div>
|
|
) : model.validated ===
|
|
true ? (
|
|
// Valid, with the time the test took
|
|
<div
|
|
className="w-full h-full rounded-lg bg-success-muted flex items-center justify-center"
|
|
title={
|
|
model.responseTime
|
|
? `${(model.responseTime / 1000).toFixed(1)} s`
|
|
: undefined
|
|
}
|
|
>
|
|
<Check className="h-4 w-4 text-success" />
|
|
</div>
|
|
) : model.validated ===
|
|
false ? (
|
|
// Invalid
|
|
<div className="w-full h-full rounded-lg bg-destructive/10 flex items-center justify-center">
|
|
<AlertCircle className="h-4 w-4 text-destructive" />
|
|
</div>
|
|
) : (
|
|
// Not validated yet
|
|
<div className="w-full h-full rounded-lg bg-primary/5 flex items-center justify-center">
|
|
<Zap className="h-4 w-4 text-primary" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Input
|
|
value={
|
|
modelIdDraft?.id ===
|
|
model.id
|
|
? modelIdDraft.value
|
|
: model.modelId
|
|
}
|
|
title={
|
|
model.modelId
|
|
}
|
|
onChange={(
|
|
e,
|
|
) => {
|
|
// 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"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
|
onClick={() =>
|
|
handleDeleteModel(
|
|
model.id,
|
|
)
|
|
}
|
|
aria-label={`Delete ${model.modelId}`}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
{/* Show validation error inline */}
|
|
{model.validated ===
|
|
false &&
|
|
model.validationError && (
|
|
<p className="text-[11px] text-destructive px-3 pb-2 pl-14">
|
|
{
|
|
model.validationError
|
|
}
|
|
</p>
|
|
)}
|
|
{!model.validationWarning &&
|
|
getModelInfo?.(
|
|
selectedProvider.provider,
|
|
model.modelId,
|
|
)?.tools ===
|
|
false && (
|
|
<p className="text-[11px] text-amber-600 dark:text-amber-400 px-3 pb-2 pl-14">
|
|
{
|
|
dict
|
|
.modelConfig
|
|
.mayNotDraw
|
|
}
|
|
</p>
|
|
)}
|
|
{model.validated &&
|
|
model.validationWarning && (
|
|
<p className="text-[11px] text-amber-600 dark:text-amber-400 px-3 pb-2 pl-14">
|
|
{
|
|
model.validationWarning
|
|
}
|
|
</p>
|
|
)}
|
|
{/* Show edit error inline */}
|
|
{editError?.modelId ===
|
|
model.id && (
|
|
<p className="text-[11px] text-destructive px-3 pb-2 pl-14">
|
|
{
|
|
editError.message
|
|
}
|
|
</p>
|
|
)}
|
|
</div>
|
|
),
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ConfigSection>
|
|
</div>
|
|
</ScrollArea>
|
|
) : (
|
|
<div className="h-full flex flex-col items-center justify-center p-8 text-center">
|
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-surface-2 mb-4">
|
|
<Server className="h-8 w-8 text-muted-foreground" />
|
|
</div>
|
|
<h3 className="font-semibold text-lg tracking-tight mb-1">
|
|
{dict.modelConfig.configureProviders}
|
|
</h3>
|
|
<p className="text-sm text-muted-foreground max-w-xs">
|
|
{dict.modelConfig.selectProviderHint}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="px-6 py-3 border-t border-border-subtle bg-surface-1/30 shrink-0">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
id="show-unvalidated-models"
|
|
checked={modelConfig.showUnvalidatedModels}
|
|
onCheckedChange={
|
|
modelConfig.setShowUnvalidatedModels
|
|
}
|
|
/>
|
|
<Label
|
|
htmlFor="show-unvalidated-models"
|
|
className="text-xs text-muted-foreground cursor-pointer"
|
|
>
|
|
{dict.modelConfig.showUnvalidatedModels}
|
|
</Label>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
|
<Key className="h-3 w-3" />
|
|
{dict.modelConfig.apiKeyStored}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
<AlertDialog
|
|
open={deleteConfirmOpen}
|
|
onOpenChange={(open) => {
|
|
setDeleteConfirmOpen(open)
|
|
if (!open) setDeleteConfirmText("")
|
|
}}
|
|
>
|
|
<AlertDialogContent className="border-destructive/30">
|
|
<AlertDialogHeader>
|
|
<div className="mx-auto mb-3 p-3 rounded-full bg-destructive/10">
|
|
<AlertCircle className="h-6 w-6 text-destructive" />
|
|
</div>
|
|
<AlertDialogTitle className="text-center">
|
|
{dict.modelConfig.deleteProvider}
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription className="text-center">
|
|
{formatMessage(dict.modelConfig.deleteConfirmDesc, {
|
|
name: selectedProvider
|
|
? selectedProvider.name ||
|
|
PROVIDER_INFO[selectedProvider.provider]
|
|
.label
|
|
: "this provider",
|
|
})}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
{selectedProvider &&
|
|
selectedProvider.models.length >= 3 && (
|
|
<div className="mt-2 space-y-2">
|
|
<Label
|
|
htmlFor="delete-confirm"
|
|
className="text-sm text-muted-foreground"
|
|
>
|
|
{formatMessage(
|
|
dict.modelConfig.typeToConfirm,
|
|
{
|
|
name:
|
|
selectedProvider.name ||
|
|
PROVIDER_INFO[
|
|
selectedProvider.provider
|
|
].label,
|
|
},
|
|
)}
|
|
</Label>
|
|
<Input
|
|
id="delete-confirm"
|
|
value={deleteConfirmText}
|
|
onChange={(e) =>
|
|
setDeleteConfirmText(e.target.value)
|
|
}
|
|
placeholder={
|
|
dict.modelConfig.typeProviderName
|
|
}
|
|
className="h-9"
|
|
/>
|
|
</div>
|
|
)}
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>
|
|
{dict.modelConfig.cancel}
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleDeleteProvider}
|
|
disabled={
|
|
selectedProvider &&
|
|
selectedProvider.models.length >= 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}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</Dialog>
|
|
)
|
|
}
|