From a24a10263c7212cbe25426abdaa4f18258710a04 Mon Sep 17 00:00:00 2001 From: bzp2010 Date: Sat, 25 Apr 2026 02:12:02 +0800 Subject: [PATCH] feat(ui): add provider management Co-authored-by: Copilot --- ui/package.json | 1 + ui/pnpm-lock.yaml | 58 ++ ui/src/components/layout/sidebar.tsx | 2 + ui/src/components/models/model-form.tsx | 516 ++++++++---------- ui/src/components/providers/provider-form.tsx | 443 +++++++++++++++ ui/src/components/ui/button.tsx | 58 +- ui/src/components/ui/combobox.tsx | 299 ++++++++++ ui/src/components/ui/input-group.tsx | 154 ++++++ ui/src/components/ui/input.tsx | 14 +- ui/src/components/ui/textarea.tsx | 14 +- ui/src/i18n/locales/en.json | 79 ++- ui/src/i18n/locales/zh-CN.json | 89 ++- ui/src/lib/api/client.ts | 19 + ui/src/lib/api/types.ts | 57 +- ui/src/lib/queries/providers.ts | 73 +++ ui/src/routeTree.gen.ts | 63 +++ ui/src/routes/_layout/providers/$id.tsx | 98 ++++ ui/src/routes/_layout/providers/create.tsx | 61 +++ ui/src/routes/_layout/providers/index.tsx | 214 ++++++++ ui/tsconfig.json | 13 +- ui/vite.config.ts | 1 + 21 files changed, 1955 insertions(+), 371 deletions(-) create mode 100644 ui/src/components/providers/provider-form.tsx create mode 100644 ui/src/components/ui/combobox.tsx create mode 100644 ui/src/components/ui/input-group.tsx create mode 100644 ui/src/lib/queries/providers.ts create mode 100644 ui/src/routes/_layout/providers/$id.tsx create mode 100644 ui/src/routes/_layout/providers/create.tsx create mode 100644 ui/src/routes/_layout/providers/index.tsx diff --git a/ui/package.json b/ui/package.json index 9a76122..868a8e7 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,6 +12,7 @@ "preview": "vite preview" }, "dependencies": { + "@base-ui/react": "^1.4.1", "@fontsource-variable/roboto": "^5.2.10", "@monaco-editor/react": "^4.7.0", "@tailwindcss/vite": "^4.2.2", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 1f88db9..7360b7f 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@base-ui/react': + specifier: ^1.4.1 + version: 1.4.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@fontsource-variable/roboto': specifier: ^5.2.10 version: 5.2.10 @@ -267,6 +270,33 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@base-ui/react@1.4.1': + resolution: {integrity: sha512-Ab5/LIhcmL8BQcsBUYiOfkSDRdLpvgUBzMK30cu684JPcLclYlztharvCZyNNgzJtbAiREzI9q0pI5erHCMgCw==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@date-fns/tz': ^1.2.0 + '@types/react': ^17 || ^18 || ^19 + date-fns: ^4.0.0 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@date-fns/tz': + optional: true + '@types/react': + optional: true + date-fns: + optional: true + + '@base-ui/utils@0.2.8': + resolution: {integrity: sha512-jvOi+c+ftGlGotNcKnzPVg2IhCaDTB6/6R3JeqdjdXktuAJi3wKH9T7+svuaKh1mmfVU11UWzUZVH74JDfi/wQ==} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@dotenvx/dotenvx@1.54.1': resolution: {integrity: sha512-41gU3q7v05GM92QPuPUf4CmUw+mmF8p4wLUh6MCRlxpCkJ9ByLcY9jUf6MwrMNmiKyG/rIckNxj9SCfmNCmCqw==} hasBin: true @@ -3109,6 +3139,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3751,6 +3784,29 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@base-ui/react@1.4.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.2 + '@base-ui/utils': 0.2.8(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/utils': 0.2.11 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + '@base-ui/utils@0.2.8(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.2 + '@floating-ui/utils': 0.2.11 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + reselect: 5.1.1 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@dotenvx/dotenvx@1.54.1': dependencies: commander: 11.1.0 @@ -6438,6 +6494,8 @@ snapshots: require-from-string@2.0.2: {} + reselect@5.1.1: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} diff --git a/ui/src/components/layout/sidebar.tsx b/ui/src/components/layout/sidebar.tsx index 224c557..8823d58 100644 --- a/ui/src/components/layout/sidebar.tsx +++ b/ui/src/components/layout/sidebar.tsx @@ -8,6 +8,7 @@ import { Monitor, Moon, Settings, + Server, Sun, Zap, } from 'lucide-react'; @@ -23,6 +24,7 @@ const NAV_GROUPS = [ labelKey: 'nav.platform', items: [ { to: '/playground', labelKey: 'nav.playground', icon: LayoutDashboard }, + { to: '/providers', labelKey: 'nav.providers', icon: Server }, { to: '/models', labelKey: 'nav.models', icon: Boxes }, { to: '/apikeys', labelKey: 'nav.apiKeys', icon: KeyRound }, ], diff --git a/ui/src/components/models/model-form.tsx b/ui/src/components/models/model-form.tsx index c764a1a..ee11bbd 100644 --- a/ui/src/components/models/model-form.tsx +++ b/ui/src/components/models/model-form.tsx @@ -1,20 +1,33 @@ +import { Link } from '@tanstack/react-router'; import { useForm } from '@tanstack/react-form'; +import { Plus, RefreshCw } from 'lucide-react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxTrigger, +} from '@/components/ui/combobox'; import { Input } from '@/components/ui/input'; +import { + InputGroupAddon, + InputGroupButton, + InputGroupText, +} from '@/components/ui/input-group'; import { Label } from '@/components/ui/label'; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; import type { Model } from '@/lib/api/types'; - -// ── Types ───────────────────────────────────────────────────────────────────── +import { useProviders } from '@/lib/queries/providers'; export interface ModelFormProps { initial?: Model; @@ -26,27 +39,6 @@ export interface ModelFormProps { extraActions?: React.ReactNode; } -type ProviderId = 'anthropic' | 'bedrock' | 'deepseek' | 'gemini' | 'openai'; -type ProviderSelection = ProviderId | ''; - -type ProviderConfigValues = Record; - -interface ProviderConfigFieldSchema { - type: 'string'; - titleKey: string; - descriptionKey?: string; - placeholder?: string; - placeholderKey?: string; - inputType?: React.HTMLInputTypeAttribute; -} - -interface ProviderConfigSchema { - required: string[]; - properties: Record; -} - -// ── Constants ───────────────────────────────────────────────────────────────── - const RATE_LIMIT_FIELDS = [ { name: 'tpm' as const, @@ -75,135 +67,6 @@ const RATE_LIMIT_FIELDS = [ }, ]; -const PROVIDER_OPTIONS: Array<{ value: ProviderId; labelKey: string }> = [ - { value: 'openai', labelKey: 'models.form.providers.openai' }, - { value: 'anthropic', labelKey: 'models.form.providers.anthropic' }, - { value: 'gemini', labelKey: 'models.form.providers.gemini' }, - { value: 'deepseek', labelKey: 'models.form.providers.deepseek' }, - { value: 'bedrock', labelKey: 'models.form.providers.bedrock' }, -]; - -const OPENAI_COMPATIBLE_CONFIG_SCHEMA: ProviderConfigSchema = { - required: ['api_key'], - properties: { - api_key: { - type: 'string', - titleKey: 'models.form.apiKeyLabel', - placeholder: 'sk-…', - inputType: 'password', - }, - api_base: { - type: 'string', - titleKey: 'models.form.apiBase', - descriptionKey: 'models.form.apiBaseHint', - placeholderKey: 'models.form.apiBasePlaceholder', - inputType: 'url', - }, - }, -}; - -const BEDROCK_CONFIG_SCHEMA: ProviderConfigSchema = { - required: ['region', 'access_key_id', 'secret_access_key'], - properties: { - region: { - type: 'string', - titleKey: 'models.form.regionLabel', - descriptionKey: 'models.form.regionHint', - placeholder: 'us-east-1', - }, - access_key_id: { - type: 'string', - titleKey: 'models.form.accessKeyIdLabel', - placeholder: 'AKIA...', - }, - secret_access_key: { - type: 'string', - titleKey: 'models.form.secretAccessKeyLabel', - inputType: 'password', - placeholderKey: 'models.form.secretAccessKeyPlaceholder', - }, - session_token: { - type: 'string', - titleKey: 'models.form.sessionTokenLabel', - descriptionKey: 'models.form.sessionTokenHint', - inputType: 'password', - placeholderKey: 'models.form.sessionTokenPlaceholder', - }, - endpoint: { - type: 'string', - titleKey: 'models.form.endpointLabel', - descriptionKey: 'models.form.endpointHint', - inputType: 'url', - placeholder: 'https://bedrock-runtime.us-east-1.amazonaws.com', - }, - }, -}; - -const PROVIDER_CONFIG_SCHEMAS: Record = { - anthropic: OPENAI_COMPATIBLE_CONFIG_SCHEMA, - bedrock: BEDROCK_CONFIG_SCHEMA, - deepseek: OPENAI_COMPATIBLE_CONFIG_SCHEMA, - gemini: OPENAI_COMPATIBLE_CONFIG_SCHEMA, - openai: OPENAI_COMPATIBLE_CONFIG_SCHEMA, -}; - -function isProviderId(value: string): value is ProviderId { - return PROVIDER_OPTIONS.some((option) => option.value === value); -} - -function splitModelIdentifier(model: string | undefined): { - provider: ProviderSelection; - providerModel: string; -} { - if (!model) { - return { provider: '', providerModel: '' }; - } - - const separatorIndex = model.indexOf('/'); - if (separatorIndex === -1) { - return { provider: '', providerModel: model }; - } - - const provider = model.slice(0, separatorIndex).toLowerCase(); - const providerModel = model.slice(separatorIndex + 1); - if (!isProviderId(provider) || !providerModel) { - return { provider: '', providerModel: model }; - } - - return { provider, providerModel }; -} - -function normalizeProviderConfigValues( - provider: ProviderId, - source: Model['provider_config'] | ProviderConfigValues | undefined, -): ProviderConfigValues { - const schema = PROVIDER_CONFIG_SCHEMAS[provider]; - const objectSource = - source && typeof source === 'object' - ? (source as Record) - : undefined; - - return Object.fromEntries( - Object.keys(schema.properties).map((fieldName) => { - const rawValue = objectSource?.[fieldName]; - return [fieldName, typeof rawValue === 'string' ? rawValue : '']; - }), - ); -} - -function serializeProviderConfig( - provider: ProviderId, - values: ProviderConfigValues, -): Model['provider_config'] { - const schema = PROVIDER_CONFIG_SCHEMAS[provider]; - - return Object.fromEntries( - Object.keys(schema.properties) - .map((fieldName) => [fieldName, values[fieldName]?.trim() ?? '']) - .filter(([, value]) => value.length > 0), - ); -} - function parseOptionalNonNegativeInteger(raw: string): number | undefined { const trimmed = raw.trim(); if (!trimmed) { @@ -218,8 +81,6 @@ function parseOptionalNonNegativeInteger(raw: string): number | undefined { return parsed; } -// ── Component ───────────────────────────────────────────────────────────────── - export function ModelForm({ initial, onSubmit, @@ -230,31 +91,22 @@ export function ModelForm({ extraActions, }: ModelFormProps) { const { t } = useTranslation(); - const initialModel = splitModelIdentifier(initial?.model); - const initialProviderConfigValues = initialModel.provider - ? normalizeProviderConfigValues( - initialModel.provider, - initial?.provider_config, - ) - : {}; - const [provider, setProvider] = useState( - initialModel.provider, - ); - const [providerConfigDrafts, setProviderConfigDrafts] = useState< - Partial> - >(() => - initialModel.provider - ? { [initialModel.provider]: initialProviderConfigValues } - : {}, - ); - const [providerConfigValues, setProviderConfigValues] = - useState(initialProviderConfigValues); const [clientError, setClientError] = useState(); + const providersQuery = useProviders(); + const providerOptions = (providersQuery.data?.list ?? []).map( + ({ key, value }) => ({ + value: key.replace('/providers/', ''), + label: key.replace('/providers/', ''), + name: value.name, + type: value.type, + }), + ); const form = useForm({ defaultValues: { name: initial?.name ?? '', - model: initialModel.providerModel, + provider_id: initial?.provider_id ?? '', + model: initial?.model ?? '', timeout: initial?.timeout != null ? String(initial.timeout) : '', tpm: initial?.rate_limit?.tpm != null ? String(initial.rate_limit.tpm) : '', @@ -270,7 +122,8 @@ export function ModelForm({ : '', }, onSubmit: async ({ value }) => { - if (!provider) { + const trimmedProviderId = value.provider_id.trim(); + if (!trimmedProviderId) { setClientError(t('models.form.providerRequired')); return; } @@ -297,13 +150,11 @@ export function ModelForm({ ...(rpd != null ? { rpd } : {}), ...(concurrency != null ? { concurrency } : {}), }; + const payload: Model = { name: value.name.trim(), - model: `${provider}/${trimmedModel}`, - provider_config: serializeProviderConfig( - provider, - providerConfigValues, - ), + provider_id: trimmedProviderId, + model: trimmedModel, ...(timeout != null ? { timeout } : {}), ...(Object.keys(rateLimit).length > 0 ? { rate_limit: rateLimit } : {}), }; @@ -311,52 +162,6 @@ export function ModelForm({ }, }); - const providerConfigSchema = provider - ? PROVIDER_CONFIG_SCHEMAS[provider] - : undefined; - - function handleProviderChange(nextProvider: string) { - if (!isProviderId(nextProvider)) { - return; - } - - const nextDrafts = provider - ? { - ...providerConfigDrafts, - [provider]: { ...providerConfigValues }, - } - : providerConfigDrafts; - const nextProviderDraft = nextDrafts[nextProvider]; - - setProviderConfigDrafts(nextDrafts); - setProvider(nextProvider); - setProviderConfigValues( - normalizeProviderConfigValues(nextProvider, nextProviderDraft), - ); - setClientError(undefined); - } - - function handleProviderConfigFieldChange( - fieldName: string, - nextValue: string, - ) { - setProviderConfigValues((current) => { - const nextValues = { - ...current, - [fieldName]: nextValue, - }; - - if (provider) { - setProviderConfigDrafts((currentDrafts) => ({ - ...currentDrafts, - [provider]: nextValues, - })); - } - - return nextValues; - }); - } - return (
{ @@ -366,14 +171,16 @@ export function ModelForm({ }} className="space-y-5" > - {/* Basic */}

{t('models.form.basicInfo')}

{(field) => ( - + - - - + + {(field) => { + const selectedProvider = providerOptions.find( + (provider) => provider.value === field.state.value.trim(), + ); + + let providerHint = t('models.form.providerSearchHint'); + if (providersQuery.isLoading) { + providerHint = t('models.form.providerLoading'); + } else if (providersQuery.isError) { + providerHint = t('models.form.providerLoadError'); + } else if (providerOptions.length === 0) { + providerHint = t('models.form.providerEmpty'); + } else if (selectedProvider) { + providerHint = t('models.form.providerSelectedHint', { + name: selectedProvider.name, + type: selectedProvider.type, + }); + } + + return ( + + provider.label} + itemToStringValue={(provider) => provider.value} + value={selectedProvider ?? null} + inputValue={field.state.value} + onValueChange={(provider) => { + setClientError(undefined); + field.handleChange(provider?.value ?? ''); + }} + onInputValueChange={(inputValue) => { + setClientError(undefined); + field.handleChange(inputValue); + }} + autoHighlight + > + + + {selectedProvider && ( + + {selectedProvider.name} · {selectedProvider.type} + + )} + + + + + + + + +

{t('models.form.toggleProviderOptions')}

+
+
+ + + + { + void providersQuery.refetch(); + }} + disabled={providersQuery.isFetching} + aria-label={t('models.form.refreshProviders')} + > + + + + +

{t('models.form.refreshProviders')}

+
+
+ + + + + + + + + + +

{t('providers.addProvider')}

+
+
+
+
+ + + {providersQuery.isLoading ? ( +
+ {t('models.form.providerLoading')} +
+ ) : providersQuery.isError ? ( +
+ {t('models.form.providerLoadError')} +
+ ) : providerOptions.length === 0 ? ( +
+ {t('models.form.providerEmpty')} +
+ ) : ( + <> + + {t('models.form.providerNoMatch')} + + + {(provider) => ( + + + {provider.value} + + + {provider.name} · {provider.type} + + + )} + + + )} +
+
+
+ ); + }} +
{(field) => ( - +
- {/* Provider Config */} -
-

- {t('models.form.providerConfig')} -

- - {providerConfigSchema ? ( -
- {Object.entries(providerConfigSchema.properties).map( - ([fieldName, fieldSchema]) => ( - - - handleProviderConfigFieldChange(fieldName, e.target.value) - } - placeholder={ - fieldSchema.placeholderKey - ? t(fieldSchema.placeholderKey) - : fieldSchema.placeholder - } - autoComplete="off" - /> - - ), - )} -
- ) : ( -

- {t('models.form.providerConfigSelectHint')} -

- )} -
- - {/* Advanced */}

{t('models.form.advanced')} @@ -523,7 +434,6 @@ export function ModelForm({

)} - {/* Footer */}
{extraActions ?? }
@@ -543,19 +453,19 @@ export function ModelForm({ ); } -// ── Field wrapper ───────────────────────────────────────────────────────────── - function Field({ label, hint, + className, children, }: { label: string; hint?: string; + className?: string; children: React.ReactNode; }) { return ( -
+
{children} {hint &&

{hint}

} diff --git a/ui/src/components/providers/provider-form.tsx b/ui/src/components/providers/provider-form.tsx new file mode 100644 index 0000000..e82e98b --- /dev/null +++ b/ui/src/components/providers/provider-form.tsx @@ -0,0 +1,443 @@ +import { useForm } from '@tanstack/react-form'; +import { Eye, EyeOff } from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from '@/components/ui/input-group'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import type { Provider, ProviderType } from '@/lib/api/types'; + +export interface ProviderFormProps { + initial?: Provider; + onSubmit: (data: Provider) => void | Promise; + onCancel: () => void; + isPending: boolean; + error?: string; + submitLabel: string; + extraActions?: React.ReactNode; +} + +const PROVIDER_TYPES: ProviderType[] = [ + 'openai', + 'anthropic', + 'gemini', + 'deepseek', + 'bedrock', +]; + +function trimOptional(value: string): string | undefined { + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +export function ProviderForm({ + initial, + onSubmit, + onCancel, + isPending, + error, + submitLabel, + extraActions, +}: ProviderFormProps) { + const { t } = useTranslation(); + const [clientError, setClientError] = useState(); + + const form = useForm({ + defaultValues: { + name: initial?.name ?? '', + type: initial?.type ?? ('openai' as ProviderType), + api_key: + initial?.type !== 'bedrock' ? (initial?.config.api_key ?? '') : '', + api_base: + initial?.type !== 'bedrock' ? (initial?.config.api_base ?? '') : '', + region: initial?.type === 'bedrock' ? initial.config.region : '', + access_key_id: + initial?.type === 'bedrock' ? initial.config.access_key_id : '', + secret_access_key: + initial?.type === 'bedrock' ? initial.config.secret_access_key : '', + session_token: + initial?.type === 'bedrock' ? (initial.config.session_token ?? '') : '', + endpoint: + initial?.type === 'bedrock' ? (initial.config.endpoint ?? '') : '', + }, + onSubmit: async ({ value }) => { + const name = value.name.trim(); + if (!name) { + setClientError(t('providers.form.nameRequired')); + return; + } + + if (value.type === 'bedrock') { + const region = value.region.trim(); + const accessKeyId = value.access_key_id.trim(); + const secretAccessKey = value.secret_access_key.trim(); + + if (!region) { + setClientError(t('providers.form.regionRequired')); + return; + } + + if (!accessKeyId) { + setClientError(t('providers.form.accessKeyIdRequired')); + return; + } + + if (!secretAccessKey) { + setClientError(t('providers.form.secretAccessKeyRequired')); + return; + } + + setClientError(undefined); + await onSubmit({ + name, + type: 'bedrock', + config: { + region, + access_key_id: accessKeyId, + secret_access_key: secretAccessKey, + ...(trimOptional(value.session_token) + ? { session_token: trimOptional(value.session_token) } + : {}), + ...(trimOptional(value.endpoint) + ? { endpoint: trimOptional(value.endpoint) } + : {}), + }, + }); + return; + } + + const apiKey = value.api_key.trim(); + if (!apiKey) { + setClientError(t('providers.form.apiKeyRequired')); + return; + } + + setClientError(undefined); + await onSubmit({ + name, + type: value.type, + config: { + api_key: apiKey, + ...(trimOptional(value.api_base) + ? { api_base: trimOptional(value.api_base) } + : {}), + }, + }); + }, + }); + + return ( + { + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + className="space-y-5" + > +
+

+ {t('providers.form.basicInfo')} +

+ +
+ + {(field) => ( + + { + setClientError(undefined); + field.handleChange(e.target.value); + }} + onBlur={field.handleBlur} + placeholder={t('providers.form.namePlaceholder')} + /> + + )} + + + + {(field) => ( + + + + )} + +
+
+ + state.values.type}> + {(providerType) => ( +
+

+ {t('providers.form.providerConfig')} +

+ + {providerType === 'bedrock' ? ( +
+ + {(field) => ( + + { + setClientError(undefined); + field.handleChange(e.target.value); + }} + onBlur={field.handleBlur} + placeholder="us-east-1" + /> + + )} + + + + {(field) => ( + + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + placeholder="https://bedrock-runtime.us-east-1.amazonaws.com" + /> + + )} + + + + {(field) => ( + + { + setClientError(undefined); + field.handleChange(e.target.value); + }} + onBlur={field.handleBlur} + placeholder="AKIA..." + autoComplete="off" + /> + + )} + + + + {(field) => ( + + { + setClientError(undefined); + field.handleChange(e.target.value); + }} + onBlur={field.handleBlur} + placeholder={t( + 'providers.form.secretAccessKeyPlaceholder', + )} + autoComplete="new-password" + showLabel={t('providers.form.showSecret')} + hideLabel={t('providers.form.hideSecret')} + /> + + )} + + + + {(field) => ( + + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + placeholder={t( + 'providers.form.sessionTokenPlaceholder', + )} + autoComplete="off" + /> + + )} + +
+ ) : ( +
+ + {(field) => ( + + { + setClientError(undefined); + field.handleChange(e.target.value); + }} + onBlur={field.handleBlur} + placeholder="sk-..." + autoComplete="new-password" + showLabel={t('providers.form.showSecret')} + hideLabel={t('providers.form.hideSecret')} + /> + + )} + + + + {(field) => ( + + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + placeholder={t('providers.form.apiBasePlaceholder')} + /> + + )} + +
+ )} +
+ )} +
+ + {(clientError ?? error) && ( +

+ {clientError ?? error} +

+ )} + +
+ {extraActions ?? } +
+ + state.isSubmitting}> + {(isSubmitting) => ( + + )} + +
+
+ + ); +} + +function Field({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ); +} + +type SecretInputProps = Omit< + React.ComponentProps, + 'type' +> & { + showLabel: string; + hideLabel: string; +}; + +function SecretInput({ + showLabel, + hideLabel, + disabled, + ...props +}: SecretInputProps) { + const [isVisible, setIsVisible] = useState(false); + + return ( + + + + + + setIsVisible((visible) => !visible)} + > + {isVisible ? : } + + + +

{isVisible ? hideLabel : showLabel}

+
+
+
+
+ ); +} diff --git a/ui/src/components/ui/button.tsx b/ui/src/components/ui/button.tsx index 1b38df0..6138844 100644 --- a/ui/src/components/ui/button.tsx +++ b/ui/src/components/ui/button.tsx @@ -1,57 +1,57 @@ -import * as React from 'react'; -import { cva, type VariantProps } from 'class-variance-authority'; -import { Slot } from 'radix-ui'; +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" -import { cn } from '@/lib/utils'; +import { cn } from "@/lib/utils" const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { - default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", outline: - 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", secondary: - 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground', + "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", ghost: - 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50', + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", destructive: - 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40', - link: 'text-primary underline-offset-4 hover:underline', + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", }, size: { default: - 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', + "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", - lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3', - icon: 'size-8', - 'icon-xs': + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-8", + "icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", - 'icon-sm': - 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg', - 'icon-lg': 'size-9', + "icon-sm": + "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-9", }, }, defaultVariants: { - variant: 'default', - size: 'default', + variant: "default", + size: "default", }, - }, -); + } +) function Button({ className, - variant = 'default', - size = 'default', + variant = "default", + size = "default", asChild = false, ...props -}: React.ComponentProps<'button'> & +}: React.ComponentProps<"button"> & VariantProps & { - asChild?: boolean; + asChild?: boolean }) { - const Comp = asChild ? Slot.Root : 'button'; + const Comp = asChild ? Slot.Root : "button" return ( - ); + ) } -export { Button, buttonVariants }; +export { Button, buttonVariants } diff --git a/ui/src/components/ui/combobox.tsx b/ui/src/components/ui/combobox.tsx new file mode 100644 index 0000000..352afc3 --- /dev/null +++ b/ui/src/components/ui/combobox.tsx @@ -0,0 +1,299 @@ +"use client" + +import * as React from "react" +import { Combobox as ComboboxPrimitive } from "@base-ui/react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@/components/ui/input-group" +import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react" + +const Combobox = ComboboxPrimitive.Root + +function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) { + return +} + +function ComboboxTrigger({ + className, + children, + ...props +}: ComboboxPrimitive.Trigger.Props) { + return ( + + {children} + + + ) +} + +function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) { + return ( + } + className={cn(className)} + {...props} + > + + + ) +} + +function ComboboxInput({ + className, + children, + disabled = false, + showTrigger = true, + showClear = false, + ...props +}: ComboboxPrimitive.Input.Props & { + showTrigger?: boolean + showClear?: boolean +}) { + return ( + + } + {...props} + /> + + {showTrigger && ( + + + + )} + {showClear && } + + {children} + + ) +} + +function ComboboxContent({ + className, + side = "bottom", + sideOffset = 6, + align = "start", + alignOffset = 0, + anchor, + ...props +}: ComboboxPrimitive.Popup.Props & + Pick< + ComboboxPrimitive.Positioner.Props, + "side" | "align" | "sideOffset" | "alignOffset" | "anchor" + >) { + return ( + + + + + + ) +} + +function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) { + return ( + + ) +} + +function ComboboxItem({ + className, + children, + ...props +}: ComboboxPrimitive.Item.Props) { + return ( + + {children} + + } + > + + + + ) +} + +function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) { + return ( + + ) +} + +function ComboboxLabel({ + className, + ...props +}: ComboboxPrimitive.GroupLabel.Props) { + return ( + + ) +} + +function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) { + return ( + + ) +} + +function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) { + return ( + + ) +} + +function ComboboxSeparator({ + className, + ...props +}: ComboboxPrimitive.Separator.Props) { + return ( + + ) +} + +function ComboboxChips({ + className, + ...props +}: React.ComponentPropsWithRef & + ComboboxPrimitive.Chips.Props) { + return ( + + ) +} + +function ComboboxChip({ + className, + children, + showRemove = true, + ...props +}: ComboboxPrimitive.Chip.Props & { + showRemove?: boolean +}) { + return ( + + {children} + {showRemove && ( + } + className="-ml-1 opacity-50 hover:opacity-100" + data-slot="combobox-chip-remove" + > + + + )} + + ) +} + +function ComboboxChipsInput({ + className, + ...props +}: ComboboxPrimitive.Input.Props) { + return ( + + ) +} + +function useComboboxAnchor() { + return React.useRef(null) +} + +export { + Combobox, + ComboboxInput, + ComboboxContent, + ComboboxList, + ComboboxItem, + ComboboxGroup, + ComboboxLabel, + ComboboxCollection, + ComboboxEmpty, + ComboboxSeparator, + ComboboxChips, + ComboboxChip, + ComboboxChipsInput, + ComboboxTrigger, + ComboboxValue, + useComboboxAnchor, +} diff --git a/ui/src/components/ui/input-group.tsx b/ui/src/components/ui/input-group.tsx new file mode 100644 index 0000000..0475d27 --- /dev/null +++ b/ui/src/components/ui/input-group.tsx @@ -0,0 +1,154 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" + +function InputGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5", + className + )} + {...props} + /> + ) +} + +const inputGroupAddonVariants = cva( + "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4", + { + variants: { + align: { + "inline-start": + "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]", + "inline-end": + "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]", + "block-start": + "order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2", + "block-end": + "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2", + }, + }, + defaultVariants: { + align: "inline-start", + }, + } +) + +function InputGroupAddon({ + className, + align = "inline-start", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
{ + if ((e.target as HTMLElement).closest("button")) { + return + } + e.currentTarget.parentElement?.querySelector("input")?.focus() + }} + {...props} + /> + ) +} + +const inputGroupButtonVariants = cva( + "flex items-center gap-2 text-sm shadow-none", + { + variants: { + size: { + xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5", + sm: "", + "icon-xs": + "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0", + "icon-sm": "size-8 p-0 has-[>svg]:p-0", + }, + }, + defaultVariants: { + size: "xs", + }, + } +) + +function InputGroupButton({ + className, + type = "button", + variant = "ghost", + size = "xs", + ...props +}: Omit, "size"> & + VariantProps) { + return ( +