Files
next-ai-draw-io/components/settings/provider-picker.tsx
T
dayuan.jiang f811242568 refactor(providers): the UI, the connection test and the routes read the record's fields
Step 5 of the provider refactor plan. Provider names gave way to the
record's credential and sdk fields in: hasCredentials (one rule per
credential kind), the connection test (an Object.hasOwn check first, then a
credential switch; EdgeOne's site endpoint and cookie forwarding follow
credential none), the chat route (the on-site function and the single-system
rule for the OpenAI SDK), the credentials form (which fields to show, the
optional-key label, the base URL hint from the record), the picker (the note
from the record), the logo (icon and logo fields), the settings dialog (test
blocker, error hints, EdgeOne origin), the admin panel (fixedCredentials),
chatRequestUrl (sdk), the model list (sdk aihubmix) and the zod schema
(Object.hasOwn instead of 'in'). The eight provider-specific dictionary
entries moved into the record as four-language objects; needAzure became a
generic needUrl.

Two intended behavior changes: the connection test answers 400 'Invalid
provider' for an unknown name whether or not a key is sent (before: 400
'API key is required' without a key, 200 with an error text with one); and
Azure has no placeholder default URL any more, so a new Azure entry starts
with an empty URL and the generic 'needs a URL' rule applies. The e2e
Azure test seeds no URL and expects the new wording. Out-of-layer
provider-name checks: 44 before, 6 after (the admin panel's fixed-variable
branches for Bedrock, Vertex AI and Ollama, kept on purpose). Behavior
snapshot unchanged.
2026-10-12 21:17:51 +09:00

141 lines
6.8 KiB
TypeScript

import { ChevronRight, Search } from "lucide-react"
import { useState } from "react"
import { useLocale } from "@/components/canvas/locale-context"
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 } from "@/lib/provider-setup"
import { PROVIDERS, type ProviderName } from "@/lib/providers"
/** 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 locale = useLocale()
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,
}
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 note = [
PROVIDERS[provider].note?.[locale],
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={
PROVIDERS[provider]
.label
}
>
{PROVIDERS[provider].label}
</span>
{note && (
<span className="block truncate text-[11px] text-muted-foreground">
{note}
</span>
)}
</span>
</button>
)
})}
</div>
</section>
))}
</div>
</ScrollArea>
</div>
)
}