From d6293e8111521d47321a3c21bd6bfb137015e70d Mon Sep 17 00:00:00 2001 From: CSCITech Date: Wed, 22 Jul 2026 00:00:04 +0800 Subject: [PATCH 1/8] =?UTF-8?q?UI=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/data-table/column-header.tsx | 8 +- .../components/data-table/data-table-page.tsx | 10 +- .../data-table/mobile-card-list.tsx | 31 +- .../components/data-table/table-skeleton.tsx | 9 +- web/default/src/components/drawer-layout.ts | 2 +- web/default/src/components/multi-select.tsx | 5 + .../src/components/status-badge.test.tsx | 58 + web/default/src/components/status-badge.tsx | 31 +- .../components/channel-capability-matrix.tsx | 59 +- .../channels/components/channels-columns.tsx | 7 +- .../drawers/channel-editor-state.test.ts | 48 + .../drawers/channel-editor-state.ts | 53 + .../drawers/channel-mutate-drawer.tsx | 675 +--------- .../drawers/sections/channel-auth-section.tsx | 2 +- .../sections/channel-basic-section.tsx | 123 +- .../sections/channel-models-section.test.tsx | 94 ++ .../sections/channel-models-section.tsx | 459 ++++++- .../components/model-mapping-editor.tsx | 110 +- .../channels/lib/channel-capabilities.ts | 31 +- .../channels/lib/channel-config-validation.ts | 65 +- .../models/components/models-columns.tsx | 4 +- .../models/model-pricing-sheet.tsx | 74 +- .../tiered-pricing-editor.runtime.test.tsx | 242 ++++ .../models/tiered-pricing-editor.tsx | 1084 +---------------- .../models/tiered-pricing-request-rules.tsx | 510 ++++++++ .../models/tiered-pricing-visual-editor.tsx | 570 +++++++++ .../components/dialogs/fail-reason-dialog.tsx | 2 +- web/default/src/i18n/locales/en.json | 52 + web/default/src/i18n/locales/fr.json | 52 + web/default/src/i18n/locales/ja.json | 52 + web/default/src/i18n/locales/ru.json | 52 + web/default/src/i18n/locales/vi.json | 52 + web/default/src/i18n/locales/zh.json | 52 + web/default/src/tanstack-table.d.ts | 8 +- 34 files changed, 2827 insertions(+), 1859 deletions(-) create mode 100644 web/default/src/components/status-badge.test.tsx create mode 100644 web/default/src/features/channels/components/drawers/channel-editor-state.test.ts create mode 100644 web/default/src/features/channels/components/drawers/sections/channel-models-section.test.tsx create mode 100644 web/default/src/features/system-settings/models/tiered-pricing-editor.runtime.test.tsx create mode 100644 web/default/src/features/system-settings/models/tiered-pricing-request-rules.tsx create mode 100644 web/default/src/features/system-settings/models/tiered-pricing-visual-editor.tsx diff --git a/web/default/src/components/data-table/column-header.tsx b/web/default/src/components/data-table/column-header.tsx index 09bd6bfe..a45b14f2 100644 --- a/web/default/src/components/data-table/column-header.tsx +++ b/web/default/src/components/data-table/column-header.tsx @@ -51,7 +51,7 @@ export function DataTableColumnHeader({ } return ( -
+
({ > {title} {column.getIsSorted() === 'desc' ? ( - + ) : column.getIsSorted() === 'asc' ? ( - + ) : ( - + )} diff --git a/web/default/src/components/data-table/data-table-page.tsx b/web/default/src/components/data-table/data-table-page.tsx index 075f16a4..65327117 100644 --- a/web/default/src/components/data-table/data-table-page.tsx +++ b/web/default/src/components/data-table/data-table-page.tsx @@ -342,7 +342,10 @@ function renderDesktop( ({ {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} diff --git a/web/default/src/components/data-table/mobile-card-list.tsx b/web/default/src/components/data-table/mobile-card-list.tsx index d856f7be..cc0cf65d 100644 --- a/web/default/src/components/data-table/mobile-card-list.tsx +++ b/web/default/src/components/data-table/mobile-card-list.tsx @@ -48,6 +48,7 @@ interface MobileColumnMeta { mobileTitle?: boolean mobileBadge?: boolean mobileHidden?: boolean + mobileClassName?: string } function getCellMeta( @@ -150,7 +151,12 @@ function CompactRow({ row }: { row: Row }) {
{renderCellContent(selectCell)}
)} {titleCell && ( -
+
{renderCellContent(titleCell)}
)} @@ -176,7 +182,12 @@ function CompactRow({ row }: { row: Row }) { {label}
)} -
+
{renderCellContent(cell) ?? '-'}
@@ -220,7 +231,13 @@ function FallbackRow({ row }: { row: Row }) { if (!label) { return ( -
+
{content}
) @@ -234,7 +251,12 @@ function FallbackRow({ row }: { row: Row }) { {label} -
+
{content ?? '-'}
@@ -254,6 +276,7 @@ function FallbackRow({ row }: { row: Row }) { * - `mobileTitle` — card header (left, larger text) * - `mobileBadge` — inline with title (right, e.g. status badge) * - `mobileHidden` — hidden on mobile + * - `mobileClassName` — value-container layout for long mobile content * * When mobileTitle or mobileBadge is set on any column, uses a structured * two-tier layout: title+badge header, then 2 key fields side-by-side. diff --git a/web/default/src/components/data-table/table-skeleton.tsx b/web/default/src/components/data-table/table-skeleton.tsx index 8a92b70f..31eed011 100644 --- a/web/default/src/components/data-table/table-skeleton.tsx +++ b/web/default/src/components/data-table/table-skeleton.tsx @@ -66,7 +66,14 @@ export function TableSkeleton({ SKELETON_WIDTHS.length return ( - +
{hasWarnings && ( - +
{rows.map((row) => (
-
{row.label}
+
{t(row.label)}
- {row.description} + {t(row.description)}
-

配置校验

+

+ {t('Configuration validation')} +

{props.issues.length === 0 ? ( - 当前渠道类型未检测到阻断性配置问题。 + {t( + 'No blocking configuration issues were detected for this channel type.' + )} ) : ( @@ -193,12 +214,12 @@ export function ChannelCapabilityMatrix(props: ChannelCapabilityMatrixProps) {
- {issue.message} + {t(issue.message)}
diff --git a/web/default/src/features/channels/components/channels-columns.tsx b/web/default/src/features/channels/components/channels-columns.tsx index 079494e9..f0a8ad81 100644 --- a/web/default/src/features/channels/components/channels-columns.tsx +++ b/web/default/src/features/channels/components/channels-columns.tsx @@ -473,7 +473,12 @@ export function useChannelsColumns(): ColumnDef[] { // Name column { accessorKey: 'name', - meta: { label: t('Name'), mobileTitle: true }, + meta: { + label: t('Name'), + mobileTitle: true, + cellClassName: 'max-w-[28rem] whitespace-normal', + mobileClassName: '[overflow-wrap:anywhere]', + }, header: ({ column }) => ( ), diff --git a/web/default/src/features/channels/components/drawers/channel-editor-state.test.ts b/web/default/src/features/channels/components/drawers/channel-editor-state.test.ts new file mode 100644 index 00000000..92c9fd86 --- /dev/null +++ b/web/default/src/features/channels/components/drawers/channel-editor-state.test.ts @@ -0,0 +1,48 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' +import { getModelMappingGuardrail } from './channel-editor-state' + +describe('model mapping guardrail', () => { + test('reports missing source models and exposed upstream targets once', () => { + const guardrail = getModelMappingGuardrail( + JSON.stringify({ + 'client-model': 'vendor-model', + 'second-client-model': 'vendor-model', + incomplete: '', + }), + ['client-model', 'vendor-model'] + ) + + assert.equal(guardrail.invalidJson, false) + assert.deepEqual(guardrail.entries, [ + { source: 'client-model', target: 'vendor-model' }, + { source: 'second-client-model', target: 'vendor-model' }, + ]) + assert.deepEqual(guardrail.missingSourceModels, ['second-client-model']) + assert.deepEqual(guardrail.exposedTargetModels, ['vendor-model']) + }) + + test('rejects invalid JSON and non-object mappings', () => { + assert.equal(getModelMappingGuardrail('{', []).invalidJson, true) + assert.equal(getModelMappingGuardrail('[]', []).invalidJson, true) + assert.equal(getModelMappingGuardrail('', []).invalidJson, false) + }) +}) diff --git a/web/default/src/features/channels/components/drawers/channel-editor-state.ts b/web/default/src/features/channels/components/drawers/channel-editor-state.ts index 5b4315db..8a3a4baf 100644 --- a/web/default/src/features/channels/components/drawers/channel-editor-state.ts +++ b/web/default/src/features/channels/components/drawers/channel-editor-state.ts @@ -34,6 +34,59 @@ export const createEmptyModelMappingGuardrail = (): ModelMappingGuardrail => ({ exposedTargetModels: [], }) +export function getModelMappingGuardrail( + modelMapping: string | undefined, + publishedModels: string[] +): ModelMappingGuardrail { + if (!modelMapping?.trim()) { + return createEmptyModelMappingGuardrail() + } + + try { + const parsed: unknown = JSON.parse(modelMapping) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { ...createEmptyModelMappingGuardrail(), invalidJson: true } + } + + const entries = Object.entries(parsed).reduce< + Array<{ source: string; target: string }> + >((acc, [rawSource, rawTarget]) => { + const source = String(rawSource).trim() + const target = String(rawTarget ?? '').trim() + + if (source && target) { + acc.push({ source, target }) + } + return acc + }, []) + + const publishedModelSet = new Set(publishedModels) + const missingSourceModels = Array.from( + new Set( + entries + .filter((entry) => !publishedModelSet.has(entry.source)) + .map((entry) => entry.source) + ) + ) + const exposedTargetModels = Array.from( + new Set( + entries + .filter((entry) => publishedModelSet.has(entry.target)) + .map((entry) => entry.target) + ) + ) + + return { + invalidJson: false, + entries, + missingSourceModels, + exposedTargetModels, + } + } catch { + return { ...createEmptyModelMappingGuardrail(), invalidJson: true } + } +} + export const formatModelNames = (models: string[]): string => models.map((model) => `"${model}"`).join(', ') diff --git a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx index da8d288a..69c1d7de 100644 --- a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -21,18 +21,13 @@ import { type SubmitErrorHandler, useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { useQuery, useQueryClient } from '@tanstack/react-query' import { - ArrowRight, Boxes, ClipboardPaste, - HelpCircle, Loader2, Server, - Sparkles, Trash2, Copy, FileText, - Eraser, - Plus, Eye, Link2, RefreshCw, @@ -55,7 +50,6 @@ import { useHiddenClickUnlock } from '@/hooks/use-hidden-click-unlock' import { Alert, AlertDescription } from '@/components/ui/alert' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' -import { Combobox } from '@/components/ui/combobox' import { Form, FormControl, @@ -74,7 +68,6 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { Separator } from '@/components/ui/separator' import { Sheet, SheetClose, @@ -84,24 +77,16 @@ import { SheetHeader, SheetTitle, } from '@/components/ui/sheet' -import { Skeleton } from '@/components/ui/skeleton' import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/components/ui/tooltip' import { sideDrawerContentClassName, sideDrawerFooterClassName, sideDrawerFormClassName, sideDrawerHeaderClassName, sideDrawerSectionClassName, - sideDrawerSwitchItemClassName, } from '@/components/drawer-layout' import { JsonEditor } from '@/components/json-editor' -import { MultiSelect } from '@/components/multi-select' import { SecureVerificationDialog, useSecureVerification, @@ -166,7 +151,6 @@ import { } from '../dialogs/missing-models-confirmation-dialog' import { ParamOverrideEditorDialog } from '../dialogs/param-override-editor-dialog' import { StatusCodeRiskDialog } from '../dialogs/status-code-risk-dialog' -import { ModelMappingEditor } from '../model-mapping-editor' import { CardHeading, ChannelEditorNav, @@ -182,10 +166,7 @@ import { ADVANCED_SETTINGS_SECTION_IDS, CHANNEL_EDITOR_MAIN_SECTION_IDS, CHANNEL_EDITOR_SECTION_IDS, - MODEL_MAPPING_PREVIEW_FALLBACK, UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT, - createEmptyModelMappingGuardrail, - formatModelNames, formatUnixTime, getCompletionStatus, getSectionStatusLabel, @@ -193,7 +174,6 @@ import { hasConfiguredOverrideValue, parseSettingsRecord, readAdvancedSettingsPreference, - type ModelMappingGuardrail, } from './channel-editor-state' import { ChannelAdvancedSection, @@ -736,84 +716,6 @@ export function ChannelMutateDrawer({ [currentModelMapping] ) - // Transform models to multi-select options - const modelOptions = useMemo(() => { - const allModels = new Set([...allModelsList, ...currentModelsArray]) - return Array.from(allModels).map((model) => ({ - value: model, - label: model, - })) - }, [allModelsList, currentModelsArray]) - - const modelMappingGuardrail = useMemo(() => { - if (!currentModelMapping?.trim()) { - return createEmptyModelMappingGuardrail() - } - - try { - const parsed = JSON.parse(currentModelMapping) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return { ...createEmptyModelMappingGuardrail(), invalidJson: true } - } - - const entries = Object.entries(parsed).reduce< - Array<{ source: string; target: string }> - >((acc, [rawSource, rawTarget]) => { - const source = String(rawSource).trim() - const target = String(rawTarget ?? '').trim() - - if (!source || !target) { - return acc - } - - acc.push({ source, target }) - return acc - }, []) - - const missingSourceModels = Array.from( - new Set( - entries - .filter( - (entry) => - Boolean(entry.source) && - !currentModelsArray.includes(entry.source) - ) - .map((entry) => entry.source) - ) - ) - - const exposedTargetModels = Array.from( - new Set( - entries - .filter( - (entry) => - Boolean(entry.target) && - currentModelsArray.includes(entry.target) - ) - .map((entry) => entry.target) - ) - ) - - return { - invalidJson: false, - entries, - missingSourceModels, - exposedTargetModels, - } - } catch { - return { ...createEmptyModelMappingGuardrail(), invalidJson: true } - } - }, [currentModelMapping, currentModelsArray]) - - const mappingPreviewPairs = - modelMappingGuardrail.entries.length > 0 - ? modelMappingGuardrail.entries.slice(0, 3) - : MODEL_MAPPING_PREVIEW_FALLBACK - const remainingMappingCount = - modelMappingGuardrail.entries.length > 3 - ? modelMappingGuardrail.entries.length - 3 - : 0 - const upstreamUpdateMeta = useMemo(() => { const settings = parseSettingsRecord(currentSettings) const detectedModels = Array.isArray( @@ -990,18 +892,6 @@ export function ChannelMutateDrawer({ } }, [channelId, queryClient, t]) - // Unified function to update models - const updateModels = useCallback( - (newModels: string[], merge: boolean = false) => { - const finalModels = merge - ? formatModelsArray([...currentModelsArray, ...newModels]) - : formatModelsArray(newModels) - form.setValue('models', finalModels) - return newModels.length - }, - [currentModelsArray, form] - ) - // Handle fetching models from upstream const handleFetchModels = useCallback(async () => { const type = form.getValues('type') @@ -1035,77 +925,6 @@ export function ChannelMutateDrawer({ throw new Error(response.message || 'No models fetched from upstream') }, [form]) - // Handle model operations - const handleFillRelatedModels = useCallback(() => { - if (!basicModels.length) { - toast.info(t('No related models available for this channel type')) - return - } - updateModels(basicModels) - toast.success( - t('Filled {{count}} related model(s)', { count: basicModels.length }) - ) - }, [basicModels, updateModels, t]) - - const handleFillAllModels = useCallback(() => { - if (!allModelsList.length) { - toast.info(t('No models available')) - return - } - updateModels(allModelsList) - toast.success( - t('Filled {{count}} model(s)', { count: allModelsList.length }) - ) - }, [allModelsList, updateModels, t]) - - const handleClearModels = useCallback(() => { - form.setValue('models', '') - toast.success(t('Cleared all models')) - }, [form, t]) - - const handleCopyModels = useCallback(async () => { - const models = form.getValues('models') - if (!models?.trim()) { - toast.info(t('No models to copy')) - return - } - await copyToClipboard(models) - }, [form, copyToClipboard, t]) - - // Handle adding prefill group models - const handleAddPrefillGroup = useCallback( - (group: { id: number; name: string; items: string | string[] }) => { - try { - const items = Array.isArray(group.items) - ? group.items - : JSON.parse(group.items) - - if (!Array.isArray(items)) { - throw new Error('Invalid items format') - } - - const count = updateModels(items, true) - toast.success( - t('Added {{count}} models from "{{name}}"', { - count, - name: group.name, - }) - ) - } catch { - toast.error(t('Failed to parse group items')) - } - }, - [updateModels, t] - ) - - // Handle model selection change from MultiSelect - const handleModelsChange = useCallback( - (selected: string[]) => { - form.setValue('models', selected.join(',')) - }, - [form] - ) - // Handle successful submission const handleSuccess = useCallback(() => { queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() }) @@ -1502,134 +1321,10 @@ export function ChannelMutateDrawer({ id={CHANNEL_EDITOR_SECTION_IDS.identity} className='scroll-mt-4' > - -
- ( - - {t('Name *')} - - - - - - )} - /> - - ( - - {t('Type *')} - - { - const nextType = Number(value) - if ( - Number.isInteger(nextType) && - nextType > 0 - ) { - field.onChange(nextType) - if ( - !isEditing && - nextType !== field.value - ) { - const scopedDefaults = - getChannelTypeScopedFieldDefaults( - nextType - ) - form.setValue( - 'base_url', - scopedDefaults.base_url, - { - shouldDirty: true, - shouldTouch: true, - shouldValidate: true, - } - ) - form.setValue( - 'other', - scopedDefaults.other, - { - shouldDirty: true, - shouldTouch: true, - shouldValidate: true, - } - ) - } - } - }} - placeholder={t('Select channel type')} - searchPlaceholder={t( - 'Search channel type...' - )} - emptyText={t('No channel type found.')} - allowCustomValue - /> - - - - )} - /> -
- - ( - -
- {t('Enabled')} - - {t('Enable or disable this channel')} - -
- - - field.onChange(checked ? 1 : 2) - } - /> - -
- )} - /> - - {currentType === 1 && ( - ( - - - {t('OpenAI Organization')} - - - - - - {t(FIELD_DESCRIPTIONS.OPENAI_ORG)} - - - - )} - /> - )} -
+ - -
-
- ( - -
-
- {t('Models *')} - - {t(FIELD_DESCRIPTIONS.MODELS)} - -
- - {t('Selected {{count}}', { - count: currentModelsArray.length, - })} - -
- - - - {modelMappingGuardrail.exposedTargetModels - .length > 0 && ( - - - - {t('The mapped upstream model(s)')}{' '} - {formatModelNames( - modelMappingGuardrail.exposedTargetModels - )}{' '} - {t( - 'are also listed here. Remove them from Models to keep the `/v1/models` response user-friendly and hide vendor-specific names.' - )} - - - - - )} - -
- )} - /> - - - -
-
-

- {t('Quick actions')} -

-

- {t( - 'Use presets or upstream discovery to populate the model list faster.' - )} -

-
-
- - - {MODEL_FETCHABLE_TYPES.has(currentType) && ( - - )} - - -
- {prefillGroups.length > 0 && ( -
- - {t('Preset groups')}: - - {prefillGroups.map((group) => ( - - ))} -
- )} -
-
- -
- ( - -
-
-
- - {t('Model Mapping')} - - - - } - > - - -

- {t('Request flow')} -

-
- {mappingPreviewPairs.map( - (pair) => ( -
- {pair.source} -
- ) - )} - {remainingMappingCount > 0 && ( -
- +{remainingMappingCount}{' '} - {t('more mapping')} - {remainingMappingCount > 1 - ? 's' - : ''} -
- )} -
-

- {t( - 'Users call the model on the left. The platform forwards the request to the upstream model on the right.' - )} -

-
-
-
- - {t(FIELD_DESCRIPTIONS.MODEL_MAPPING)} - -
-
- - option.value - )} - /> - - {modelMappingGuardrail.invalidJson && ( - - - {t( - 'Model Mapping must be a JSON object like' - )}{' '} - - {'{"gpt-4":"Azure-GPT4"}'} - - {t( - '. Please fix the JSON before saving.' - )} - - - )} - {modelMappingGuardrail.missingSourceModels - .length > 0 && ( - - - - {t('Add')}{' '} - {formatModelNames( - modelMappingGuardrail.missingSourceModels - )}{' '} - {t( - 'to the Models list so users can use them before the mapping sends traffic upstream.' - )} - - - - - )} - -
- )} - /> -
- -
- ( - -
- {t('Groups *')} - - {t(FIELD_DESCRIPTIONS.GROUP)} - -
- - {isLoadingGroups ? ( - - ) : ( - - )} - - -
- )} - /> -
-
-
+
{ - form.setValue('models', formatModelsArray(models)) + form.setValue('models', formatModelsArray(models), { + shouldDirty: true, + shouldValidate: true, + }) }} redirectModels={redirectModelList} redirectSourceModels={redirectModelKeyList} diff --git a/web/default/src/features/channels/components/drawers/sections/channel-auth-section.tsx b/web/default/src/features/channels/components/drawers/sections/channel-auth-section.tsx index ab056593..6af709d3 100644 --- a/web/default/src/features/channels/components/drawers/sections/channel-auth-section.tsx +++ b/web/default/src/features/channels/components/drawers/sections/channel-auth-section.tsx @@ -34,7 +34,7 @@ export function ChannelAuthSection(props: ChannelAuthSectionProps) { className='text-muted-foreground h-3.5 w-3.5' aria-hidden='true' /> -

+

{t('Authentication')}

diff --git a/web/default/src/features/channels/components/drawers/sections/channel-basic-section.tsx b/web/default/src/features/channels/components/drawers/sections/channel-basic-section.tsx index 63af16de..eb126712 100644 --- a/web/default/src/features/channels/components/drawers/sections/channel-basic-section.tsx +++ b/web/default/src/features/channels/components/drawers/sections/channel-basic-section.tsx @@ -16,20 +16,41 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import type { ReactNode } from 'react' +import { useFormContext, useWatch } from 'react-hook-form' import { Server } from 'lucide-react' import { useTranslation } from 'react-i18next' +import { Combobox } from '@/components/ui/combobox' +import type { ComboboxInputOption } from '@/components/ui/combobox-input' +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' import { SideDrawerSection, SideDrawerSectionHeader, + sideDrawerSwitchItemClassName, } from '@/components/drawer-layout' +import { FIELD_DESCRIPTIONS, FIELD_PLACEHOLDERS } from '../../../constants' +import { + getChannelTypeScopedFieldDefaults, + type ChannelFormValues, +} from '../../../lib' type ChannelBasicSectionProps = { - children: ReactNode + channelTypeOptions: ComboboxInputOption[] + isEditing: boolean } export function ChannelBasicSection(props: ChannelBasicSectionProps) { const { t } = useTranslation() + const form = useFormContext() + const currentType = useWatch({ control: form.control, name: 'type' }) return ( @@ -38,7 +59,103 @@ export function ChannelBasicSection(props: ChannelBasicSectionProps) { description={t('Name, provider type, and availability.')} icon={ ) } diff --git a/web/default/src/features/channels/components/drawers/sections/channel-models-section.test.tsx b/web/default/src/features/channels/components/drawers/sections/channel-models-section.test.tsx new file mode 100644 index 00000000..05abd5b4 --- /dev/null +++ b/web/default/src/features/channels/components/drawers/sections/channel-models-section.test.tsx @@ -0,0 +1,94 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { useForm } from 'react-hook-form' +import i18n from 'i18next' +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' +import { renderToStaticMarkup } from 'react-dom/server' +import { I18nextProvider } from 'react-i18next' +import { Form } from '@/components/ui/form' +import { + CHANNEL_FORM_DEFAULT_VALUES, + type ChannelFormValues, +} from '../../../lib' +import { ChannelModelsSection } from './channel-models-section' + +i18n.init({ + lng: 'en', + fallbackLng: 'en', + resources: { en: { translation: {} } }, + interpolation: { escapeValue: false }, +}) + +const selectedModels = Array.from( + { length: 8 }, + (_, index) => `long-vendor-model-${index + 1}` +) + +function ChannelModelsSectionFixture() { + const form = useForm({ + defaultValues: { + ...CHANNEL_FORM_DEFAULT_VALUES, + models: selectedModels.join(','), + model_mapping: JSON.stringify({ + 'public-model': selectedModels[0], + }), + }, + }) + + return ( +
+ undefined} + prefillGroups={[]} + /> + + ) +} + +describe('ChannelModelsSection', () => { + test('caps selected chips while preserving the complete model count', () => { + const markup = renderToStaticMarkup( + + + + ) + + assert.match(markup, /Selected 8/) + assert.match(markup, /\+2 more/) + assert.doesNotMatch(markup, />long-vendor-model-7long-vendor-model-8 { + const markup = renderToStaticMarkup( + + + + ) + + assert.match(markup, /\[overflow-wrap:anywhere\]/) + assert.match(markup, /aria-describedby="[^"]+-form-item-description"/) + }) +}) diff --git a/web/default/src/features/channels/components/drawers/sections/channel-models-section.tsx b/web/default/src/features/channels/components/drawers/sections/channel-models-section.tsx index f68388e7..93017c9a 100644 --- a/web/default/src/features/channels/components/drawers/sections/channel-models-section.tsx +++ b/web/default/src/features/channels/components/drawers/sections/channel-models-section.tsx @@ -16,20 +16,141 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import type { ReactNode } from 'react' -import { Boxes } from 'lucide-react' +import { useCallback, useMemo } from 'react' +import { useFormContext, useWatch } from 'react-hook-form' +import { + ArrowRight, + Boxes, + Copy, + Eraser, + FileText, + HelpCircle, + Plus, + Sparkles, +} from 'lucide-react' import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Separator } from '@/components/ui/separator' +import { Skeleton } from '@/components/ui/skeleton' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' import { SideDrawerSection, SideDrawerSectionHeader, } from '@/components/drawer-layout' +import { MultiSelect, type Option } from '@/components/multi-select' +import { + FIELD_DESCRIPTIONS, + FIELD_PLACEHOLDERS, + MODEL_FETCHABLE_TYPES, +} from '../../../constants' +import { + formatModelsArray, + parseModelsString, + type ChannelFormValues, +} from '../../../lib' +import { ModelMappingEditor } from '../../model-mapping-editor' +import { + MODEL_MAPPING_PREVIEW_FALLBACK, + formatModelNames, + getModelMappingGuardrail, +} from '../channel-editor-state' + +export type ChannelModelPrefillGroup = { + id: number + name: string + items: string | string[] +} type ChannelModelsSectionProps = { - children: ReactNode + allModels: string[] + basicModels: string[] + groupOptions: Option[] + isLoadingGroups: boolean + isSubmitting: boolean + onFetchModels: () => void + prefillGroups: ChannelModelPrefillGroup[] } export function ChannelModelsSection(props: ChannelModelsSectionProps) { const { t } = useTranslation() + const form = useFormContext() + const { copyToClipboard } = useCopyToClipboard() + const currentType = useWatch({ control: form.control, name: 'type' }) + const currentModels = useWatch({ control: form.control, name: 'models' }) + const currentModelMapping = useWatch({ + control: form.control, + name: 'model_mapping', + }) + + const currentModelsArray = useMemo( + () => parseModelsString(currentModels), + [currentModels] + ) + const modelOptions = useMemo(() => { + const models = new Set([...props.allModels, ...currentModelsArray]) + return Array.from(models).map((model) => ({ value: model, label: model })) + }, [props.allModels, currentModelsArray]) + const modelMappingGuardrail = useMemo( + () => getModelMappingGuardrail(currentModelMapping, currentModelsArray), + [currentModelMapping, currentModelsArray] + ) + const mappingPreviewPairs = + modelMappingGuardrail.entries.length > 0 + ? modelMappingGuardrail.entries.slice(0, 3) + : MODEL_MAPPING_PREVIEW_FALLBACK + const remainingMappingCount = Math.max( + modelMappingGuardrail.entries.length - mappingPreviewPairs.length, + 0 + ) + + const updateModels = useCallback( + (newModels: string[], merge = false) => { + const finalModels = merge + ? formatModelsArray([...currentModelsArray, ...newModels]) + : formatModelsArray(newModels) + form.setValue('models', finalModels, { + shouldDirty: true, + shouldValidate: true, + }) + return newModels.length + }, + [currentModelsArray, form] + ) + + const handleAddPrefillGroup = (group: ChannelModelPrefillGroup) => { + try { + const items = Array.isArray(group.items) + ? group.items + : JSON.parse(group.items) + if (!Array.isArray(items)) throw new Error('Invalid items format') + + const count = updateModels(items, true) + toast.success( + t('Added {{count}} models from "{{name}}"', { + count, + name: group.name, + }) + ) + } catch { + toast.error(t('Failed to parse group items')) + } + } return ( @@ -38,7 +159,337 @@ export function ChannelModelsSection(props: ChannelModelsSectionProps) { description={t('Published models, groups, and model remapping rules.')} icon={ ) } diff --git a/web/default/src/features/channels/components/model-mapping-editor.tsx b/web/default/src/features/channels/components/model-mapping-editor.tsx index b785d5ae..0f80d823 100644 --- a/web/default/src/features/channels/components/model-mapping-editor.tsx +++ b/web/default/src/features/channels/components/model-mapping-editor.tsx @@ -16,7 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useId, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { Code, Plus, Table, Trash2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { cn } from '@/lib/utils' @@ -70,68 +70,71 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) { const nextRowIdRef = useRef(0) const duplicateSources = useMemo(() => getDuplicateSources(rows), [rows]) - const createRowId = () => { + const createRowId = useCallback(() => { nextRowIdRef.current += 1 return `mapping-${nextRowIdRef.current}` - } + }, []) - const parseJsonToRows = (json: string): boolean => { - try { - if (!json.trim()) { - setRows([]) - setJsonError(null) - return true - } - const parsed = JSON.parse(json) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - setJsonError(t('Model mapping must be a valid JSON object')) - return false - } - const entries = Object.entries(parsed) - const invalidValue = entries.find(([, to]) => typeof to !== 'string') - if (invalidValue) { - setJsonError(t('Model mapping values must be strings')) - return false - } - setRows((previousRows) => { - const remainingRows = [...previousRows] - return entries.map(([from, to], index) => { - const toString = String(to) - const existingIndex = remainingRows.findIndex( - (row) => - row.from === from || - (row.from === from && row.to === toString) || - previousRows[index]?.id === row.id - ) - if (existingIndex >= 0) { - const [existing] = remainingRows.splice(existingIndex, 1) + const parseJsonToRows = useCallback( + (json: string): boolean => { + try { + if (!json.trim()) { + setRows([]) + setJsonError(null) + return true + } + const parsed = JSON.parse(json) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + setJsonError(t('Model mapping must be a valid JSON object')) + return false + } + const entries = Object.entries(parsed) + const invalidValue = entries.find(([, to]) => typeof to !== 'string') + if (invalidValue) { + setJsonError(t('Model mapping values must be strings')) + return false + } + setRows((previousRows) => { + const remainingRows = [...previousRows] + return entries.map(([from, to], index) => { + const toString = String(to) + const existingIndex = remainingRows.findIndex( + (row) => + row.from === from || + (row.from === from && row.to === toString) || + previousRows[index]?.id === row.id + ) + if (existingIndex >= 0) { + const [existing] = remainingRows.splice(existingIndex, 1) + return { + id: existing.id, + from, + to: toString, + } + } return { - id: existing.id, + id: createRowId(), from, to: toString, } - } - return { - id: createRowId(), - from, - to: toString, - } + }) }) - }) - setJsonError(null) - return true - } catch (_error) { - setJsonError(t('Model mapping must be valid JSON format')) - return false - } - } + setJsonError(null) + return true + } catch (_error) { + setJsonError(t('Model mapping must be valid JSON format')) + return false + } + }, + [createRowId, t] + ) // Parse JSON to rows when value changes externally useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect setJsonValue(props.value) parseJsonToRows(props.value) - }, [props.value]) + }, [parseJsonToRows, props.value]) const convertRowsToJson = (updatedRows: MappingRow[]): string => { if (updatedRows.length === 0) { @@ -222,7 +225,7 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) { return (
-
+
@@ -264,7 +267,7 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) { {rows.length > 0 ? (
-
+
{t('Original Model')}
{t('Replacement Model')}
@@ -272,9 +275,10 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) { {rows.map((row) => (
handleRowChange(row.id, 'from', e.target.value) @@ -282,6 +286,7 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) { placeholder='gpt-3.5-turbo' disabled={props.disabled} list={sourceListId} + aria-label={t('Original Model')} /> -
- ) -} - -// --------------------------------------------------------------------------- -// Price input field -// --------------------------------------------------------------------------- - -type PriceFieldProps = { - label: string - hint?: string - value: number - onChange: (next: number) => void -} - -function PriceField({ label, hint, value, onChange }: PriceFieldProps) { - return ( -
- - - {hint &&

{hint}

} -
- ) -} - -// --------------------------------------------------------------------------- -// Single tier card (visual editor) -// --------------------------------------------------------------------------- - -type VisualTierCardProps = { - tier: VisualTier - index: number - total: number - onChange: (next: VisualTier) => void - onRemove: () => void - onAddCondition: () => void -} - -function VisualTierCard({ - tier, - index, - total, - onChange, - onRemove, - onAddCondition, -}: VisualTierCardProps) { - const { t } = useTranslation() - const cacheMode = getTierCacheMode(tier) - - const handleConditionChange = ( - conditionIndex: number, - next: TierConditionInput - ) => { - const conditions = [...tier.conditions] - conditions[conditionIndex] = next - onChange({ ...tier, conditions }) - } - - const handleConditionRemove = (conditionIndex: number) => { - onChange({ - ...tier, - conditions: tier.conditions.filter((_, i) => i !== conditionIndex), - }) - } - - const handlePriceChange = (field: keyof VisualTier, value: number) => { - onChange({ ...tier, [field]: value }) - } - - const handleCacheModeChange = (mode: CacheMode) => { - onChange({ - ...tier, - cache_mode: mode, - cache_create_1h_unit_cost: - mode === CACHE_MODE_TIMED ? (tier.cache_create_1h_unit_cost ?? 0) : 0, - }) - } - - const inputUnitPrice = unitCostToPrice(tier.input_unit_cost) - const outputUnitPrice = unitCostToPrice(tier.output_unit_cost) - const hasMediaPricing = MEDIA_PRICE_VARS.some((variable) => { - const fieldKey = variable.tierField as keyof VisualTier - return unitCostToPrice((tier[fieldKey] as number | undefined) ?? 0) > 0 - }) - const [mediaOpen, setMediaOpen] = useState(hasMediaPricing) - - useEffect(() => { - // Reveal newly configured media prices when controlled data changes. - // eslint-disable-next-line react-hooks/set-state-in-effect - if (hasMediaPricing) setMediaOpen(true) - }, [hasMediaPricing]) - - const renderPriceVariable = ( - variable: (typeof BILLING_EXTRA_VARS)[number] - ) => { - const fieldKey = variable.tierField as keyof VisualTier - const value = unitCostToPrice((tier[fieldKey] as number | undefined) ?? 0) - - return ( - handlePriceChange(fieldKey, priceToUnitCost(next))} - /> - ) - } - - return ( -
-
-
- - {t('Tier')} {index + 1} / {total} - - {tier.conditions.length === 0 && ( - {t('Fallback tier')} - )} - - onChange({ ...tier, label: event.target.value }) - } - placeholder={t('Tier name')} - className='h-7 w-36' - /> -
- -
- - {/* Conditions */} -
-
- - -
- {tier.conditions.length === 0 ? ( -

- {t('Always matches (default tier).')} -

- ) : ( - tier.conditions.map((condition, conditionIndex) => ( - handleConditionChange(conditionIndex, next)} - onRemove={() => handleConditionRemove(conditionIndex)} - /> - )) - )} -
- -
-
- - - {PRICE_SUFFIX} - -
- -
-
- - handlePriceChange('input_unit_cost', priceToUnitCost(value)) - } - /> - - handlePriceChange('output_unit_cost', priceToUnitCost(value)) - } - /> -
- -
-
- - value !== null && handleCacheModeChange(value as CacheMode) - } - > - - - {t('Generic cache')} - - - {t('Time-sliced cache (Claude)')} - - - -
-
- {CACHE_PRICE_VARS.map((variable) => { - if (variable.key === 'cc1h' && cacheMode !== CACHE_MODE_TIMED) { - return null - } - return renderPriceVariable(variable) - })} -
-
-
-
- - {/* Media prices */} -
- - {mediaOpen && ( -
- {MEDIA_PRICE_VARS.map(renderPriceVariable)} -
- )} -
-
- ) -} - -// --------------------------------------------------------------------------- -// Visual editor (list of tiers) -// --------------------------------------------------------------------------- - -type VisualEditorProps = { - visualConfig: VisualConfig | null - onChange: (next: VisualConfig) => void -} - -function VisualEditor({ visualConfig, onChange }: VisualEditorProps) { - const { t } = useTranslation() - const config = useMemo( - () => normalizeVisualConfig(visualConfig), - [visualConfig] - ) - - const handleTierChange = (index: number, next: VisualTier) => { - const tiers = [...config.tiers] - tiers[index] = normalizeVisualTier(next) - onChange({ ...config, tiers }) - } - - const handleAddTier = () => { - const tiers = [...config.tiers] - const lastIndex = tiers.length - 1 - // When adding a new fallback, give the previous catch-all tier a default - // upper-bound condition so the expression compiles into a sane two-tier - // shape. Mirrors the legacy editor UX for adding tiers. - if (lastIndex >= 0 && tiers[lastIndex].conditions.length === 0) { - tiers[lastIndex] = normalizeVisualTier({ - ...tiers[lastIndex], - conditions: [{ var: 'len', op: '<', value: 200000 }], - }) - } - tiers.push( - normalizeVisualTier({ - label: `tier_${tiers.length + 1}`, - conditions: [], - input_unit_cost: 0, - output_unit_cost: 0, - }) - ) - onChange({ ...config, tiers }) - } - - const handleRemoveTier = (index: number) => { - const tiers = config.tiers.filter((_, i) => i !== index) - onChange({ ...config, tiers: tiers.length > 0 ? tiers : config.tiers }) - } - - const handleAddCondition = (index: number) => { - const tier = config.tiers[index] - if (tier.conditions.length >= 2) return - // Prefer `len` (input length) over `p`/`c` for tier conditions because - // `p` is subject to auto-exclusion when sub-categories like `cr` are - // priced separately, which can misroute long-input requests into shorter - // tiers when cache-hits reduce the effective `p`. - const usedVars = new Set(tier.conditions.map((c) => c.var)) - const nextVar: TierConditionInput['var'] = usedVars.has('len') ? 'c' : 'len' - onChange({ - ...config, - tiers: config.tiers.map((current, i) => - i === index - ? { - ...current, - conditions: [ - ...tier.conditions, - { var: nextVar, op: '<', value: 200000 }, - ], - } - : current - ), - }) - } - - return ( -
-

- {t( - 'Each tier supports up to 2 conditions. The last tier without conditions is the fallback.' - )} -

- {config.tiers.map((tier, index) => ( - handleTierChange(index, next)} - onRemove={() => handleRemoveTier(index)} - onAddCondition={() => handleAddCondition(index)} - /> - ))} - -
- ) -} - -// --------------------------------------------------------------------------- -// Raw expression editor -// --------------------------------------------------------------------------- - -type RawExprEditorProps = { - exprString: string - onChange: (value: string) => void -} - -function RawExprEditor({ exprString, onChange }: RawExprEditorProps) { - const { t } = useTranslation() - return ( -
- - -
- {t('Variables')}: len, p, c,{' '} - cr, cc, cc1h,{' '} - img, img_o, ai,{' '} - ao -
-
- {t('Functions')}: tier(name, value), max,{' '} - min, ceil, floor,{' '} - abs, header(name),{' '} - param(path), has(source, text) -
-
-
-