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.
This commit is contained in:
dayuan.jiang
2026-10-08 17:44:08 +09:00
parent c513d1a0c2
commit 69fb66804d
32 changed files with 4237 additions and 1730 deletions
+366 -146
View File
@@ -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<BedrockAuth>(
filledAuth ?? "apiKey",
)
if (filledAuth && filledAuth !== chosenAuth) setChosenAuth(filledAuth)
const bedrockAuth: BedrockAuth = filledAuth ?? chosenAuth
const nameField = (
<div className="space-y-2">
<Label
htmlFor="provider-name"
className="text-xs font-medium flex items-center gap-1.5"
>
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.displayName}
</Label>
<Input
id="provider-name"
value={name ?? ""}
disabled={disabled}
onChange={(e) => onChange("name", e.target.value)}
placeholder={info.label}
className="h-9"
/>
</div>
)
const regionField = (
<div className="space-y-2">
<Label
htmlFor="aws-region"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.awsRegion}
</Label>
<Select
value={awsRegion || ""}
disabled={disabled}
onValueChange={(v) => onChange("awsRegion", v)}
>
<SelectTrigger
id="aws-region"
className="h-9 font-mono text-xs hover:bg-accent"
>
<SelectValue placeholder={dict.modelConfig.selectRegion} />
</SelectTrigger>
<SelectContent className="max-h-64">
{AWS_REGIONS.map(([region, label]) => (
<SelectItem key={region} value={region}>
{region} ({label})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
const vertexBaseUrlField = (
<div className="space-y-2">
<Label
htmlFor="vertex-base-url"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{baseUrlLabel}
</Label>
<Input
id="vertex-base-url"
value={baseUrl ?? ""}
disabled={disabled}
onChange={(e) => onChange("baseUrl", e.target.value)}
placeholder={dict.modelConfig.customEndpoint}
className="h-9 font-mono text-xs"
/>
</div>
)
// API key, with a link to where the provider issues keys
const apiKeyField = (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label
htmlFor="api-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.apiKey}
{provider === "ollama" && ` ${dict.modelConfig.optional}`}
</Label>
{info.apiKeyUrl && (
<a
href={info.apiKeyUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline"
>
{dict.modelConfig.getApiKey}
</a>
)}
</div>
{renderSecret({ field: "apiKey", id: "api-key" })}
</div>
)
const baseUrlField = (
<div className="space-y-2">
<Label
htmlFor="base-url"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{baseUrlLabel}
</Label>
<Input
id="base-url"
value={baseUrl ?? ""}
disabled={disabled}
onChange={(e) => 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 && (
<p className="text-xs text-muted-foreground font-mono break-all">
{formatMessage(dict.modelConfig.requestUrl, {
url: requestUrl,
})}
</p>
)}
{provider === "minimax" && (
<p className="text-xs text-muted-foreground">
{dict.modelConfig.minimaxBaseUrlHint}
</p>
)}
{provider === "mimo" && (
<p className="text-xs text-muted-foreground">
{dict.modelConfig.mimoBaseUrlHint}
</p>
)}
</div>
)
// EdgeOne needs no credentials — the caller supplies just a test button
if (provider === "edgeone") {
return <div className="space-y-5">{footer}</div>
}
if (settingsLayout) {
return (
<div className="space-y-4">
{provider === "bedrock" ? (
<>
{/* Buttons, not radios: arrow keys would empty
secrets without a deliberate press */}
<fieldset
aria-label={dict.modelConfig.awsCredentials}
className="inline-flex rounded-lg bg-muted p-0.5"
>
{(["apiKey", "accessKey"] as const).map((auth) => (
<button
key={auth}
type="button"
aria-pressed={bedrockAuth === auth}
disabled={disabled}
onClick={() => {
if (bedrockAuth === auth) return
setChosenAuth(auth)
clearSecrets?.(
auth === "apiKey"
? [
"awsAccessKeyId",
"awsSecretAccessKey",
]
: ["apiKey"],
)
}}
className={cn(
"inline-flex h-7 items-center rounded-md px-2.5 text-xs text-muted-foreground transition-colors",
bedrockAuth === auth &&
"bg-card font-medium text-foreground shadow-float",
)}
>
{auth === "apiKey"
? dict.modelConfig.bedrockAuthApiKey
: dict.modelConfig.bedrockAuthAccessKey}
</button>
))}
</fieldset>
{/* Both kinds kept (saved before the choice): the
access keys sit unused behind the API key */}
{bedrockFilled?.apiKey && bedrockFilled.accessKey && (
<p className="text-xs text-muted-foreground">
{dict.modelConfig.bedrockBothSaved}{" "}
<button
type="button"
disabled={disabled}
onClick={() => {
clearSecrets?.([
"awsAccessKeyId",
"awsSecretAccessKey",
])
// This note goes away with the keys
document
.getElementById("bedrock-api-key")
?.focus()
}}
className="font-medium text-foreground underline underline-offset-2"
>
{dict.modelConfig.removeAccessKeys}
</button>
</p>
)}
{bedrockAuth === "apiKey" ? (
<div className="space-y-2">
<Label
htmlFor="bedrock-api-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.bedrockApiKey}
</Label>
{renderSecret({
field: "apiKey",
id: "bedrock-api-key",
})}
</div>
) : (
<>
<div className="space-y-2">
<Label
htmlFor="aws-access-key-id"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.awsAccessKeyId}
</Label>
{renderSecret({
field: "awsAccessKeyId",
id: "aws-access-key-id",
})}
</div>
<div className="space-y-2">
<Label
htmlFor="aws-secret-access-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.awsSecretAccessKey}
</Label>
{renderSecret({
field: "awsSecretAccessKey",
id: "aws-secret-access-key",
})}
</div>
{sessionTokenInput && (
<div className="space-y-2">
<Label
htmlFor="aws-session-token"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.awsSessionToken}
</Label>
{sessionTokenInput}
<p className="text-xs text-muted-foreground">
{
dict.modelConfig
.awsSessionTokenHint
}
</p>
</div>
)}
</>
)}
{regionField}
</>
) : provider === "vertexai" ? (
<div className="space-y-2">
<Label
htmlFor="vertex-api-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.apiKey}
</Label>
{renderSecret({
field: "vertexApiKey",
id: "vertex-api-key",
})}
</div>
) : (
apiKeyField
)}
<div className="space-y-4">
<button
type="button"
onClick={() => setOptionsOpen(!optionsOpen)}
aria-expanded={optionsOpen}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ChevronRight
className={cn(
"size-3.5 transition-transform",
optionsOpen && "rotate-90",
)}
/>
{provider === "bedrock"
? dict.modelConfig.moreOptionsName
: dict.modelConfig.moreOptions}
</button>
{optionsOpen && (
<>
{provider !== "bedrock" &&
(provider === "vertexai"
? vertexBaseUrlField
: baseUrlField)}
{nameField}
</>
)}
</div>
{footer}
</div>
)
}
return (
<div className="space-y-5">
{/* Display Name */}
<div className="space-y-2">
<Label
htmlFor="provider-name"
className="text-xs font-medium flex items-center gap-1.5"
>
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.displayName}
</Label>
<Input
id="provider-name"
value={name ?? ""}
disabled={disabled}
onChange={(e) => onChange("name", e.target.value)}
placeholder={info.label}
className="h-9"
/>
</div>
{nameField}
{provider === "bedrock" ? (
<>
@@ -166,37 +508,7 @@ export function ProviderCredentialsFields({
})}
</div>
{/* AWS Region */}
<div className="space-y-2">
<Label
htmlFor="aws-region"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.awsRegion}
</Label>
<Select
value={awsRegion || ""}
disabled={disabled}
onValueChange={(v) => onChange("awsRegion", v)}
>
<SelectTrigger
id="aws-region"
className="h-9 font-mono text-xs hover:bg-accent"
>
<SelectValue
placeholder={dict.modelConfig.selectRegion}
/>
</SelectTrigger>
<SelectContent className="max-h-64">
{AWS_REGIONS.map(([region, label]) => (
<SelectItem key={region} value={region}>
{region} ({label})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{regionField}
</>
) : provider === "vertexai" ? (
<>
@@ -215,104 +527,12 @@ export function ProviderCredentialsFields({
})}
</div>
{/* Base URL (optional) */}
<div className="space-y-2">
<Label
htmlFor="vertex-base-url"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{baseUrlLabel}
</Label>
<Input
id="vertex-base-url"
value={baseUrl ?? ""}
disabled={disabled}
onChange={(e) =>
onChange("baseUrl", e.target.value)
}
placeholder={dict.modelConfig.customEndpoint}
className="h-9 font-mono text-xs"
/>
</div>
{vertexBaseUrlField}
</>
) : (
<>
{/* API Key, with a link to where the provider issues keys */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label
htmlFor="api-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.apiKey}
{provider === "ollama" &&
` ${dict.modelConfig.optional}`}
</Label>
{info.apiKeyUrl && (
<a
href={info.apiKeyUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline"
>
{dict.modelConfig.getApiKey}
</a>
)}
</div>
{renderSecret({ field: "apiKey", id: "api-key" })}
</div>
{/* Base URL */}
<div className="space-y-2">
<Label
htmlFor="base-url"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{baseUrlLabel}
</Label>
<Input
id="base-url"
value={baseUrl ?? ""}
disabled={disabled}
onChange={(e) =>
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 && (
<p className="text-xs text-muted-foreground font-mono break-all">
{formatMessage(dict.modelConfig.requestUrl, {
url: requestUrl,
})}
</p>
)}
{provider === "minimax" && (
<p className="text-xs text-muted-foreground">
{dict.modelConfig.minimaxBaseUrlHint}
</p>
)}
{provider === "mimo" && (
<p className="text-xs text-muted-foreground">
{dict.modelConfig.mimoBaseUrlHint}
</p>
)}
</div>
{apiKeyField}
{baseUrlField}
</>
)}