From 69fb66804d63266d2001ed3be647325ddd40eb7e Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Thu, 8 Oct 2026 16:32:47 +0900 Subject: [PATCH] feat(settings): reorganize the settings dialog and the model settings 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. --- components/chat/chat-engine.tsx | 17 +- components/chat/composer.tsx | 4 +- components/chat/message-list.tsx | 11 +- components/model-config-dialog.tsx | 1966 +++++++++----------- components/model-selector.tsx | 136 +- components/provider-credentials-fields.tsx | 512 +++-- components/settings-dialog.tsx | 485 ++--- components/settings/provider-picker.tsx | 144 ++ components/settings/providers-list.tsx | 271 +++ components/settings/settings-header.tsx | 8 + docs/cn/admin-panel.md | 2 +- docs/en/admin-panel.md | 2 +- docs/ja/admin-panel.md | 2 +- hooks/use-model-config.ts | 29 +- lib/i18n/dictionaries/en.json | 119 +- lib/i18n/dictionaries/ja.json | 111 +- lib/i18n/dictionaries/zh-Hant.json | 131 +- lib/i18n/dictionaries/zh.json | 131 +- lib/provider-setup.ts | 138 ++ lib/types/model-config.ts | 4 +- stores/ui-store.ts | 15 +- tests/e2e/chat-errors.spec.ts | 14 +- tests/e2e/language.spec.ts | 2 +- tests/e2e/lib/fixtures.ts | 4 +- tests/e2e/model-test.spec.ts | 10 +- tests/e2e/provider-models.spec.ts | 127 +- tests/e2e/settings-providers.spec.ts | 504 +++++ tests/e2e/settings-review-fixes.spec.ts | 830 +++++++++ tests/e2e/settings.spec.ts | 6 +- tests/e2e/workspace.spec.ts | 36 +- tests/unit/provider-setup.test.ts | 154 ++ tests/unit/use-model-config.test.tsx | 42 + 32 files changed, 4237 insertions(+), 1730 deletions(-) create mode 100644 components/settings/provider-picker.tsx create mode 100644 components/settings/providers-list.tsx create mode 100644 components/settings/settings-header.tsx create mode 100644 lib/provider-setup.ts create mode 100644 tests/e2e/settings-providers.spec.ts create mode 100644 tests/e2e/settings-review-fixes.spec.ts create mode 100644 tests/unit/provider-setup.test.ts 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 }, + ]} + /> + + +