mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-10 03:29:50 +08:00
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:
@@ -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<string | undefined>(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 = {
|
||||
|
||||
@@ -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() && (
|
||||
<kbd
|
||||
|
||||
@@ -615,7 +615,16 @@ export function MessageList() {
|
||||
| undefined
|
||||
)?.openModelConfig
|
||||
}
|
||||
onOpenModelConfig={() => openSettings("models")}
|
||||
onOpenModelConfig={() => {
|
||||
const { providerId } =
|
||||
(message.metadata as
|
||||
| { providerId?: string }
|
||||
| undefined) ?? {}
|
||||
openSettings(
|
||||
"models",
|
||||
providerId ? { providerId } : "list",
|
||||
)
|
||||
}}
|
||||
onRetry={
|
||||
index === messages.length - 1 &&
|
||||
!engine.isBusy &&
|
||||
|
||||
+905
-1061
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||
<div ref={wrapperRef} className="min-w-0 max-w-44">
|
||||
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
|
||||
<ModelSelectorRoot
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (next) toSettingsRef.current = false
|
||||
setOpen(next)
|
||||
}}
|
||||
>
|
||||
<ModelSelectorTrigger asChild>
|
||||
<ButtonWithTooltip
|
||||
tooltipContent={tooltipContent}
|
||||
@@ -222,7 +234,14 @@ export function ModelSelector({
|
||||
</ButtonWithTooltip>
|
||||
</ModelSelectorTrigger>
|
||||
|
||||
<ModelSelectorContent title={dict.modelConfig.selectModel}>
|
||||
<ModelSelectorContent
|
||||
title={dict.modelConfig.selectModel}
|
||||
onCloseAutoFocus={(e) => {
|
||||
if (!toSettingsRef.current) return
|
||||
toSettingsRef.current = false
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<ModelSelectorInput
|
||||
placeholder={dict.modelConfig.searchModels}
|
||||
/>
|
||||
@@ -230,10 +249,7 @@ export function ModelSelector({
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ModelSelectorList className="overflow-y-auto scrollbar-thin">
|
||||
<ModelSelectorEmpty>
|
||||
{displayModels.length === 0 &&
|
||||
models.length > 0
|
||||
? dict.modelConfig.noVerifiedModels
|
||||
: dict.modelConfig.noModelsFound}
|
||||
{dict.modelConfig.noModelsFound}
|
||||
</ModelSelectorEmpty>
|
||||
|
||||
{/* Server Default Option - only show when no server models are configured */}
|
||||
@@ -422,13 +438,22 @@ export function ModelSelector({
|
||||
{model.validated !==
|
||||
true && (
|
||||
<span
|
||||
title={
|
||||
dict
|
||||
.modelConfig
|
||||
.unvalidatedModelWarning
|
||||
}
|
||||
className={cn(
|
||||
"ml-auto shrink-0 pl-2 text-xs",
|
||||
model.validated ===
|
||||
false
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
|
||||
{model.validated ===
|
||||
false
|
||||
? dict
|
||||
.modelConfig
|
||||
.modelFailed
|
||||
: dict
|
||||
.modelConfig
|
||||
.modelUntested}
|
||||
</span>
|
||||
)}
|
||||
</ModelSelectorItem>
|
||||
@@ -441,31 +466,46 @@ export function ModelSelector({
|
||||
)}
|
||||
</ModelSelectorList>
|
||||
</div>
|
||||
{/* Pinned footer: Configure Models... + info text (z-10 above list shadow) */}
|
||||
<div className="relative z-10 shrink-0 border-t bg-background">
|
||||
{onConfigure && (
|
||||
<div className="px-3 py-2">
|
||||
<ModelSelectorItem
|
||||
value="__configure_models__"
|
||||
onSelect={() => {
|
||||
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) && (
|
||||
<div
|
||||
className="relative z-10 shrink-0 space-y-0.5 border-t bg-background px-3 py-2"
|
||||
// The search's Enter would pick the highlighted
|
||||
// model instead of pressing the button
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{onAddProvider && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
goToSettings(onAddProvider)
|
||||
}
|
||||
className={footerButton}
|
||||
>
|
||||
<Plus className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
{dict.modelConfig.addProviderEntry}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{onConfigure && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
goToSettings(onConfigure)
|
||||
}
|
||||
className={footerButton}
|
||||
>
|
||||
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<ModelSelectorName>
|
||||
<span className="truncate">
|
||||
{dict.modelConfig.configureModels}
|
||||
</ModelSelectorName>
|
||||
</ModelSelectorItem>
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 pb-2 text-xs text-muted-foreground">
|
||||
{showUnvalidatedModels
|
||||
? dict.modelConfig.allModelsShown
|
||||
: dict.modelConfig.onlyVerifiedShown}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ModelSelectorContent>
|
||||
</ModelSelectorRoot>
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
+271
-214
@@ -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<T extends string>({
|
||||
)
|
||||
}
|
||||
|
||||
function AppearanceTab() {
|
||||
function SectionHeading({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="pt-6 pb-1 text-[11px] font-medium text-muted-foreground first:pt-0">
|
||||
{children}
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="divide-y divide-border">
|
||||
<Row label={t.theme} description={t.themeDescription}>
|
||||
<Segmented<ThemePreference>
|
||||
label={t.theme}
|
||||
value={theme}
|
||||
onChange={setTheme}
|
||||
options={[
|
||||
{ value: "light", label: t.themeLight, icon: <Sun /> },
|
||||
{
|
||||
value: "dark",
|
||||
label: t.themeDarkMode,
|
||||
icon: <Moon />,
|
||||
},
|
||||
{
|
||||
value: "system",
|
||||
label: t.themeSystem,
|
||||
icon: <Monitor />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
<Row label={t.language} description={t.languageDescription}>
|
||||
<Select
|
||||
value={currentLang}
|
||||
onValueChange={changeLanguage}
|
||||
// Not while an answer runs: the page mounts anew
|
||||
disabled={engine.isBusy}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="language-select"
|
||||
className="h-8 w-[132px] rounded-lg"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{i18n.locales.map((locale) => (
|
||||
<SelectItem key={locale} value={locale}>
|
||||
{LANGUAGE_LABELS[locale]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Row>
|
||||
<Row label={t.diagramStyle} description={t.diagramStyleDescription}>
|
||||
<Segmented<"styled" | "minimal">
|
||||
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 },
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="divide-y divide-border">
|
||||
{accessCodeRequired && (
|
||||
<Row
|
||||
label={t.accessCode}
|
||||
description={t.accessCodeDescription}
|
||||
htmlFor="access-code"
|
||||
stacked
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="access-code"
|
||||
type="password"
|
||||
value={accessCode}
|
||||
onChange={(e) => setAccessCode(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
saveAccessCode()
|
||||
}
|
||||
}}
|
||||
placeholder={t.accessCodePlaceholder}
|
||||
autoComplete="off"
|
||||
className="h-9"
|
||||
/>
|
||||
<Button
|
||||
onClick={saveAccessCode}
|
||||
disabled={isVerifying || !accessCode.trim()}
|
||||
className="h-9 rounded-lg px-4"
|
||||
>
|
||||
{isVerifying ? "…" : dict.common.save}
|
||||
</Button>
|
||||
</div>
|
||||
{accessError && (
|
||||
<p className="text-xs text-destructive">
|
||||
{accessError}
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<SectionHeading>{t.sectionInterface}</SectionHeading>
|
||||
<div className="divide-y divide-border">
|
||||
<Row label={t.theme} description={t.themeDescription}>
|
||||
<Segmented<ThemePreference>
|
||||
label={t.theme}
|
||||
value={theme}
|
||||
onChange={setTheme}
|
||||
options={[
|
||||
{
|
||||
value: "light",
|
||||
label: t.themeLight,
|
||||
icon: <Sun />,
|
||||
},
|
||||
{
|
||||
value: "dark",
|
||||
label: t.themeDarkMode,
|
||||
icon: <Moon />,
|
||||
},
|
||||
{
|
||||
value: "system",
|
||||
label: t.themeSystem,
|
||||
icon: <Monitor />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
<Row label={t.sendShortcut} description={t.sendShortcutDescription}>
|
||||
<Select
|
||||
value={settings.sendShortcut}
|
||||
onValueChange={(value) =>
|
||||
settings.setSendShortcut(value as SendShortcut)
|
||||
}
|
||||
<Row
|
||||
label={t.language}
|
||||
description={t.languageDescription}
|
||||
htmlFor="language-select"
|
||||
>
|
||||
<SelectTrigger
|
||||
id="send-shortcut-select"
|
||||
className="h-8 w-auto rounded-lg"
|
||||
<Select
|
||||
value={currentLang}
|
||||
onValueChange={changeLanguage}
|
||||
// Not while an answer runs: the page mounts anew
|
||||
disabled={engine.isBusy}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="enter">{t.enterToSend}</SelectItem>
|
||||
<SelectItem value="ctrl-enter">
|
||||
{t.ctrlEnterToSend}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SelectTrigger
|
||||
id="language-select"
|
||||
className="h-8 w-[132px] rounded-lg"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{i18n.locales.map((locale) => (
|
||||
<SelectItem key={locale} value={locale}>
|
||||
{LANGUAGE_LABELS[locale]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Row>
|
||||
<Row
|
||||
label={t.sendShortcut}
|
||||
description={t.sendShortcutDescription}
|
||||
htmlFor="send-shortcut-select"
|
||||
>
|
||||
<Select
|
||||
value={sendShortcut}
|
||||
onValueChange={(value) =>
|
||||
setSendShortcut(value as SendShortcut)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="send-shortcut-select"
|
||||
className="h-8 w-auto rounded-lg"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="enter">
|
||||
{t.enterToSend}
|
||||
</SelectItem>
|
||||
<SelectItem value="ctrl-enter">
|
||||
{t.ctrlEnterToSend}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Row>
|
||||
</div>
|
||||
{(accessCodeRequired || isElectron) && (
|
||||
<>
|
||||
<SectionHeading>{t.sectionAccess}</SectionHeading>
|
||||
<div className="divide-y divide-border">
|
||||
{accessCodeRequired && (
|
||||
<Row
|
||||
label={t.accessCode}
|
||||
description={t.accessCodeDescription}
|
||||
htmlFor="access-code"
|
||||
stacked
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="access-code"
|
||||
type="password"
|
||||
value={accessCode}
|
||||
onChange={(e) =>
|
||||
setAccessCode(e.target.value)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
saveAccessCode()
|
||||
}
|
||||
}}
|
||||
placeholder={t.accessCodePlaceholder}
|
||||
autoComplete="off"
|
||||
className="h-9"
|
||||
/>
|
||||
<Button
|
||||
onClick={saveAccessCode}
|
||||
disabled={
|
||||
isVerifying || !accessCode.trim()
|
||||
}
|
||||
className="h-9 rounded-lg px-4"
|
||||
>
|
||||
{isVerifying ? "…" : dict.common.save}
|
||||
</Button>
|
||||
</div>
|
||||
{accessError && (
|
||||
<p className="text-xs text-destructive">
|
||||
{accessError}
|
||||
</p>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
{isElectron && (
|
||||
<Row
|
||||
label={t.proxy}
|
||||
description={t.proxyDescription}
|
||||
stacked
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="http-proxy"
|
||||
value={httpProxy}
|
||||
onChange={(e) =>
|
||||
setHttpProxy(e.target.value)
|
||||
}
|
||||
placeholder={`${t.httpProxy}: http://proxy:8080`}
|
||||
className="h-9"
|
||||
/>
|
||||
<Input
|
||||
id="https-proxy"
|
||||
value={httpsProxy}
|
||||
onChange={(e) =>
|
||||
setHttpsProxy(e.target.value)
|
||||
}
|
||||
placeholder={`${t.httpsProxy}: http://proxy:8080`}
|
||||
className="h-9"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={applyProxy}
|
||||
disabled={isApplyingProxy}
|
||||
className="h-9 rounded-lg px-4"
|
||||
>
|
||||
{isApplyingProxy ? "…" : t.applyProxy}
|
||||
</Button>
|
||||
</div>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawingTab() {
|
||||
const dict = useDictionary()
|
||||
const t = dict.settings
|
||||
const settings = useSettingsStore()
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
<Row label={t.diagramStyle} description={t.diagramStyleDescription}>
|
||||
<Segmented<"styled" | "minimal">
|
||||
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 },
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
label={t.customSystemMessage}
|
||||
description={t.customSystemMessageDescription}
|
||||
htmlFor="custom-system-message"
|
||||
stacked
|
||||
>
|
||||
<Textarea
|
||||
id="custom-system-message"
|
||||
value={settings.customSystemMessage}
|
||||
onChange={(e) =>
|
||||
settings.setCustomSystemMessage(e.target.value)
|
||||
}
|
||||
placeholder={t.customSystemMessagePlaceholder}
|
||||
className="max-h-[180px] min-h-[96px] text-sm"
|
||||
maxLength={5000}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
label={t.diagramValidation}
|
||||
@@ -408,50 +500,6 @@ function AdvancedTab({ open }: { open: boolean }) {
|
||||
className="h-8 w-28 text-sm"
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
label={t.customSystemMessage}
|
||||
description={t.customSystemMessageDescription}
|
||||
htmlFor="custom-system-message"
|
||||
stacked
|
||||
>
|
||||
<Textarea
|
||||
id="custom-system-message"
|
||||
value={settings.customSystemMessage}
|
||||
onChange={(e) =>
|
||||
settings.setCustomSystemMessage(e.target.value)
|
||||
}
|
||||
placeholder={t.customSystemMessagePlaceholder}
|
||||
className="max-h-[180px] min-h-[96px] text-sm"
|
||||
maxLength={5000}
|
||||
/>
|
||||
</Row>
|
||||
{isElectron && (
|
||||
<Row label={t.proxy} description={t.proxyDescription} stacked>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
id="http-proxy"
|
||||
value={httpProxy}
|
||||
onChange={(e) => setHttpProxy(e.target.value)}
|
||||
placeholder={`${t.httpProxy}: http://proxy:8080`}
|
||||
className="h-9"
|
||||
/>
|
||||
<Input
|
||||
id="https-proxy"
|
||||
value={httpsProxy}
|
||||
onChange={(e) => setHttpsProxy(e.target.value)}
|
||||
placeholder={`${t.httpsProxy}: http://proxy:8080`}
|
||||
className="h-9"
|
||||
/>
|
||||
<Button
|
||||
onClick={applyProxy}
|
||||
disabled={isApplyingProxy}
|
||||
className="h-9 w-full rounded-lg"
|
||||
>
|
||||
{isApplyingProxy ? "…" : t.applyProxy}
|
||||
</Button>
|
||||
</div>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -465,7 +513,7 @@ function AboutTab() {
|
||||
const aboutPath = `/${lang}/about${lang === "zh" ? "/cn" : lang === "ja" ? "/ja" : ""}`
|
||||
|
||||
return (
|
||||
<div className="space-y-5 pt-1">
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<BrandMark className="size-10 rounded-xl" />
|
||||
<div>
|
||||
@@ -534,20 +582,20 @@ export function SettingsDialog() {
|
||||
const setTab = useUiStore((s) => s.setSettingsTab)
|
||||
|
||||
const tabs: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: "models", label: t.tabModels, icon: <KeyRound /> },
|
||||
{ id: "appearance", label: t.tabAppearance, icon: <Palette /> },
|
||||
{ id: "advanced", label: t.tabAdvanced, icon: <SlidersHorizontal /> },
|
||||
{ id: "models", label: t.tabModels, icon: <Sparkles /> },
|
||||
{ id: "general", label: t.tabGeneral, icon: <Settings2 /> },
|
||||
{ id: "drawing", label: t.tabDrawing, icon: <PenTool /> },
|
||||
{ id: "about", label: t.tabAbout, icon: <Info /> },
|
||||
]
|
||||
const current = tabs.find((item) => item.id === tab) ?? tabs[0]
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="flex h-[min(640px,88vh)] max-w-[calc(100%-1.5rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl lg:flex-row">
|
||||
{/* On top below 1024 px: beside the model settings' own
|
||||
provider list it would leave too little width */}
|
||||
<nav className="flex shrink-0 gap-1 overflow-x-auto border-b border-border bg-surface-1 p-2 lg:w-52 lg:flex-col lg:overflow-visible lg:border-r lg:border-b-0 lg:p-3">
|
||||
<DialogTitle className="hidden px-2 pt-1 pb-3 text-[15px] lg:block">
|
||||
<DialogContent className="flex h-[min(640px,88vh)] max-w-[calc(100%-1.5rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl md:flex-row">
|
||||
{/* On top below 768 px, where beside the page it would leave
|
||||
too little width */}
|
||||
<nav className="flex shrink-0 gap-1 overflow-x-auto border-b border-border bg-surface-1 p-2 md:w-48 md:flex-col md:overflow-visible md:border-r md:border-b-0 md:p-3">
|
||||
<DialogTitle className="hidden px-2 pt-1 pb-3 text-[15px] md:block">
|
||||
{t.title}
|
||||
</DialogTitle>
|
||||
{tabs.map((item) => (
|
||||
@@ -557,9 +605,12 @@ export function SettingsDialog() {
|
||||
onClick={() => setTab(item.id)}
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center gap-2 rounded-lg px-2.5 text-[13px] text-muted-foreground hover:text-foreground [&_svg]:size-4",
|
||||
tab === item.id &&
|
||||
current.id === item.id &&
|
||||
"bg-card font-medium text-foreground shadow-float",
|
||||
)}
|
||||
aria-current={
|
||||
current.id === item.id ? "page" : undefined
|
||||
}
|
||||
data-testid={`settings-tab-${item.id}`}
|
||||
>
|
||||
{item.icon}
|
||||
@@ -571,20 +622,26 @@ export function SettingsDialog() {
|
||||
<DialogDescription className="sr-only">
|
||||
{t.description}
|
||||
</DialogDescription>
|
||||
{tab === "models" ? (
|
||||
{current.id === "models" ? (
|
||||
<ModelConfigDialog
|
||||
open={open}
|
||||
modelConfig={engine.modelConfig}
|
||||
/>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 pt-5 pb-6 scrollbar-thin">
|
||||
<h2 className="mb-2 text-[15px] font-semibold">
|
||||
{current.label}
|
||||
</h2>
|
||||
{tab === "appearance" && <AppearanceTab />}
|
||||
{tab === "advanced" && <AdvancedTab open={open} />}
|
||||
{tab === "about" && <AboutTab />}
|
||||
</div>
|
||||
<>
|
||||
<SettingsHeader>
|
||||
<h2 className="font-semibold">
|
||||
{current.label}
|
||||
</h2>
|
||||
</SettingsHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6 scrollbar-thin">
|
||||
{current.id === "general" && (
|
||||
<GeneralTab open={open} />
|
||||
)}
|
||||
{current.id === "drawing" && <DrawingTab />}
|
||||
{current.id === "about" && <AboutTab />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</DialogContent>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { ChevronRight, Search } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { ProviderLogo } from "@/components/provider-logo"
|
||||
import { SettingsHeader } from "@/components/settings/settings-header"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { filterProviderGroups, PROVIDER_NOTE_KEYS } from "@/lib/provider-setup"
|
||||
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
|
||||
|
||||
/** Every provider in groups, with a search; picking one adds it */
|
||||
export function ProviderPicker({
|
||||
added,
|
||||
onPick,
|
||||
onBack,
|
||||
}: {
|
||||
/** Provider types the user has already added */
|
||||
added: ProviderName[]
|
||||
onPick: (provider: ProviderName) => void
|
||||
onBack: () => void
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const t = dict.modelConfig
|
||||
const [query, setQuery] = useState("")
|
||||
const groups = filterProviderGroups(query)
|
||||
const groupLabels: Record<string, string> = {
|
||||
vendors: t.groupVendors,
|
||||
aggregators: t.groupAggregators,
|
||||
cloud: t.groupCloud,
|
||||
selfHosted: t.groupSelfHosted,
|
||||
}
|
||||
const notes = t as Record<string, string>
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<SettingsHeader>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="rounded text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t.models}
|
||||
</button>
|
||||
<ChevronRight className="size-3.5 text-muted-foreground/60" />
|
||||
<h2
|
||||
className="font-semibold outline-none"
|
||||
tabIndex={-1}
|
||||
data-page-title
|
||||
>
|
||||
{t.addProvider}
|
||||
</h2>
|
||||
</SettingsHeader>
|
||||
<div className="px-6 pb-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
// The page is opened to search it
|
||||
autoFocus
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Enter that confirms an IME candidate picks nothing
|
||||
if (
|
||||
e.nativeEvent.isComposing ||
|
||||
e.keyCode === 229
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Enter picks the first match
|
||||
const first = groups[0]?.providers[0]
|
||||
if (e.key === "Enter" && query.trim() && first) {
|
||||
e.preventDefault()
|
||||
onPick(first)
|
||||
}
|
||||
}}
|
||||
placeholder={t.searchProviders}
|
||||
aria-label={t.searchProviders}
|
||||
className="h-10 w-full rounded-xl border border-border bg-background pr-3 pl-9 text-[13px] outline-none placeholder:text-muted-foreground focus-visible:border-foreground/40 focus-visible:ring-2 focus-visible:ring-ring/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="px-6 pb-6">
|
||||
{groups.length === 0 && (
|
||||
<p className="py-8 text-center text-[13px] text-muted-foreground">
|
||||
{t.noProvidersFound}
|
||||
</p>
|
||||
)}
|
||||
{groups.map((group) => (
|
||||
<section key={group.id} className="mb-3.5">
|
||||
<h3 className="mb-1.5 text-[11px] font-medium text-muted-foreground">
|
||||
{groupLabels[group.id]}
|
||||
</h3>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(148px,1fr))] gap-2">
|
||||
{group.providers.map((provider) => {
|
||||
const noteKey = PROVIDER_NOTE_KEYS[provider]
|
||||
const note = [
|
||||
noteKey && notes[noteKey],
|
||||
added.includes(provider) && t.added,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
return (
|
||||
<button
|
||||
key={provider}
|
||||
type="button"
|
||||
onClick={() => onPick(provider)}
|
||||
data-testid={`pick-provider-${provider}`}
|
||||
className="flex h-[46px] min-w-0 items-center gap-2.5 rounded-xl border border-border px-3 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<ProviderLogo
|
||||
provider={provider}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="min-w-0 leading-tight">
|
||||
<span
|
||||
className="block truncate text-[12.5px] font-medium"
|
||||
title={
|
||||
PROVIDER_INFO[provider]
|
||||
.label
|
||||
}
|
||||
>
|
||||
{
|
||||
PROVIDER_INFO[provider]
|
||||
.label
|
||||
}
|
||||
</span>
|
||||
{note && (
|
||||
<span className="block truncate text-[11px] text-muted-foreground">
|
||||
{note}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
CircleDashed,
|
||||
CircleDotDashed,
|
||||
Lock,
|
||||
Plus,
|
||||
Server,
|
||||
} from "lucide-react"
|
||||
import { shortModelName } from "@/components/model-selector"
|
||||
import { ProviderLogo } from "@/components/provider-logo"
|
||||
import { SettingsHeader } from "@/components/settings/settings-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { POPULAR_PROVIDERS, providerStatus } from "@/lib/provider-setup"
|
||||
import {
|
||||
type FlattenedModel,
|
||||
PROVIDER_INFO,
|
||||
type ProviderConfig,
|
||||
type ProviderName,
|
||||
} from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function InUse() {
|
||||
const dict = useDictionary()
|
||||
return (
|
||||
<span className="rounded bg-primary px-1.5 py-0.5 text-[11px] font-medium text-primary-foreground">
|
||||
{dict.modelConfig.inUse}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** The models tab's first page: the user's providers and the server's models */
|
||||
export function ProvidersList({
|
||||
providers,
|
||||
serverModels,
|
||||
selectedModelId,
|
||||
onOpen,
|
||||
onAdd,
|
||||
onBrowse,
|
||||
}: {
|
||||
providers: ProviderConfig[]
|
||||
serverModels: FlattenedModel[]
|
||||
selectedModelId: string | undefined
|
||||
onOpen: (providerId: string) => void
|
||||
onAdd: (provider: ProviderName) => void
|
||||
/** Shows every provider to pick from */
|
||||
onBrowse: () => void
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const t = dict.modelConfig
|
||||
const userProviderInUse = providers.find((p) =>
|
||||
p.models.some((m) => m.id === selectedModelId),
|
||||
)
|
||||
// No model chosen is the server's default model
|
||||
const serverInUse =
|
||||
!selectedModelId || serverModels.some((m) => m.id === selectedModelId)
|
||||
|
||||
const names = serverModels
|
||||
.slice(0, 3)
|
||||
.map((m) => shortModelName(m.modelId))
|
||||
.join(t.listSeparator)
|
||||
const serverCard = (serverModels.length > 0 || serverInUse) && (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border-subtle bg-surface-1 px-4 py-3">
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border-subtle bg-surface-0">
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-[13px] font-medium">
|
||||
{t.serverModelsTitle}
|
||||
{serverInUse && <InUse />}
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{serverModels.length > 0
|
||||
? formatMessage(
|
||||
serverModels.length === 1
|
||||
? t.serverModelsSummaryOne
|
||||
: t.serverModelsSummaryOther,
|
||||
{
|
||||
count: serverModels.length,
|
||||
names:
|
||||
serverModels.length > 3
|
||||
? `${names}…`
|
||||
: names,
|
||||
},
|
||||
)
|
||||
: t.serverDefaultSummary}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const statusText = (p: ProviderConfig) => {
|
||||
const status = providerStatus(p)
|
||||
switch (status.kind) {
|
||||
case "error":
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-destructive">
|
||||
<AlertCircle className="size-3.5" />
|
||||
{formatMessage(t.statusError, { count: status.count })}
|
||||
</span>
|
||||
)
|
||||
case "incomplete":
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-amber-700 dark:text-amber-400">
|
||||
<CircleDotDashed className="size-3.5" />
|
||||
{t.statusIncomplete}
|
||||
</span>
|
||||
)
|
||||
case "untested":
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
<CircleDashed className="size-3.5" />
|
||||
{formatMessage(t.statusUntested, {
|
||||
count: status.count,
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
case "ok":
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-success">
|
||||
<CheckCircle2 className="size-3.5" />
|
||||
{formatMessage(t.statusOk, { count: status.count })}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<SettingsHeader>
|
||||
<h2
|
||||
className="font-semibold outline-none"
|
||||
tabIndex={-1}
|
||||
data-page-title
|
||||
>
|
||||
{t.models}
|
||||
</h2>
|
||||
</SettingsHeader>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-6 px-6 pb-6">
|
||||
{providers.length === 0 ? (
|
||||
<>
|
||||
{serverCard}
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-[13px] font-semibold">
|
||||
{t.useOwnKey}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t.useOwnKeyHint}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(170px,1fr))] gap-2.5">
|
||||
{POPULAR_PROVIDERS.map((provider) => (
|
||||
<button
|
||||
key={provider}
|
||||
type="button"
|
||||
onClick={() => onAdd(provider)}
|
||||
data-testid={`pick-provider-${provider}`}
|
||||
className="flex h-14 items-center gap-3 rounded-xl border border-border px-4 text-left text-[13px] font-medium transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-surface-2">
|
||||
<ProviderLogo
|
||||
provider={provider}
|
||||
/>
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{PROVIDER_INFO[provider].label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBrowse}
|
||||
className="flex items-center gap-1 text-[13px] font-medium hover:underline"
|
||||
>
|
||||
{formatMessage(t.viewAllProviders, {
|
||||
count: Object.keys(PROVIDER_INFO)
|
||||
.length,
|
||||
})}
|
||||
<ChevronRight className="size-3.5" />
|
||||
</button>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<section className="space-y-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-[13px] font-semibold">
|
||||
{t.myProviders}
|
||||
</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onBrowse}
|
||||
className="h-8 gap-1.5 rounded-lg text-xs"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
{t.addProvider}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="divide-y divide-border-subtle rounded-xl border border-border">
|
||||
{providers.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => onOpen(p.id)}
|
||||
data-testid={`provider-row-${p.id}`}
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full items-center gap-3 px-4 py-2.5 text-left transition-colors first:rounded-t-xl last:rounded-b-xl",
|
||||
"hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-surface-2">
|
||||
<ProviderLogo
|
||||
provider={p.provider}
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-2 text-[13px] font-medium">
|
||||
<span className="truncate">
|
||||
{p.name ||
|
||||
PROVIDER_INFO[
|
||||
p.provider
|
||||
].label}
|
||||
</span>
|
||||
{userProviderInUse?.id ===
|
||||
p.id && <InUse />}
|
||||
</span>
|
||||
<span className="block truncate font-mono text-xs text-muted-foreground">
|
||||
{p.models.length > 0 ? (
|
||||
p.models
|
||||
.map(
|
||||
(m) =>
|
||||
m.modelId,
|
||||
)
|
||||
.join(
|
||||
t.listSeparator,
|
||||
)
|
||||
) : (
|
||||
<span className="font-sans">
|
||||
{t.noModelsYet}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-xs">
|
||||
{statusText(p)}
|
||||
</span>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground/60" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{serverCard}
|
||||
</>
|
||||
)}
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Lock className="size-3.5 shrink-0" />
|
||||
{t.keysStayLocal}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** A settings page's title row; the dialog's close button sits at its end */
|
||||
export function SettingsHeader({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<header className="flex h-14 shrink-0 items-center gap-1.5 pr-14 pl-6 text-[15px]">
|
||||
{children}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user