diff --git a/components/chat/chat-engine.tsx b/components/chat/chat-engine.tsx index 0e86e670..8de0a2bd 100644 --- a/components/chat/chat-engine.tsx +++ b/components/chat/chat-engine.tsx @@ -413,6 +413,8 @@ export function ChatEngineProvider({ // screen): an answer started meanwhile would land in the next chat const leavingRef = useRef(false) const [isLeaving, setIsLeaving] = useState(false) + // The user's provider the current turn was sent to, for its errors + const requestProviderIdRef = useRef(undefined) // A message is being sent again (retry, regenerate, edit), until the // SDK has put it back const [isResending, setIsResending] = useState(false) @@ -607,6 +609,11 @@ export function ChatEngineProvider({ // Not in another chat that came on screen meanwhile if (getChatGeneration() !== turnChatRef.current) return + + // The provider the turn was sent to (none for a server model); + // the model selected may have changed since, e.g. in another tab + const providerId = requestProviderIdRef.current + // A system message, so it can be cleared with the conversation setMessages((currentMessages) => [ ...currentMessages, @@ -614,14 +621,15 @@ export function ChatEngineProvider({ id: `error-${Date.now()}`, role: "system" as const, parts: [{ type: "text" as const, text }], - // The message shows a button that opens model settings + // The message shows a button that opens model settings, + // on the page of the provider that refused ...(openModelConfig && { - metadata: { openModelConfig: true }, + metadata: { openModelConfig: true, providerId }, }), }, ]) - if (isAccessCodeError) openSettings("advanced") + if (isAccessCodeError) openSettings("general") }, // Re-render streamed messages at most every 150 ms. The streaming // diagram preview draws on each update, so this also limits redraws @@ -1200,6 +1208,9 @@ export function ChatEngineProvider({ const config = getSelectedAIConfig() const { customSystemMessage, minimalStyle, maxOutputTokens } = useSettingsStore.getState() + // For this turn's errors: the provider these headers name (read with + // them, from storage: another tab may have changed it meanwhile) + requestProviderIdRef.current = config.providerId || undefined const selected = selectedCells.map(({ id, label }) => ({ id, label })) const options = { diff --git a/components/chat/composer.tsx b/components/chat/composer.tsx index de8f3f35..8e1e3f44 100644 --- a/components/chat/composer.tsx +++ b/components/chat/composer.tsx @@ -466,10 +466,8 @@ export function Composer({ selectedModelId={modelConfig.selectedModelId} onSelect={modelConfig.setSelectedModelId} onConfigure={() => openSettings("models")} + onAddProvider={() => openSettings("models", "picker")} disabled={isDisabled} - showUnvalidatedModels={ - modelConfig.showUnvalidatedModels - } /> {!isBusy && input.trim() && ( openSettings("models")} + onOpenModelConfig={() => { + const { providerId } = + (message.metadata as + | { providerId?: string } + | undefined) ?? {} + openSettings( + "models", + providerId ? { providerId } : "list", + ) + }} onRetry={ index === messages.length - 1 && !engine.isBusy && diff --git a/components/model-config-dialog.tsx b/components/model-config-dialog.tsx index 73758930..73b21113 100644 --- a/components/model-config-dialog.tsx +++ b/components/model-config-dialog.tsx @@ -3,26 +3,27 @@ import { AlertCircle, Check, + CheckCircle2, ChevronRight, + CircleDashed, Eye, EyeOff, - Key, Loader2, Plus, RefreshCw, - Server, - Settings2, - Sparkles, Trash2, X, - Zap, } from "lucide-react" -import { useCallback, useEffect, useRef, useState } from "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, @@ -49,19 +50,12 @@ import { 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 { hasCredentials } from "@/lib/provider-setup" import { STORAGE_KEYS } from "@/lib/storage" import type { ModelConfig, @@ -70,6 +64,7 @@ import type { } 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 @@ -78,50 +73,50 @@ interface ModelConfigDialogProps { type ValidationStatus = "idle" | "validating" | "success" | "error" -// Configuration section with title and optional action -function ConfigSection({ +/** One numbered step of a provider's setup; a check mark once done */ +function Step({ + n, + done, title, - icon: Icon, action, children, }: { + n: number + done: boolean title: string - icon: React.ComponentType<{ className?: string }> action?: React.ReactNode children: React.ReactNode }) { return ( -
-
-
- - - {title} - +
+ + {done ? : n} + +
+
+

{title}

+ {action}
- {action} + {children}
- {children} -
+ ) } -// Card wrapper with subtle depth -function ConfigCard({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
- ) -} - -/** A Bedrock API key or an access key pair, and a region */ -function hasBedrockCredentials(p: ProviderConfig): boolean { - return ( - (!!p.apiKey || (!!p.awsAccessKeyId && !!p.awsSecretAccessKey)) && - !!p.awsRegion - ) -} +/** + * 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() /** Providers as last saved (the model config is saved on every change) */ function savedProviders(): ProviderConfig[] { @@ -140,18 +135,34 @@ export function ModelConfigDialog({ modelConfig, }: ModelConfigDialogProps) { const dict = useDictionary() - const [selectedProviderId, setSelectedProviderId] = useState( - null, - ) + 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("idle") const [validationError, setValidationError] = useState("") + // 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>({}) + const markRejected = ( + providerId: string, + askedWith: string, + rejected: boolean, + ) => + setRejectedKeys(({ [providerId]: _, ...rest }) => + rejected ? { ...rest, [providerId]: askedWith } : rest, + ) const [customModelInput, setCustomModelInput] = useState("") - const scrollRef = useRef(null) - const validationResetTimeoutRef = useRef | null>(null) const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) const [deleteConfirmText, setDeleteConfirmText] = useState("") // Models whose test is running (they are all tested at once) @@ -168,11 +179,13 @@ export function ModelConfigDialog({ id: string value: string } | null>(null) - // Models fetched from the provider, per provider config + // Models fetched from the provider, per provider config, with the + // credentials they were fetched with const [fetchedModels, setFetchedModels] = useState< - Record + Record >({}) - const [fetchingModels, setFetchingModels] = useState(false) + // The providers whose model list is being fetched + const [fetchingFor, setFetchingFor] = useState([]) const [fetchModelsError, setFetchModelsError] = useState("") const [modelPickerOpen, setModelPickerOpen] = useState(false) // models.dev data for hints, loaded with the dialog (it is ~180 KB) @@ -194,15 +207,27 @@ export function ModelConfigDialog({ 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 - // Number of the latest Test click: only that test may reset the busy - // state when its credentials changed meanwhile - const validationRunRef = useRef(0) // 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) @@ -231,20 +256,40 @@ export function ModelConfigDialog({ ]) } + // 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(null) + // The test result, read out by screen readers + const resultId = useId() + const useButtonRef = useRef(null) + const backButtonRef = useRef(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("[data-page-title]")?.focus() + }, [pageKey]) + // 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) => @@ -252,11 +297,23 @@ export function ModelConfigDialog({ ) }, [open, getModelInfo]) + // What to do about an error kind, worded for this page, where the + // credentials can be fixed + const errorHints = (p: ProviderConfig): Record => ({ + ...(dict.errors.llm as Record), + 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) - setFetchingModels(true) + const hints = errorHints(selectedProvider) + setFetchingFor((current) => [...current, providerId]) setFetchModelsError("") try { const response = await fetch( @@ -282,14 +339,16 @@ export function ModelConfigDialog({ if (Array.isArray(data.models)) { setFetchedModels((current) => ({ ...current, - [providerId]: data.models, + [providerId]: { askedWith, models: data.models }, })) - if (stillShown) setModelPickerOpen(true) - } else if (stillShown) { - const hints = dict.errors.llm as Record + 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(dict.modelConfig.requestFailed, { + formatMessage(t.requestFailed, { status: response.status, }), ) @@ -302,17 +361,32 @@ export function ModelConfigDialog({ setFetchModelsError(dict.errors.networkError) } } finally { - setFetchingModels(false) + setFetchingFor((current) => + current.filter((id) => id !== providerId), + ) } } - // The provider's own list once fetched, else the suggested models + // 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 - ? fetchedModels[selectedProvider.id] || - (SUGGESTED_MODELS[selectedProvider.provider] || []).map((id) => ({ - id, - })) + ? 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 @@ -326,28 +400,27 @@ export function ModelConfigDialog({ const availableSuggestions = suggestedModels.filter( (model) => !existingModelIds.includes(model.id), ) - const emptyStateSuggestions = selectedProvider + // 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) - setSelectedProviderId(newProvider.id) - setValidationStatus("idle") - setFetchModelsError("") - setModelPickerOpen(false) + // 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 = ( - field: keyof ProviderConfig, - value: string | boolean, - ) => { + const handleProviderUpdate = (updates: Partial) => { if (!selectedProviderId || !selectedProvider) return - const updates: Partial = { [field]: value } // Reset validation of the provider and its models when credentials change const credentialFields = [ "apiKey", @@ -355,32 +428,61 @@ export function ModelConfigDialog({ "awsAccessKeyId", "awsSecretAccessKey", "awsRegion", + "awsSessionToken", "vertexApiKey", ] - if (credentialFields.includes(field)) { + if (Object.keys(updates).some((f) => credentialFields.includes(f))) { setValidationStatus("idle") - setValidatingModelIds(new Set()) - setFetchedModels(({ [selectedProviderId]: _, ...rest }) => rest) + // 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.validated = false - updates.models = selectedProvider.models.map((m) => ({ - ...m, - validated: undefined, - validationError: undefined, - validationWarning: undefined, - responseTime: undefined, - })) + 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(dict.modelConfig.modelIdExists) + setDuplicateError(t.modelIdExists) return false } setDuplicateError("") @@ -388,6 +490,11 @@ export function ModelConfigDialog({ return true } + const addCustomModel = () => { + const modelId = customModelInput.trim() + if (modelId && handleAddModel(modelId)) setCustomModelInput("") + } + // Handle deleting a model const handleDeleteModel = (modelConfigId: string) => { if (!selectedProviderId) return @@ -398,7 +505,7 @@ export function ModelConfigDialog({ const handleDeleteProvider = () => { if (!selectedProviderId) return deleteProvider(selectedProviderId) - setSelectedProviderId(null) + setModelsPage("list") setValidationStatus("idle") setDeleteConfirmOpen(false) } @@ -406,26 +513,11 @@ export function ModelConfigDialog({ // 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 (!hasBedrockCredentials(selectedProvider)) return - } else if (isVertexAI) { - // Vertex AI requires vertexApiKey for Express Mode - if (!selectedProvider.vertexApiKey) { - return - } - } else if (!isEdgeOne && !isOllama && !selectedProvider.apiKey) { - return - } + if (!hasCredentials(selectedProvider)) return // Need at least one model to validate if (selectedProvider.models.length === 0) { - setValidationError(dict.modelConfig.addModelFirst) + setValidationError(t.addModelFirst) setValidationStatus("error") return } @@ -436,19 +528,27 @@ export function ModelConfigDialog({ let allValid = true let errorCount = 0 let idChanged = false + let rejected = false const askedWith = credentialsOf(selectedProviderId) - const run = ++validationRunRef.current + const run = (latestTestRun.get(selectedProviderId) ?? 0) + 1 + latestTestRun.set(selectedProviderId, run) // For EdgeOne, construct baseUrl from current origin - const baseUrl = isEdgeOne - ? `${window.location.origin}/api/edgeai` - : selectedProvider.baseUrl + 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(new Set(selectedProvider.models.map((m) => m.id))) + setValidatingModelIds( + (prev) => + new Set([...prev, ...selectedProvider.models.map((m) => m.id)]), + ) await Promise.all( selectedProvider.models.map(async (model) => { let update: Partial + let code: string | undefined try { const response = await fetch( getApiEndpoint("/api/validate-model"), @@ -480,6 +580,7 @@ export function ModelConfigDialog({ }, ) const data = await response.json().catch(() => ({})) + code = data.valid ? undefined : data.code update = data.valid ? { validated: true, @@ -492,23 +593,14 @@ export function ModelConfigDialog({ // 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, - ] + [hints[data.code], data.error] .filter(Boolean) .join(" ") || (response.ok - ? dict.modelConfig.validationError - : formatMessage( - dict.modelConfig.requestFailed, - { status: response.status }, - )), + ? t.validationError + : formatMessage(t.requestFailed, { + status: response.status, + })), validationWarning: undefined, } } catch { @@ -520,7 +612,7 @@ export function ModelConfigDialog({ } // A newer test started: its own results and spinners count, // whatever the credentials are now (they may have come back) - if (run !== validationRunRef.current) return + 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). @@ -548,6 +640,7 @@ export function ModelConfigDialog({ if (update.validated === false) { allValid = false errorCount++ + if (code === "invalid_api_key") rejected = true } updateModel(selectedProviderId, model.id, update) setValidatingModelIds((prev) => { @@ -557,7 +650,7 @@ export function ModelConfigDialog({ }) }), ) - if (run !== validationRunRef.current) return + if (run !== latestTestRun.get(selectedProviderId)) return if (credentialsOf(selectedProviderId) !== askedWith) { // The status line is about the provider shown now if (selectedProviderIdRef.current === selectedProviderId) { @@ -570,27 +663,22 @@ export function ModelConfigDialog({ 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") - // Reset to idle after showing success briefly (with cleanup) - if (validationResetTimeoutRef.current) { - clearTimeout(validationResetTimeoutRef.current) - } - validationResetTimeoutRef.current = setTimeout(() => { - validationResetTimeoutRef.current = null - if (run !== validationRunRef.current) return - setValidationStatus("idle") - }, 1500) } else { setValidationStatus("error") setValidationError( - formatMessage(dict.modelConfig.validationFailedCount, { - count: errorCount, - }), + formatMessage( + errorCount === 1 + ? t.validationFailedCountOne + : t.validationFailedCountOther, + { count: errorCount }, + ), ) } }, [ @@ -601,91 +689,52 @@ export function ModelConfigDialog({ dict, ]) - // Get all available provider types - const availableProviders = Object.keys(PROVIDER_INFO) as ProviderName[] - - // Get display name for provider - const getProviderDisplayName = (provider: ProviderConfig) => { - return provider.name || PROVIDER_INFO[provider.provider].label - } - - // Inline Test button + error, shared across credential layouts. Disabled - // until the relevant credentials are present. - const renderTestButton = (canValidate: boolean) => ( -
- - {validationStatus === "error" && validationError && ( -

- - {validationError} -

- )} -
- ) - // Plaintext secret input with show/hide toggle (the user dialog stores // keys client-side, so values are shown directly — unlike the masked - // admin panel). The primary key field carries the inline Test button. - const renderProviderSecret = (field: SecretField, id: string) => { + // admin panel) + const renderProviderSecret = ( + field: SecretField | "awsSessionToken", + 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 = ( + return (
- handleProviderUpdate(field, e.target.value) + handleProviderUpdate({ [field]: e.target.value }) } placeholder={ field === "awsSecretAccessKey" - ? dict.modelConfig.enterSecretKey + ? t.enterSecretKey : field === "awsAccessKeyId" ? "AKIA..." - : dict.modelConfig.enterApiKey + : field === "awsSessionToken" + ? undefined + : t.enterApiKey } - className="h-9 pr-10 font-mono text-xs" + aria-invalid={keyRejected || undefined} + className={cn( + "h-9 pr-10 font-mono text-xs", + keyRejected && + "border-destructive/60 focus-visible:ring-destructive/30", + )} />
) - if (!withInlineTest) return input + } + + 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 ( -
-
- {input} - {renderTestButton(canValidate)} +
+
+ + {testing ? ( + + ) : model.validated === true ? ( + + ) : model.validated === false ? ( + + ) : ( + + )} + + { + // 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 && ( + + {model.validated === true + ? seconds + ? formatMessage(t.modelWorksTime, { + seconds, + }) + : t.modelWorks + : model.validated === false + ? t.modelFailed + : t.modelUntested} + + )} +
+ {/* Show validation error inline */} + {model.validated === false && model.validationError && ( +

+ {model.validationError} +

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

+ {t.mayNotDraw} +

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

+ {model.validationWarning} +

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

+ {editError.message} +

+ )} +
+ ) + } + + 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 ( +
+ + + + + + +

+ {providerName(provider)} +

+
+ +
+ {/* 1: credentials */} + + {provider.provider === "edgeone" ? ( +

+ {t.edgeoneNoKey} +

+ ) : ( + + handleProviderUpdate({ + [field]: value, + }) + } + renderSecret={({ field, id }) => + renderProviderSecret(field, id) + } + /> + )} +
+ + {/* 2: models */} + 0} + title={t.stepModels} + action={ + PROVIDER_INFO[provider.provider].modelList && ( + + ) + } + > + {fetchModelsError && ( +

+ {fetchModelsError} +

+ )} + {models.length > 0 && ( +
+ {models.map(renderModelRow)} +
+ )} + {quickSuggestions.length > 0 && ( +
+ + {t.suggestedLabel} + + {quickSuggestions.map((modelId) => ( + + ))} +
+ )} +
+
+ { + 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 && ( +

+ {duplicateError} +

+ )} +
+ + {/* 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 && ( + + + + + + + + + + {t.noModelsFound} + + {availableSuggestions.map( + (model) => ( + { + handleAddModel( + model.id, + ) + setModelPickerOpen( + false, + ) + }} + className="font-mono text-xs" + > + + {model.id} + + {supportsTools( + model, + ) === false && ( + + { + t.noTools + } + + )} + + ), + )} + + + + + )} +
+
+ + {/* 3: test */} + + {/* Always on the page, so a screen reader reads + out the result when it arrives */} +

+ {testPassed + ? passedText + : testFailed + ? validationError + : ""} +

+ {testPassed ? ( +
+

+ + {/* A long model id wraps */} + + {passedText} + {lastSeconds && ( + + {formatMessage( + t.answeredIn, + { + seconds: + lastSeconds, + }, + )} + + )} + +

+
+ {firstWorking && ( + + )} + +
+
+ ) : ( + <> +

+ {t.testHint} +

+
+ + {testFailed && ( +

+ + {validationError} +

+ )} +
+ {testBlocker && ( +

+ {testBlocker} +

+ )} + + )} +
+ + +
+
) } return ( <> -
-
- {/* Provider List (Left Sidebar) */} -
-
- - {dict.modelConfig.providers} - -
- - -
- {config.providers.length === 0 ? ( -
-
- -
-

- {dict.modelConfig.addProviderHint} -

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

- { - PROVIDER_INFO[ - selectedProvider - .provider - ].label - } -

-

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

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

- {duplicateError} -

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

- {fetchModelsError} -

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

- { - dict.modelConfig - .noModelsConfigured - } -

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

- { - model.validationError - } -

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

- { - dict - .modelConfig - .mayNotDraw - } -

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

- { - model.validationWarning - } -

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

- { - editError.message - } -

- )} -
- ), - )} -
- )} -
- -
- - ) : ( -
-
- -
-

- {config.providers.length === 0 - ? dict.modelConfig.noProvidersTitle - : dict.modelConfig.configureProviders} -

-

- {config.providers.length === 0 - ? dict.modelConfig.noProvidersHint - : dict.modelConfig.selectProviderHint} -

-
+
+ {selectedProvider ? ( + renderProviderPage(selectedProvider) + ) : page === "picker" ? ( + p.provider)} + onPick={handleAddProvider} + onBack={() => setModelsPage("list")} + /> + ) : ( + m.source === "server", )} -
-
- - {/* Footer */} -
-
-
- - -
-

- - {dict.modelConfig.apiKeyStored} -

-
-
+ selectedModelId={modelConfig.selectedModelId} + onOpen={openProvider} + onAdd={handleAddProvider} + onBrowse={() => setModelsPage("picker")} + /> + )}
{/* Delete Confirmation Dialog */} @@ -1541,14 +1400,12 @@ export function ModelConfigDialog({
- {dict.modelConfig.deleteProvider} + {t.deleteProvider} - {formatMessage(dict.modelConfig.deleteConfirmDesc, { + {formatMessage(t.deleteConfirmDesc, { name: selectedProvider - ? selectedProvider.name || - PROVIDER_INFO[selectedProvider.provider] - .label + ? providerName(selectedProvider) : "this provider", })} @@ -1560,16 +1417,9 @@ export function ModelConfigDialog({ htmlFor="delete-confirm" className="text-sm text-muted-foreground" > - {formatMessage( - dict.modelConfig.typeToConfirm, - { - name: - selectedProvider.name || - PROVIDER_INFO[ - selectedProvider.provider - ].label, - }, - )} + {formatMessage(t.typeToConfirm, { + name: providerName(selectedProvider), + })} setDeleteConfirmText(e.target.value) } - placeholder={ - dict.modelConfig.typeProviderName - } + placeholder={t.typeProviderName} className="h-9" />
)} - - {dict.modelConfig.cancel} - + {t.cancel} = 3 && deleteConfirmText !== - (selectedProvider.name || - PROVIDER_INFO[selectedProvider.provider] - .label) + providerName(selectedProvider) } className="bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50" > - {dict.modelConfig.delete} + {t.delete} diff --git a/components/model-selector.tsx b/components/model-selector.tsx index d0629e07..1b146d66 100644 --- a/components/model-selector.tsx +++ b/components/model-selector.tsx @@ -1,11 +1,11 @@ "use client" import { - AlertTriangle, Bot, Check, ChevronDown, Monitor, + Plus, Server, Settings2, User, @@ -39,8 +39,9 @@ interface ModelSelectorProps { selectedModelId: string | undefined onSelect: (modelId: string | undefined) => void onConfigure?: () => void + /** Opens the settings on the list of providers to add */ + onAddProvider?: () => void disabled?: boolean - showUnvalidatedModels?: boolean } // Group models by providerLabel (handles duplicate providers) @@ -92,27 +93,21 @@ export function ModelSelector({ selectedModelId, onSelect, onConfigure, + onAddProvider, disabled = false, - showUnvalidatedModels = false, }: ModelSelectorProps) { const dict = useDictionary() const [open, setOpen] = useState(false) - // Filter models based on showUnvalidatedModels setting - const displayModels = useMemo(() => { - if (showUnvalidatedModels) { - return models - } - return models.filter((m) => m.validated === true) - }, [models, showUnvalidatedModels]) - // Separate server and user models + // Separate server and user models. Every user model is listed; one not + // tested yet, or that failed its test, says so. const serverModels = useMemo( - () => displayModels.filter((m) => m.source === "server"), - [displayModels], + () => models.filter((m) => m.source === "server"), + [models], ) const userModels = useMemo( - () => displayModels.filter((m) => m.source !== "server"), - [displayModels], + () => models.filter((m) => m.source !== "server"), + [models], ) // Group each category separately @@ -131,6 +126,17 @@ export function ModelSelector({ [models, selectedModelId], ) + // Leaving for the settings: closing, the picker must not give the focus + // back to its button, behind the settings dialog + const toSettingsRef = useRef(false) + const goToSettings = (openSettings: () => void) => { + toSettingsRef.current = true + setOpen(false) + openSettings() + } + const footerButton = + "flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none hover:bg-accent focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-ring" + const handleSelect = (value: string) => { if (value === "__server_default__") { onSelect(undefined) @@ -180,7 +186,13 @@ export function ModelSelector({ return (
- + { + if (next) toSettingsRef.current = false + setOpen(next) + }} + > - + { + if (!toSettingsRef.current) return + toSettingsRef.current = false + e.preventDefault() + }} + > @@ -230,10 +249,7 @@ export function ModelSelector({
- {displayModels.length === 0 && - models.length > 0 - ? dict.modelConfig.noVerifiedModels - : dict.modelConfig.noModelsFound} + {dict.modelConfig.noModelsFound} {/* Server Default Option - only show when no server models are configured */} @@ -422,13 +438,22 @@ export function ModelSelector({ {model.validated !== true && ( - + {model.validated === + false + ? dict + .modelConfig + .modelFailed + : dict + .modelConfig + .modelUntested} )} @@ -441,31 +466,46 @@ export function ModelSelector({ )}
- {/* Pinned footer: Configure Models... + info text (z-10 above list shadow) */} -
- {onConfigure && ( -
- { - onConfigure() - setOpen(false) - }} - className="flex cursor-pointer items-center gap-2 rounded-sm" + {/* Pinned footer: add a provider, configure models + (z-10 above list shadow). Buttons, outside the + search: reachable with Tab, never filtered out */} + {(onAddProvider || onConfigure) && ( +
e.stopPropagation()} + > + {onAddProvider && ( + + )} + {onConfigure && ( +
- )} -
- {showUnvalidatedModels - ? dict.modelConfig.allModelsShown - : dict.modelConfig.onlyVerifiedShown} + + + )}
-
+ )}
diff --git a/components/provider-credentials-fields.tsx b/components/provider-credentials-fields.tsx index f2d86954..4a8a3e42 100644 --- a/components/provider-credentials-fields.tsx +++ b/components/provider-credentials-fields.tsx @@ -1,7 +1,7 @@ "use client" -import { Key, Link2, Tag } from "lucide-react" -import type { ReactNode } from "react" +import { ChevronRight, Key, Link2, Tag } from "lucide-react" +import { type ReactNode, useState } from "react" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { @@ -13,12 +13,14 @@ import { } from "@/components/ui/select" import { useDictionary } from "@/hooks/use-dictionary" import { formatMessage } from "@/lib/i18n/utils" +import { OPTIONS_OPEN } from "@/lib/provider-setup" import { chatRequestUrl, normalizeBaseUrl, PROVIDER_INFO, type ProviderName, } from "@/lib/types/model-config" +import { cn } from "@/lib/utils" // Logical secret field. The caller owns the actual input — plaintext for the // user dialog, write-only masked for the admin panel — supplied via @@ -30,6 +32,9 @@ export type SecretField = | "awsSecretAccessKey" | "vertexApiKey" +/** Bedrock signs in with an API key or with an access key pair */ +export type BedrockAuth = "apiKey" | "accessKey" + // AWS regions offered for Bedrock (shared by both screens) export const AWS_REGIONS: Array<[string, string]> = [ ["us-east-1", "N. Virginia"], @@ -66,6 +71,17 @@ interface ProviderCredentialsFieldsProps { // Extra content after the fields — used for the Bedrock test row and the // EdgeOne test button, which aren't beside a credential input. footer?: ReactNode + // The user's settings page: the secrets come first, the display name and + // base URL fold under "More options", and Bedrock shows only the way of + // signing in that is chosen. Switching it empties the other way's + // secrets (clearSecrets), so a request carries one kind of credential. + settingsLayout?: boolean + clearSecrets?: (fields: SecretField[]) => void + // Bedrock credentials filled in, to choose the way of signing in shown + bedrockFilled?: { apiKey: boolean; accessKey: boolean } + // Bedrock's optional session token input, for temporary access keys + // (the admin panel has none) + sessionTokenInput?: ReactNode } // Display name + per-provider credential inputs, shared by the user @@ -80,6 +96,10 @@ export function ProviderCredentialsFields({ onChange, renderSecret, footer, + settingsLayout, + clearSecrets, + bedrockFilled, + sessionTokenInput, }: ProviderCredentialsFieldsProps) { const dict = useDictionary() const info = PROVIDER_INFO[provider] @@ -87,32 +107,354 @@ export function ProviderCredentialsFields({ example: info.defaultBaseUrl || "https://api.example.com/v1", }) const requestUrl = baseUrl ? chatRequestUrl(provider, baseUrl) : null + // Open when it holds something the user set, or for a provider whose + // base URL usually needs changing or comes with a hint + const [optionsOpen, setOptionsOpen] = useState( + () => + !!name || + (!!baseUrl && baseUrl !== info.defaultBaseUrl) || + OPTIONS_OPEN.includes(provider), + ) + // Bedrock: the way that is filled in (an API key is used first when both + // are, as the server does), else the one shown last, so emptying the + // fields to type new keys keeps them on screen + const filledAuth: BedrockAuth | null = bedrockFilled?.apiKey + ? "apiKey" + : bedrockFilled?.accessKey + ? "accessKey" + : null + const [chosenAuth, setChosenAuth] = useState( + filledAuth ?? "apiKey", + ) + if (filledAuth && filledAuth !== chosenAuth) setChosenAuth(filledAuth) + const bedrockAuth: BedrockAuth = filledAuth ?? chosenAuth + + const nameField = ( +
+ + onChange("name", e.target.value)} + placeholder={info.label} + className="h-9" + /> +
+ ) + const regionField = ( +
+ + +
+ ) + const vertexBaseUrlField = ( +
+ + onChange("baseUrl", e.target.value)} + placeholder={dict.modelConfig.customEndpoint} + className="h-9 font-mono text-xs" + /> +
+ ) + // API key, with a link to where the provider issues keys + const apiKeyField = ( +
+
+ + {info.apiKeyUrl && ( + + {dict.modelConfig.getApiKey} + + )} +
+ {renderSecret({ field: "apiKey", id: "api-key" })} +
+ ) + const baseUrlField = ( +
+ + onChange("baseUrl", e.target.value)} + // Drop a pasted endpoint path such as /chat/completions + onBlur={(e) => { + const normalized = normalizeBaseUrl(e.target.value) + if (normalized !== e.target.value) { + onChange("baseUrl", normalized) + } + }} + placeholder={ + info.defaultBaseUrl || dict.modelConfig.customEndpoint + } + className="h-9 rounded-xl font-mono text-xs" + /> + {requestUrl && ( +

+ {formatMessage(dict.modelConfig.requestUrl, { + url: requestUrl, + })} +

+ )} + {provider === "minimax" && ( +

+ {dict.modelConfig.minimaxBaseUrlHint} +

+ )} + {provider === "mimo" && ( +

+ {dict.modelConfig.mimoBaseUrlHint} +

+ )} +
+ ) // EdgeOne needs no credentials — the caller supplies just a test button if (provider === "edgeone") { return
{footer}
} + if (settingsLayout) { + return ( +
+ {provider === "bedrock" ? ( + <> + {/* Buttons, not radios: arrow keys would empty + secrets without a deliberate press */} +
+ {(["apiKey", "accessKey"] as const).map((auth) => ( + + ))} +
+ {/* Both kinds kept (saved before the choice): the + access keys sit unused behind the API key */} + {bedrockFilled?.apiKey && bedrockFilled.accessKey && ( +

+ {dict.modelConfig.bedrockBothSaved}{" "} + +

+ )} + {bedrockAuth === "apiKey" ? ( +
+ + {renderSecret({ + field: "apiKey", + id: "bedrock-api-key", + })} +
+ ) : ( + <> +
+ + {renderSecret({ + field: "awsAccessKeyId", + id: "aws-access-key-id", + })} +
+
+ + {renderSecret({ + field: "awsSecretAccessKey", + id: "aws-secret-access-key", + })} +
+ {sessionTokenInput && ( +
+ + {sessionTokenInput} +

+ { + dict.modelConfig + .awsSessionTokenHint + } +

+
+ )} + + )} + {regionField} + + ) : provider === "vertexai" ? ( +
+ + {renderSecret({ + field: "vertexApiKey", + id: "vertex-api-key", + })} +
+ ) : ( + apiKeyField + )} +
+ + {optionsOpen && ( + <> + {provider !== "bedrock" && + (provider === "vertexai" + ? vertexBaseUrlField + : baseUrlField)} + {nameField} + + )} +
+ {footer} +
+ ) + } + return (
- {/* Display Name */} -
- - onChange("name", e.target.value)} - placeholder={info.label} - className="h-9" - /> -
+ {nameField} {provider === "bedrock" ? ( <> @@ -166,37 +508,7 @@ export function ProviderCredentialsFields({ })}
- {/* AWS Region */} -
- - -
+ {regionField} ) : provider === "vertexai" ? ( <> @@ -215,104 +527,12 @@ export function ProviderCredentialsFields({ })}
- {/* Base URL (optional) */} -
- - - onChange("baseUrl", e.target.value) - } - placeholder={dict.modelConfig.customEndpoint} - className="h-9 font-mono text-xs" - /> -
+ {vertexBaseUrlField} ) : ( <> - {/* API Key, with a link to where the provider issues keys */} -
-
- - {info.apiKeyUrl && ( - - {dict.modelConfig.getApiKey} - - )} -
- {renderSecret({ field: "apiKey", id: "api-key" })} -
- - {/* Base URL */} -
- - - onChange("baseUrl", e.target.value) - } - // Drop a pasted endpoint path such as /chat/completions - onBlur={(e) => { - const normalized = normalizeBaseUrl( - e.target.value, - ) - if (normalized !== e.target.value) { - onChange("baseUrl", normalized) - } - }} - placeholder={ - info.defaultBaseUrl || - dict.modelConfig.customEndpoint - } - className="h-9 rounded-xl font-mono text-xs" - /> - {requestUrl && ( -

- {formatMessage(dict.modelConfig.requestUrl, { - url: requestUrl, - })} -

- )} - {provider === "minimax" && ( -

- {dict.modelConfig.minimaxBaseUrlHint} -

- )} - {provider === "mimo" && ( -

- {dict.modelConfig.mimoBaseUrlHint} -

- )} -
+ {apiKeyField} + {baseUrlField} )} diff --git a/components/settings-dialog.tsx b/components/settings-dialog.tsx index df6fab2b..0ddc71b2 100644 --- a/components/settings-dialog.tsx +++ b/components/settings-dialog.tsx @@ -1,11 +1,11 @@ import { Github, Info, - KeyRound, Monitor, Moon, - Palette, - SlidersHorizontal, + PenTool, + Settings2, + Sparkles, Sun, Terminal, } from "lucide-react" @@ -14,6 +14,7 @@ import { useEffect, useState } from "react" import { toast } from "sonner" import { useChatEngine } from "@/components/chat/chat-engine" import { ModelConfigDialog } from "@/components/model-config-dialog" +import { SettingsHeader } from "@/components/settings/settings-header" import { Button } from "@/components/ui/button" import { Dialog, @@ -130,7 +131,15 @@ function Segmented({ ) } -function AppearanceTab() { +function SectionHeading({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ) +} + +function GeneralTab({ open }: { open: boolean }) { const dict = useDictionary() const t = dict.settings const engine = useChatEngine() @@ -139,99 +148,10 @@ function AppearanceTab() { const search = useSearchParams() const theme = useSettingsStore((s) => s.theme) const setTheme = useSettingsStore((s) => s.setTheme) - const minimalStyle = useSettingsStore((s) => s.minimalStyle) - const setMinimalStyle = useSettingsStore((s) => s.setMinimalStyle) + const sendShortcut = useSettingsStore((s) => s.sendShortcut) + const setSendShortcut = useSettingsStore((s) => s.setSendShortcut) const currentLang = (pathname.split("/").filter(Boolean)[0] as Locale) || i18n.defaultLocale - - // The page mounts anew in the other language: the chat is saved first - // (it may not be yet), and its session goes into the new URL - const changeLanguage = (lang: string) => - engine.leavePage((sessionId) => { - localStorage.setItem(STORAGE_KEYS.locale, lang) - // The page reloads in the new language; close settings as before - useUiStore.getState().setSettingsOpen(false) - // Keep the desktop app's menu language in sync - window.electronAPI - ?.setUserLocale?.(lang) - .catch((error: unknown) => { - console.error("Failed to sync locale with Electron:", error) - }) - const parts = pathname.split("/") - if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) { - parts[1] = lang - } else { - parts.splice(1, 0, lang) - } - const params = new URLSearchParams(search?.toString()) - if (sessionId) params.set("session", sessionId) - const query = params.toString() ? `?${params.toString()}` : "" - router.push((parts.join("/") || "/") + query) - }) - - return ( -
- - - label={t.theme} - value={theme} - onChange={setTheme} - options={[ - { value: "light", label: t.themeLight, icon: }, - { - value: "dark", - label: t.themeDarkMode, - icon: , - }, - { - value: "system", - label: t.themeSystem, - icon: , - }, - ]} - /> - - - - - - - label={t.diagramStyle} - value={minimalStyle ? "minimal" : "styled"} - onChange={(value) => setMinimalStyle(value === "minimal")} - options={[ - { value: "styled", label: dict.chat.styledMode }, - { value: "minimal", label: dict.chat.minimalStyle }, - ]} - /> - -
- ) -} - -function AdvancedTab({ open }: { open: boolean }) { - const dict = useDictionary() - const t = dict.settings - const settings = useSettingsStore() const [accessCode, setAccessCode] = useState("") const [accessCodeRequired, setAccessCodeRequired] = useState( () => localStorage.getItem(STORAGE_KEYS.accessCodeRequired) === "true", @@ -269,6 +189,31 @@ function AdvancedTab({ open }: { open: boolean }) { }) }, [open]) + // The page mounts anew in the other language: the chat is saved first + // (it may not be yet), and its session goes into the new URL + const changeLanguage = (lang: string) => + engine.leavePage((sessionId) => { + localStorage.setItem(STORAGE_KEYS.locale, lang) + // The page reloads in the new language; close settings as before + useUiStore.getState().setSettingsOpen(false) + // Keep the desktop app's menu language in sync + window.electronAPI + ?.setUserLocale?.(lang) + .catch((error: unknown) => { + console.error("Failed to sync locale with Electron:", error) + }) + const parts = pathname.split("/") + if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) { + parts[1] = lang + } else { + parts.splice(1, 0, lang) + } + const params = new URLSearchParams(search?.toString()) + if (sessionId) params.set("session", sessionId) + const query = params.toString() ? `?${params.toString()}` : "" + router.push((parts.join("/") || "/") + query) + }) + const saveAccessCode = async () => { setAccessError("") setIsVerifying(true) @@ -320,65 +265,212 @@ function AdvancedTab({ open }: { open: boolean }) { } return ( -
- {accessCodeRequired && ( - -
- setAccessCode(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault() - saveAccessCode() - } - }} - placeholder={t.accessCodePlaceholder} - autoComplete="off" - className="h-9" - /> - -
- {accessError && ( -

- {accessError} -

- )} +
+ {t.sectionInterface} +
+ + + label={t.theme} + value={theme} + onChange={setTheme} + options={[ + { + value: "light", + label: t.themeLight, + icon: , + }, + { + value: "dark", + label: t.themeDarkMode, + icon: , + }, + { + value: "system", + label: t.themeSystem, + icon: , + }, + ]} + /> - )} - - + + + + + {i18n.locales.map((locale) => ( + + {LANGUAGE_LABELS[locale]} + + ))} + + + + + + +
+ {(accessCodeRequired || isElectron) && ( + <> + {t.sectionAccess} +
+ {accessCodeRequired && ( + +
+ + setAccessCode(e.target.value) + } + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + saveAccessCode() + } + }} + placeholder={t.accessCodePlaceholder} + autoComplete="off" + className="h-9" + /> + +
+ {accessError && ( +

+ {accessError} +

+ )} +
+ )} + {isElectron && ( + +
+ + setHttpProxy(e.target.value) + } + placeholder={`${t.httpProxy}: http://proxy:8080`} + className="h-9" + /> + + setHttpsProxy(e.target.value) + } + placeholder={`${t.httpsProxy}: http://proxy:8080`} + className="h-9" + /> + +
+
+ )} +
+ + )} +
+ ) +} + +function DrawingTab() { + const dict = useDictionary() + const t = dict.settings + const settings = useSettingsStore() + + return ( +
+ + + label={t.diagramStyle} + value={settings.minimalStyle ? "minimal" : "styled"} + onChange={(value) => + settings.setMinimalStyle(value === "minimal") + } + options={[ + { value: "styled", label: dict.chat.styledMode }, + { value: "minimal", label: dict.chat.minimalStyle }, + ]} + /> + + +