mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-10 03:29:50 +08:00
Tabs Models, General, Drawing and About, each with a fixed title row. The models tab becomes three pages: the user's providers with one status each and the server's models; a searchable picker of all 24 providers in four groups; and a provider page in three steps (connection, models, test). A new provider gets its first suggested model, and a passing test offers to use the model in the chat. Bedrock asks for one way of signing in, with an optional session token for temporary access keys. The composer's model picker lists every model, marking untested and failed ones, and can open the provider picker. A chat error that offers model settings opens the provider the request went to.
1455 lines
66 KiB
TypeScript
1455 lines
66 KiB
TypeScript
"use client"
|
|
|
|
import {
|
|
AlertCircle,
|
|
Check,
|
|
CheckCircle2,
|
|
ChevronRight,
|
|
CircleDashed,
|
|
Eye,
|
|
EyeOff,
|
|
Loader2,
|
|
Plus,
|
|
RefreshCw,
|
|
Trash2,
|
|
X,
|
|
} from "lucide-react"
|
|
import { useCallback, useEffect, useId, useRef, useState } from "react"
|
|
import { useChatEngine } from "@/components/chat/chat-engine"
|
|
import {
|
|
ProviderCredentialsFields,
|
|
type SecretField,
|
|
} from "@/components/provider-credentials-fields"
|
|
import { ProviderLogo } from "@/components/provider-logo"
|
|
import { ProviderPicker } from "@/components/settings/provider-picker"
|
|
import { ProvidersList } from "@/components/settings/providers-list"
|
|
import { SettingsHeader } from "@/components/settings/settings-header"
|
|
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 { 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 { 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 { hasCredentials } from "@/lib/provider-setup"
|
|
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"
|
|
import { useUiStore } from "@/stores/ui-store"
|
|
|
|
interface ModelConfigDialogProps {
|
|
open: boolean
|
|
modelConfig: UseModelConfigReturn
|
|
}
|
|
|
|
type ValidationStatus = "idle" | "validating" | "success" | "error"
|
|
|
|
/** One numbered step of a provider's setup; a check mark once done */
|
|
function Step({
|
|
n,
|
|
done,
|
|
title,
|
|
action,
|
|
children,
|
|
}: {
|
|
n: number
|
|
done: boolean
|
|
title: string
|
|
action?: React.ReactNode
|
|
children: React.ReactNode
|
|
}) {
|
|
return (
|
|
<section className="flex gap-3.5">
|
|
<span
|
|
className={cn(
|
|
"mt-px flex size-[22px] shrink-0 items-center justify-center rounded-full text-xs font-semibold",
|
|
done
|
|
? "bg-success-muted text-success"
|
|
: "bg-primary text-primary-foreground",
|
|
)}
|
|
data-testid={`step-${n}-${done ? "done" : "todo"}`}
|
|
>
|
|
{done ? <Check className="size-3" /> : n}
|
|
</span>
|
|
<div className="min-w-0 flex-1 space-y-3">
|
|
<div className="flex min-h-[22px] items-center justify-between gap-3">
|
|
<h3 className="text-[13px] font-semibold">{title}</h3>
|
|
{action}
|
|
</div>
|
|
{children}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The latest test of each provider. Kept outside the component: this view
|
|
* mounts anew when settings reopen or another tab comes back, and a test
|
|
* started before must not overwrite a newer one's results.
|
|
*/
|
|
const latestTestRun = new Map<string, number>()
|
|
|
|
/** Providers as last saved (the model config is saved on every change) */
|
|
function savedProviders(): ProviderConfig[] {
|
|
try {
|
|
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
|
return stored
|
|
? (JSON.parse(stored) as { providers: ProviderConfig[] }).providers
|
|
: []
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
export function ModelConfigDialog({
|
|
open,
|
|
modelConfig,
|
|
}: ModelConfigDialogProps) {
|
|
const dict = useDictionary()
|
|
const t = dict.modelConfig
|
|
const engine = useChatEngine()
|
|
// Like the chat's model picker, "Use in the chat" waits while a request
|
|
// runs: the answer keeps the model it was sent with
|
|
const chatLocked = (engine.isBusy && !engine.error) || engine.isLeaving
|
|
// The page shown lives in the UI store: a chat error or the model
|
|
// picker can open settings on a provider or on the provider picker
|
|
const page = useUiStore((s) => s.modelsPage)
|
|
const setModelsPage = useUiStore((s) => s.setModelsPage)
|
|
const selectedProviderId = typeof page === "object" ? page.providerId : null
|
|
const [showApiKey, setShowApiKey] = useState(false)
|
|
const [validationStatus, setValidationStatus] =
|
|
useState<ValidationStatus>("idle")
|
|
const [validationError, setValidationError] = useState<string>("")
|
|
// Per provider, the credentials a test was refused with: the key field is
|
|
// marked until they change, here or in another tab, or a test passes. A
|
|
// model list says nothing about the key: some providers list models
|
|
// without one.
|
|
const [rejectedKeys, setRejectedKeys] = useState<Record<string, string>>({})
|
|
const markRejected = (
|
|
providerId: string,
|
|
askedWith: string,
|
|
rejected: boolean,
|
|
) =>
|
|
setRejectedKeys(({ [providerId]: _, ...rest }) =>
|
|
rejected ? { ...rest, [providerId]: askedWith } : rest,
|
|
)
|
|
const [customModelInput, setCustomModelInput] = useState("")
|
|
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)
|
|
// Models fetched from the provider, per provider config, with the
|
|
// credentials they were fetched with
|
|
const [fetchedModels, setFetchedModels] = useState<
|
|
Record<string, { askedWith: string; models: ListedModel[] }>
|
|
>({})
|
|
// The providers whose model list is being fetched
|
|
const [fetchingFor, setFetchingFor] = useState<string[]>([])
|
|
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,
|
|
)
|
|
// Another provider shown: what was shown for the one before goes, also
|
|
// when the page is changed from outside (openSettings)
|
|
const [shownProviderId, setShownProviderId] = useState(selectedProviderId)
|
|
if (shownProviderId !== selectedProviderId) {
|
|
setShownProviderId(selectedProviderId)
|
|
setValidationStatus("idle")
|
|
setValidationError("")
|
|
setShowApiKey(false)
|
|
setFetchModelsError("")
|
|
setModelPickerOpen(false)
|
|
setCustomModelInput("")
|
|
setDuplicateError("")
|
|
setEditError(null)
|
|
setModelIdDraft(null)
|
|
}
|
|
// 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
|
|
// Set when this view goes away (settings closed or on another tab): its
|
|
// config no longer follows edits, the saved one does
|
|
const closedRef = useRef(false)
|
|
useEffect(() => {
|
|
closedRef.current = false
|
|
return () => {
|
|
closedRef.current = true
|
|
}
|
|
}, [])
|
|
// The providers as they are now: once this view is gone, as saved
|
|
const providersNow = () =>
|
|
closedRef.current ? savedProviders() : configRef.current.providers
|
|
// 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 = providersNow().find((x) => x.id === providerId)
|
|
return JSON.stringify([
|
|
p?.provider,
|
|
p?.apiKey,
|
|
p?.baseUrl,
|
|
p?.awsAccessKeyId,
|
|
p?.awsSecretAccessKey,
|
|
p?.awsRegion,
|
|
p?.awsSessionToken,
|
|
p?.vertexApiKey,
|
|
])
|
|
}
|
|
|
|
// The button that changed the page went with the old page: the new
|
|
// page's title takes the focus, unless one of its fields took it
|
|
// (autoFocus). Not on the first page: the dialog places the focus then.
|
|
const pageRef = useRef<HTMLDivElement>(null)
|
|
// The test result, read out by screen readers
|
|
const resultId = useId()
|
|
const useButtonRef = useRef<HTMLButtonElement>(null)
|
|
const backButtonRef = useRef<HTMLButtonElement>(null)
|
|
// A test passed: its button went with the success panel, whose first
|
|
// button takes the focus (it is described by the result) unless the
|
|
// focus went somewhere on the page meanwhile
|
|
useEffect(() => {
|
|
if (validationStatus !== "success") return
|
|
const root = pageRef.current
|
|
if (!root || root.contains(document.activeElement)) return
|
|
const use = useButtonRef.current
|
|
;(use && !use.disabled ? use : backButtonRef.current)?.focus()
|
|
}, [validationStatus])
|
|
const pageKey =
|
|
selectedProvider?.id ?? (page === "picker" ? "picker" : "list")
|
|
const shownPageRef = useRef(pageKey)
|
|
useEffect(() => {
|
|
if (shownPageRef.current === pageKey) return
|
|
shownPageRef.current = pageKey
|
|
const root = pageRef.current
|
|
if (!root || root.contains(document.activeElement)) return
|
|
root.querySelector<HTMLElement>("[data-page-title]")?.focus()
|
|
}, [pageKey])
|
|
|
|
// Discard an unfinished model ID edit when the dialog closes
|
|
useEffect(() => {
|
|
if (!open) setModelIdDraft(null)
|
|
}, [open])
|
|
|
|
useEffect(() => {
|
|
if (!open || getModelInfo) return
|
|
import("@/lib/model-catalog").then((catalog) =>
|
|
setGetModelInfo(() => catalog.getModelInfo),
|
|
)
|
|
}, [open, getModelInfo])
|
|
|
|
// What to do about an error kind, worded for this page, where the
|
|
// credentials can be fixed
|
|
const errorHints = (p: ProviderConfig): Record<string, string> => ({
|
|
...(dict.errors.llm as Record<string, string>),
|
|
invalid_api_key:
|
|
p.provider === "bedrock" && !p.apiKey
|
|
? t.awsKeysRejected
|
|
: t.keyRejected,
|
|
model_not_found: t.modelNotFound,
|
|
})
|
|
|
|
const handleFetchModels = async () => {
|
|
if (!selectedProvider) return
|
|
const providerId = selectedProvider.id
|
|
const askedWith = credentialsOf(providerId)
|
|
const hints = errorHints(selectedProvider)
|
|
setFetchingFor((current) => [...current, providerId])
|
|
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]: { askedWith, models: data.models },
|
|
}))
|
|
if (!stillShown) return
|
|
if (data.models.length > 0) setModelPickerOpen(true)
|
|
else setFetchModelsError(t.noModelsReturned)
|
|
} else {
|
|
if (!stillShown) return
|
|
setFetchModelsError(
|
|
[hints[data.code], data.error].filter(Boolean).join(" ") ||
|
|
formatMessage(t.requestFailed, {
|
|
status: response.status,
|
|
}),
|
|
)
|
|
}
|
|
} catch {
|
|
if (
|
|
selectedProviderIdRef.current === providerId &&
|
|
credentialsOf(providerId) === askedWith
|
|
) {
|
|
setFetchModelsError(dict.errors.networkError)
|
|
}
|
|
} finally {
|
|
setFetchingFor((current) =>
|
|
current.filter((id) => id !== providerId),
|
|
)
|
|
}
|
|
}
|
|
|
|
// The provider's own list, if fetched with the credentials it has now,
|
|
// else the suggested models
|
|
const fetched = selectedProvider
|
|
? fetchedModels[selectedProvider.id]
|
|
: undefined
|
|
const suggestedModels: ListedModel[] = selectedProvider
|
|
? fetched && fetched.askedWith === credentialsOf(selectedProvider.id)
|
|
? fetched.models
|
|
: (SUGGESTED_MODELS[selectedProvider.provider] || []).map((id) => ({
|
|
id,
|
|
}))
|
|
: []
|
|
// The picker closes when there is nothing left to pick from, so it does
|
|
// not open by itself once there is again
|
|
if (modelPickerOpen && suggestedModels.length === 0) {
|
|
setModelPickerOpen(false)
|
|
}
|
|
const keyRejected =
|
|
!!selectedProvider &&
|
|
rejectedKeys[selectedProvider.id] === credentialsOf(selectedProvider.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),
|
|
)
|
|
// A few suggested models to add with one click
|
|
const quickSuggestions = selectedProvider
|
|
? (SUGGESTED_MODELS[selectedProvider.provider] || [])
|
|
.filter((modelId) => !existingModelIds.includes(modelId))
|
|
.slice(0, 4)
|
|
: []
|
|
|
|
const openProvider = (providerId: string) => setModelsPage({ providerId })
|
|
|
|
// Handle adding a new provider
|
|
const handleAddProvider = (providerType: ProviderName) => {
|
|
const newProvider = addProvider(providerType)
|
|
// With the first suggested model picked, a key is all a test needs
|
|
const firstModel = SUGGESTED_MODELS[providerType]?.[0]
|
|
if (firstModel) addModel(newProvider.id, firstModel)
|
|
openProvider(newProvider.id)
|
|
}
|
|
|
|
// Handle provider field updates
|
|
const handleProviderUpdate = (updates: Partial<ProviderConfig>) => {
|
|
if (!selectedProviderId || !selectedProvider) return
|
|
// Reset validation of the provider and its models when credentials change
|
|
const credentialFields = [
|
|
"apiKey",
|
|
"baseUrl",
|
|
"awsAccessKeyId",
|
|
"awsSecretAccessKey",
|
|
"awsRegion",
|
|
"awsSessionToken",
|
|
"vertexApiKey",
|
|
]
|
|
if (Object.keys(updates).some((f) => credentialFields.includes(f))) {
|
|
setValidationStatus("idle")
|
|
// A test of this provider still running no longer counts
|
|
const ids = new Set(selectedProvider.models.map((m) => m.id))
|
|
setValidatingModelIds(
|
|
(prev) => new Set([...prev].filter((id) => !ids.has(id))),
|
|
)
|
|
setFetchModelsError("")
|
|
updates = {
|
|
...updates,
|
|
validated: false,
|
|
models: selectedProvider.models.map((m) => ({
|
|
...m,
|
|
validated: undefined,
|
|
validationError: undefined,
|
|
validationWarning: undefined,
|
|
responseTime: undefined,
|
|
})),
|
|
}
|
|
}
|
|
updateProvider(selectedProviderId, updates)
|
|
}
|
|
|
|
// Empty the secrets of the Bedrock sign-in the user switched away from;
|
|
// a session token belongs to the access keys
|
|
const clearSecrets = (fields: SecretField[]) => {
|
|
if (!selectedProvider) return
|
|
const cleared = [
|
|
...fields,
|
|
...(fields.includes("awsAccessKeyId")
|
|
? (["awsSessionToken"] as const)
|
|
: []),
|
|
].filter((field) => selectedProvider[field])
|
|
// Nothing filled in: the test results still hold
|
|
if (cleared.length === 0) return
|
|
const updates = Object.fromEntries(cleared.map((field) => [field, ""]))
|
|
// Access keys kept behind an API key were never used (the server
|
|
// takes the API key first): the test results still hold
|
|
if (selectedProvider.apiKey && !fields.includes("apiKey")) {
|
|
updateProvider(selectedProvider.id, updates)
|
|
} else {
|
|
handleProviderUpdate(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(t.modelIdExists)
|
|
return false
|
|
}
|
|
setDuplicateError("")
|
|
addModel(selectedProviderId, modelId)
|
|
return true
|
|
}
|
|
|
|
const addCustomModel = () => {
|
|
const modelId = customModelInput.trim()
|
|
if (modelId && handleAddModel(modelId)) setCustomModelInput("")
|
|
}
|
|
|
|
// 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)
|
|
setModelsPage("list")
|
|
setValidationStatus("idle")
|
|
setDeleteConfirmOpen(false)
|
|
}
|
|
|
|
// Validate all models
|
|
const handleValidate = useCallback(async () => {
|
|
if (!selectedProvider || !selectedProviderId) return
|
|
if (!hasCredentials(selectedProvider)) return
|
|
|
|
// Need at least one model to validate
|
|
if (selectedProvider.models.length === 0) {
|
|
setValidationError(t.addModelFirst)
|
|
setValidationStatus("error")
|
|
return
|
|
}
|
|
|
|
setValidationStatus("validating")
|
|
setValidationError("")
|
|
|
|
let allValid = true
|
|
let errorCount = 0
|
|
let idChanged = false
|
|
let rejected = false
|
|
const askedWith = credentialsOf(selectedProviderId)
|
|
const run = (latestTestRun.get(selectedProviderId) ?? 0) + 1
|
|
latestTestRun.set(selectedProviderId, run)
|
|
|
|
// For EdgeOne, construct baseUrl from current origin
|
|
const baseUrl =
|
|
selectedProvider.provider === "edgeone"
|
|
? `${window.location.origin}/api/edgeai`
|
|
: selectedProvider.baseUrl
|
|
const hints = errorHints(selectedProvider)
|
|
|
|
// Test every model at once; each row updates when its answer arrives
|
|
setValidatingModelIds(
|
|
(prev) =>
|
|
new Set([...prev, ...selectedProvider.models.map((m) => m.id)]),
|
|
)
|
|
await Promise.all(
|
|
selectedProvider.models.map(async (model) => {
|
|
let update: Partial<ModelConfig>
|
|
let code: string | undefined
|
|
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(() => ({}))
|
|
code = data.valid ? undefined : data.code
|
|
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:
|
|
[hints[data.code], data.error]
|
|
.filter(Boolean)
|
|
.join(" ") ||
|
|
(response.ok
|
|
? t.validationError
|
|
: formatMessage(t.requestFailed, {
|
|
status: response.status,
|
|
})),
|
|
validationWarning: undefined,
|
|
}
|
|
} catch {
|
|
update = {
|
|
validated: false,
|
|
validationError: dict.errors.networkError,
|
|
validationWarning: undefined,
|
|
}
|
|
}
|
|
// A newer test started: its own results and spinners count,
|
|
// whatever the credentials are now (they may have come back)
|
|
if (run !== latestTestRun.get(selectedProviderId)) 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 = providersNow()
|
|
.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++
|
|
if (code === "invalid_api_key") rejected = true
|
|
}
|
|
updateModel(selectedProviderId, model.id, update)
|
|
setValidatingModelIds((prev) => {
|
|
const next = new Set(prev)
|
|
next.delete(model.id)
|
|
return next
|
|
})
|
|
}),
|
|
)
|
|
if (run !== latestTestRun.get(selectedProviderId)) 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 })
|
|
}
|
|
markRejected(selectedProviderId, askedWith, rejected)
|
|
// The status line is about the provider shown now
|
|
if (selectedProviderIdRef.current !== selectedProviderId) return
|
|
if (idChanged) {
|
|
setValidationStatus("idle")
|
|
} else if (allValid) {
|
|
setValidationStatus("success")
|
|
} else {
|
|
setValidationStatus("error")
|
|
setValidationError(
|
|
formatMessage(
|
|
errorCount === 1
|
|
? t.validationFailedCountOne
|
|
: t.validationFailedCountOther,
|
|
{ count: errorCount },
|
|
),
|
|
)
|
|
}
|
|
}, [
|
|
selectedProvider,
|
|
selectedProviderId,
|
|
updateProvider,
|
|
updateModel,
|
|
dict,
|
|
])
|
|
|
|
// Plaintext secret input with show/hide toggle (the user dialog stores
|
|
// keys client-side, so values are shown directly — unlike the masked
|
|
// admin panel)
|
|
const renderProviderSecret = (
|
|
field: SecretField | "awsSessionToken",
|
|
id: string,
|
|
) => {
|
|
if (!selectedProvider) return null
|
|
const value = (selectedProvider[field] as string | undefined) ?? ""
|
|
return (
|
|
<div className="relative flex-1">
|
|
<Input
|
|
id={id}
|
|
// A provider still without credentials: they come first
|
|
// (one of these three is shown at a time)
|
|
autoFocus={
|
|
!hasCredentials(selectedProvider) &&
|
|
(field === "apiKey" ||
|
|
field === "vertexApiKey" ||
|
|
field === "awsAccessKeyId")
|
|
}
|
|
type={showApiKey ? "text" : "password"}
|
|
value={value}
|
|
onChange={(e) =>
|
|
handleProviderUpdate({ [field]: e.target.value })
|
|
}
|
|
placeholder={
|
|
field === "awsSecretAccessKey"
|
|
? t.enterSecretKey
|
|
: field === "awsAccessKeyId"
|
|
? "AKIA..."
|
|
: field === "awsSessionToken"
|
|
? undefined
|
|
: t.enterApiKey
|
|
}
|
|
aria-invalid={keyRejected || undefined}
|
|
className={cn(
|
|
"h-9 pr-10 font-mono text-xs",
|
|
keyRejected &&
|
|
"border-destructive/60 focus-visible:ring-destructive/30",
|
|
)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowApiKey(!showApiKey)}
|
|
aria-label={showApiKey ? t.hideValue : t.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>
|
|
)
|
|
}
|
|
|
|
const providerName = (p: ProviderConfig) =>
|
|
p.name || PROVIDER_INFO[p.provider].label
|
|
|
|
const renderModelRow = (model: ModelConfig) => {
|
|
if (!selectedProvider) return null
|
|
const testing = validatingModelIds.has(model.id)
|
|
const seconds = model.responseTime
|
|
? (model.responseTime / 1000).toFixed(1)
|
|
: undefined
|
|
return (
|
|
<div
|
|
key={model.id}
|
|
className={cn(
|
|
"transition-colors duration-150",
|
|
model.validated === false && !testing
|
|
? "bg-destructive/5"
|
|
: "hover:bg-interactive-hover/50",
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2.5 px-3 py-1.5 min-w-0">
|
|
<span className="flex size-5 shrink-0 items-center justify-center">
|
|
{testing ? (
|
|
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
|
) : model.validated === true ? (
|
|
<CheckCircle2 className="size-4 text-success" />
|
|
) : model.validated === false ? (
|
|
<AlertCircle className="size-4 text-destructive" />
|
|
) : (
|
|
<CircleDashed className="size-4 text-muted-foreground" />
|
|
)}
|
|
</span>
|
|
<Input
|
|
value={
|
|
modelIdDraft?.id === model.id
|
|
? modelIdDraft.value
|
|
: model.modelId
|
|
}
|
|
title={model.modelId}
|
|
aria-label={t.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(t.modelIdEmpty)
|
|
return
|
|
}
|
|
|
|
// Check for duplicate
|
|
const otherModelIds =
|
|
selectedProvider?.models
|
|
.filter((m) => m.id !== model.id)
|
|
.map((m) => m.modelId) || []
|
|
if (otherModelIds.includes(newModelId)) {
|
|
showError(t.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="h-8 min-w-0 flex-1 border-0 bg-transparent px-1.5 font-mono text-sm shadow-none focus-visible:bg-background focus-visible:ring-1 dark:bg-transparent"
|
|
/>
|
|
{!testing && (
|
|
<span
|
|
className={cn(
|
|
"shrink-0 text-xs",
|
|
model.validated === true
|
|
? "text-success"
|
|
: model.validated === false
|
|
? "text-destructive"
|
|
: "text-muted-foreground",
|
|
)}
|
|
>
|
|
{model.validated === true
|
|
? seconds
|
|
? formatMessage(t.modelWorksTime, {
|
|
seconds,
|
|
})
|
|
: t.modelWorks
|
|
: model.validated === false
|
|
? t.modelFailed
|
|
: t.modelUntested}
|
|
</span>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
|
onClick={() => handleDeleteModel(model.id)}
|
|
aria-label={`${dict.common.delete} ${model.modelId}`}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
{/* Show validation error inline */}
|
|
{model.validated === false && model.validationError && (
|
|
<p className="px-3 pb-2 pl-11 text-xs text-destructive">
|
|
{model.validationError}
|
|
</p>
|
|
)}
|
|
{!model.validationWarning &&
|
|
getModelInfo?.(selectedProvider.provider, model.modelId)
|
|
?.tools === false && (
|
|
<p className="px-3 pb-2 pl-11 text-xs text-amber-600 dark:text-amber-400">
|
|
{t.mayNotDraw}
|
|
</p>
|
|
)}
|
|
{model.validated && model.validationWarning && (
|
|
<p className="px-3 pb-2 pl-11 text-xs text-amber-600 dark:text-amber-400">
|
|
{model.validationWarning}
|
|
</p>
|
|
)}
|
|
{/* Show edit error inline */}
|
|
{editError?.modelId === model.id && (
|
|
<p className="px-3 pb-2 pl-11 text-xs text-destructive">
|
|
{editError.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const renderProviderPage = (provider: ProviderConfig) => {
|
|
const models = provider.models
|
|
const credentialsDone = hasCredentials(provider)
|
|
const allWork =
|
|
models.length > 0 && models.every((m) => m.validated === true)
|
|
const firstWorking = models.find((m) => m.validated === true)
|
|
const lastSeconds =
|
|
models.length === 1 && models[0].responseTime
|
|
? (models[0].responseTime / 1000).toFixed(1)
|
|
: undefined
|
|
const canTest = credentialsDone && models.length > 0
|
|
const testing = validationStatus === "validating"
|
|
const testBlocker = !credentialsDone
|
|
? provider.provider === "bedrock"
|
|
? t.needAws
|
|
: provider.provider === "azure"
|
|
? t.needAzure
|
|
: t.needKey
|
|
: models.length === 0
|
|
? t.needModel
|
|
: ""
|
|
const fetchLabel = formatMessage(t.fetchAllModels, {
|
|
name: providerName(provider),
|
|
})
|
|
const testPassed = validationStatus === "success" && allWork
|
|
// Until the failed models change, here or in another tab
|
|
const testFailed =
|
|
validationStatus === "error" &&
|
|
!!validationError &&
|
|
models.some((m) => m.validated === false)
|
|
const passedText =
|
|
models.length === 1
|
|
? formatMessage(t.testPassedOne, { model: models[0].modelId })
|
|
: formatMessage(t.testPassedAll, { count: models.length })
|
|
const testLabel =
|
|
models.length === 0
|
|
? t.stepTest
|
|
: models.length === 1
|
|
? formatMessage(t.testOne, { model: models[0].modelId })
|
|
: formatMessage(t.testAll, { count: models.length })
|
|
|
|
return (
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
<SettingsHeader>
|
|
<button
|
|
type="button"
|
|
onClick={() => setModelsPage("list")}
|
|
className="rounded text-muted-foreground hover:text-foreground"
|
|
>
|
|
{t.models}
|
|
</button>
|
|
<ChevronRight className="size-3.5 text-muted-foreground/60" />
|
|
<span className="ml-0.5 flex size-6 items-center justify-center rounded-md bg-surface-2">
|
|
<ProviderLogo
|
|
provider={provider.provider}
|
|
className="size-3.5"
|
|
/>
|
|
</span>
|
|
<h2
|
|
className="truncate font-semibold outline-none"
|
|
tabIndex={-1}
|
|
data-page-title
|
|
>
|
|
{providerName(provider)}
|
|
</h2>
|
|
</SettingsHeader>
|
|
<ScrollArea className="min-h-0 flex-1">
|
|
<div className="space-y-7 px-6 pt-1 pb-6">
|
|
{/* 1: credentials */}
|
|
<Step
|
|
n={1}
|
|
done={credentialsDone && !keyRejected}
|
|
title={t.stepConnect}
|
|
>
|
|
{provider.provider === "edgeone" ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
{t.edgeoneNoKey}
|
|
</p>
|
|
) : (
|
|
<ProviderCredentialsFields
|
|
// Its folded options and Bedrock choice
|
|
// belong to this provider
|
|
key={provider.id}
|
|
provider={provider.provider}
|
|
name={provider.name}
|
|
baseUrl={provider.baseUrl}
|
|
awsRegion={provider.awsRegion}
|
|
bedrockApiKey
|
|
settingsLayout
|
|
bedrockFilled={{
|
|
apiKey: !!provider.apiKey,
|
|
accessKey:
|
|
!!provider.awsAccessKeyId ||
|
|
!!provider.awsSecretAccessKey,
|
|
}}
|
|
clearSecrets={clearSecrets}
|
|
sessionTokenInput={renderProviderSecret(
|
|
"awsSessionToken",
|
|
"aws-session-token",
|
|
)}
|
|
onChange={(field, value) =>
|
|
handleProviderUpdate({
|
|
[field]: value,
|
|
})
|
|
}
|
|
renderSecret={({ field, id }) =>
|
|
renderProviderSecret(field, id)
|
|
}
|
|
/>
|
|
)}
|
|
</Step>
|
|
|
|
{/* 2: models */}
|
|
<Step
|
|
n={2}
|
|
done={models.length > 0}
|
|
title={t.stepModels}
|
|
action={
|
|
PROVIDER_INFO[provider.provider].modelList && (
|
|
<button
|
|
type="button"
|
|
onClick={handleFetchModels}
|
|
disabled={fetchingFor.includes(
|
|
provider.id,
|
|
)}
|
|
title={fetchLabel}
|
|
className="flex min-w-0 items-center gap-1 text-xs text-muted-foreground hover:text-foreground disabled:opacity-60"
|
|
>
|
|
{fetchingFor.includes(provider.id) ? (
|
|
<Loader2 className="size-3 shrink-0 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="size-3 shrink-0" />
|
|
)}
|
|
<span className="truncate">
|
|
{fetchLabel}
|
|
</span>
|
|
</button>
|
|
)
|
|
}
|
|
>
|
|
{fetchModelsError && (
|
|
<p className="text-xs text-destructive">
|
|
{fetchModelsError}
|
|
</p>
|
|
)}
|
|
{models.length > 0 && (
|
|
<div className="overflow-hidden rounded-xl border border-border divide-y divide-border-subtle">
|
|
{models.map(renderModelRow)}
|
|
</div>
|
|
)}
|
|
{quickSuggestions.length > 0 && (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="text-xs text-muted-foreground">
|
|
{t.suggestedLabel}
|
|
</span>
|
|
{quickSuggestions.map((modelId) => (
|
|
<Button
|
|
key={modelId}
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 max-w-[260px] rounded-lg px-2 font-mono text-[11px]"
|
|
title={modelId}
|
|
onClick={() =>
|
|
handleAddModel(modelId)
|
|
}
|
|
>
|
|
<Plus className="h-3 w-3 shrink-0" />
|
|
<span className="truncate">
|
|
{modelId}
|
|
</span>
|
|
</Button>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="flex items-start gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<Input
|
|
placeholder={t.orTypeModelId}
|
|
aria-label={t.customModelId}
|
|
value={customModelInput}
|
|
onChange={(e) => {
|
|
setCustomModelInput(e.target.value)
|
|
if (duplicateError) {
|
|
setDuplicateError("")
|
|
}
|
|
}}
|
|
onKeyDown={(e) => {
|
|
// Enter that confirms an IME
|
|
// candidate adds nothing
|
|
if (
|
|
e.nativeEvent.isComposing ||
|
|
e.keyCode === 229
|
|
) {
|
|
return
|
|
}
|
|
if (e.key === "Enter") {
|
|
addCustomModel()
|
|
}
|
|
}}
|
|
className={cn(
|
|
"h-8 rounded-lg font-mono text-xs",
|
|
duplicateError &&
|
|
"border-destructive focus-visible:ring-destructive",
|
|
)}
|
|
/>
|
|
{duplicateError && (
|
|
<p className="mt-1 text-[11px] text-destructive">
|
|
{duplicateError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 rounded-lg"
|
|
onClick={addCustomModel}
|
|
disabled={!customModelInput.trim()}
|
|
>
|
|
{t.add}
|
|
</Button>
|
|
{/* Nothing to browse for a provider without
|
|
suggested models, until its list is fetched.
|
|
modal: the dialog blocks the wheel outside
|
|
itself, and the list is rendered outside it */}
|
|
{suggestedModels.length > 0 && (
|
|
<Popover
|
|
modal
|
|
open={modelPickerOpen}
|
|
onOpenChange={setModelPickerOpen}
|
|
>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 rounded-lg text-xs"
|
|
disabled={
|
|
availableSuggestions.length ===
|
|
0
|
|
}
|
|
>
|
|
{availableSuggestions.length ===
|
|
0
|
|
? t.allAdded
|
|
: t.browseModels}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="w-80 p-0"
|
|
align="end"
|
|
>
|
|
<Command>
|
|
<CommandInput
|
|
placeholder={t.searchModels}
|
|
/>
|
|
<CommandList className="max-h-72">
|
|
<CommandEmpty>
|
|
{t.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">
|
|
{
|
|
t.noTools
|
|
}
|
|
</span>
|
|
)}
|
|
</CommandItem>
|
|
),
|
|
)}
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)}
|
|
</div>
|
|
</Step>
|
|
|
|
{/* 3: test */}
|
|
<Step n={3} done={allWork} title={t.stepTest}>
|
|
{/* Always on the page, so a screen reader reads
|
|
out the result when it arrives */}
|
|
<p
|
|
id={resultId}
|
|
role="status"
|
|
className="sr-only"
|
|
data-testid="test-result"
|
|
>
|
|
{testPassed
|
|
? passedText
|
|
: testFailed
|
|
? validationError
|
|
: ""}
|
|
</p>
|
|
{testPassed ? (
|
|
<div className="space-y-3 rounded-xl border border-success/20 bg-success-muted/70 px-4 py-3.5">
|
|
<p className="flex items-start gap-2 text-[13px] font-medium text-success">
|
|
<CheckCircle2 className="mt-0.5 size-4 shrink-0" />
|
|
{/* A long model id wraps */}
|
|
<span className="min-w-0 [overflow-wrap:anywhere]">
|
|
{passedText}
|
|
{lastSeconds && (
|
|
<span className="ml-2 text-xs font-normal opacity-80">
|
|
{formatMessage(
|
|
t.answeredIn,
|
|
{
|
|
seconds:
|
|
lastSeconds,
|
|
},
|
|
)}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{firstWorking && (
|
|
<Button
|
|
ref={useButtonRef}
|
|
className="h-9 max-w-full rounded-lg px-4"
|
|
disabled={chatLocked}
|
|
aria-describedby={resultId}
|
|
title={formatMessage(
|
|
t.useInChat,
|
|
{
|
|
model: firstWorking.modelId,
|
|
},
|
|
)}
|
|
onClick={() => {
|
|
modelConfig.setSelectedModelId(
|
|
firstWorking.id,
|
|
)
|
|
useUiStore
|
|
.getState()
|
|
.setSettingsOpen(false)
|
|
}}
|
|
>
|
|
<span className="truncate">
|
|
{formatMessage(
|
|
t.useInChat,
|
|
{
|
|
model: firstWorking.modelId,
|
|
},
|
|
)}
|
|
</span>
|
|
</Button>
|
|
)}
|
|
<Button
|
|
ref={backButtonRef}
|
|
variant="outline"
|
|
className="h-9 rounded-lg bg-background px-4"
|
|
aria-describedby={resultId}
|
|
onClick={() =>
|
|
setModelsPage("list")
|
|
}
|
|
>
|
|
{t.backToProviders}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t.testHint}
|
|
</p>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<Button
|
|
// Not disabled while it runs: the
|
|
// focus would leave it
|
|
onClick={() => {
|
|
if (!testing) handleValidate()
|
|
}}
|
|
disabled={!canTest}
|
|
aria-disabled={testing || undefined}
|
|
className="h-9 max-w-full rounded-lg px-4"
|
|
title={testLabel}
|
|
data-testid="test-models"
|
|
>
|
|
{validationStatus ===
|
|
"validating" && (
|
|
<Loader2 className="size-4 animate-spin" />
|
|
)}
|
|
<span className="truncate">
|
|
{testLabel}
|
|
</span>
|
|
</Button>
|
|
{testFailed && (
|
|
<p className="flex items-center gap-1 text-xs text-destructive">
|
|
<X className="size-3" />
|
|
{validationError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{testBlocker && (
|
|
<p className="text-xs text-muted-foreground">
|
|
{testBlocker}
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</Step>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setDeleteConfirmOpen(true)}
|
|
className="flex items-center gap-1.5 text-xs text-destructive hover:underline"
|
|
>
|
|
<Trash2 className="size-3.5" />
|
|
{formatMessage(
|
|
models.length === 0
|
|
? t.deleteProviderOnly
|
|
: models.length === 1
|
|
? t.deleteProviderNamedOne
|
|
: t.deleteProviderNamedOther,
|
|
{
|
|
name: providerName(provider),
|
|
count: models.length,
|
|
},
|
|
)}
|
|
</button>
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div ref={pageRef} className="flex min-h-0 flex-1 flex-col">
|
|
{selectedProvider ? (
|
|
renderProviderPage(selectedProvider)
|
|
) : page === "picker" ? (
|
|
<ProviderPicker
|
|
added={config.providers.map((p) => p.provider)}
|
|
onPick={handleAddProvider}
|
|
onBack={() => setModelsPage("list")}
|
|
/>
|
|
) : (
|
|
<ProvidersList
|
|
providers={config.providers}
|
|
serverModels={modelConfig.models.filter(
|
|
(m) => m.source === "server",
|
|
)}
|
|
selectedModelId={modelConfig.selectedModelId}
|
|
onOpen={openProvider}
|
|
onAdd={handleAddProvider}
|
|
onBrowse={() => setModelsPage("picker")}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* 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">
|
|
{t.deleteProvider}
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription className="text-center">
|
|
{formatMessage(t.deleteConfirmDesc, {
|
|
name: selectedProvider
|
|
? providerName(selectedProvider)
|
|
: "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(t.typeToConfirm, {
|
|
name: providerName(selectedProvider),
|
|
})}
|
|
</Label>
|
|
<Input
|
|
id="delete-confirm"
|
|
value={deleteConfirmText}
|
|
onChange={(e) =>
|
|
setDeleteConfirmText(e.target.value)
|
|
}
|
|
placeholder={t.typeProviderName}
|
|
className="h-9"
|
|
/>
|
|
</div>
|
|
)}
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>{t.cancel}</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleDeleteProvider}
|
|
disabled={
|
|
selectedProvider &&
|
|
selectedProvider.models.length >= 3 &&
|
|
deleteConfirmText !==
|
|
providerName(selectedProvider)
|
|
}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
|
|
>
|
|
{t.delete}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</>
|
|
)
|
|
}
|