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:
@@ -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