new-api/web/default/src/features/channels/lib/model-mapping-validation.ts
t0ng7u 3d850d38b6 ♻️ refactor(channels): rebuild channel create/edit drawer with modular sections and improved form UX
Restructure the default-theme channel create/edit experience to align with
classic frontend behavior, modern form UX patterns, and the project's Base UI
design system.

Channel editor architecture:
- Split the monolithic channel mutate drawer into focused section components
  (basic, API access, auth, models, advanced) with shared drawer layout
  primitives
- Extract submission, toast handling, and react-query cache invalidation into
  `useChannelMutateForm`
- Add a dedicated loading skeleton for channel detail fetch during edit mode
- Remove the top-level configuration summary block per UX feedback

Form validation and data handling:
- Strengthen `channel-form` Zod schema with JSON, model mapping, status code
  mapping, Codex credential, and Vertex AI key refinements
- Move type-specific conditional validation into `superRefine`
- Normalize base URL formatting and tighten model mapping value validation

Model mapping editor:
- Add Visual/JSON tabbed editing with inline JSON and duplicate-key feedback
- Improve accessibility for icon-only actions and add model suggestion datalists

MultiSelect component:
- Replace the custom cmdk-based implementation with Base UI Combobox chips
- Align focus, border, ring, disabled, and invalid states with standard Input
  styling via `ComboboxChips`
- Preserve existing API (`options`, `selected`, `onChange`, `allowCreate`,
  `createLabel`) for all current callers
- Support inline custom value creation, comma/newline batch input, searchable
  options, portal-based dropdown positioning, and chip removal

Models & groups UX:
- Integrate manual custom model entry directly into the model MultiSelect
- Remove the separate manual model input/button block
- Keep selected-model count and existing model-mapping guardrail behavior

i18n:
- Add and sync translation keys for new editor sections, validation messages,
  model mapping UI, and MultiSelect empty/create labels across en, zh, fr, ja,
  ru, and vi
- Remove obsolete keys tied to the deprecated summary and manual model entry UI

Affected areas:
- `web/default/src/features/channels/components/drawers/`
- `web/default/src/features/channels/hooks/use-channel-mutate-form.ts`
- `web/default/src/features/channels/lib/channel-form.ts`
- `web/default/src/features/channels/lib/model-mapping-validation.ts`
- `web/default/src/features/channels/components/model-mapping-editor.tsx`
- `web/default/src/components/multi-select.tsx`
- `web/default/src/i18n/locales/*.json`
2026-05-26 01:22:49 +08:00

254 lines
6.5 KiB
TypeScript
Vendored

/*
Copyright (C) 2023-2026 QuantumNous
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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
// ============================================================================
// Model Mapping Validation Utilities
// ============================================================================
/**
* Parse models string to array
*/
export function parseModelsString(modelsStr: string): string[] {
return modelsStr
? modelsStr
.split(',')
.map((m) => m.trim())
.filter(Boolean)
: []
}
/**
* Format models array to string
*/
export function formatModelsArray(models: string[]): string {
return Array.from(new Set(models)).join(',')
}
/**
* Normalize model name
*/
export function normalizeModelName(model: string): string {
return typeof model === 'string' ? model.trim() : ''
}
/**
* Extract source keys from model_mapping JSON
* (the keys of the mapping object — models being remapped FROM)
*/
export function extractMappingSourceModels(modelMapping: string): string[] {
if (typeof modelMapping !== 'string') return []
const trimmed = modelMapping.trim()
if (!trimmed) return []
try {
const parsed = JSON.parse(trimmed)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return []
}
const keys = Object.keys(parsed)
.map((key) => key.trim())
.filter(Boolean)
return Array.from(new Set(keys))
} catch {
return []
}
}
/**
* Extract redirect models from model_mapping JSON
*/
export function extractRedirectModels(modelMapping: string): string[] {
const mapping = modelMapping
if (typeof mapping !== 'string') return []
const trimmed = mapping.trim()
if (!trimmed) return []
try {
const parsed = JSON.parse(trimmed)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return []
}
const values = Object.values(parsed)
.map((value) => (typeof value === 'string' ? value.trim() : undefined))
.filter((value): value is string => Boolean(value))
return Array.from(new Set(values))
} catch {
return []
}
}
/**
* Check if model configuration has changed
*/
export function hasModelConfigChanged(
currentModels: string[],
currentModelMapping: string,
initialModels: string[],
initialModelMapping: string
): boolean {
// Always return true if not editing (new channel)
if (initialModels.length === 0 && !initialModelMapping) {
return true
}
// Check if models array changed
if (currentModels.length !== initialModels.length) {
return true
}
for (let i = 0; i < currentModels.length; i++) {
if (currentModels[i] !== initialModels[i]) {
return true
}
}
// Check if model_mapping changed
const normalizedCurrent = (currentModelMapping || '').trim()
const normalizedInitial = (initialModelMapping || '').trim()
return normalizedCurrent !== normalizedInitial
}
/**
* Find models in model_mapping that are missing from the models list
*/
export function findMissingModelsInMapping(
modelMapping: string,
currentModels: string[]
): string[] {
if (!modelMapping || modelMapping.trim() === '') {
return []
}
let parsedMapping: Record<string, unknown>
try {
parsedMapping = JSON.parse(modelMapping)
if (
!parsedMapping ||
typeof parsedMapping !== 'object' ||
Array.isArray(parsedMapping)
) {
return []
}
} catch {
return []
}
const modelSet = new Set(currentModels.map((m) => normalizeModelName(m)))
const missingModels = Object.keys(parsedMapping)
.map((key) => normalizeModelName(key))
.filter((key) => key && !modelSet.has(key))
return Array.from(new Set(missingModels))
}
/**
* Validate model mapping JSON format
*/
export function validateModelMappingJson(modelMapping: string): {
valid: boolean
error?: string
} {
if (!modelMapping || modelMapping.trim() === '') {
return { valid: true }
}
try {
const parsed = JSON.parse(modelMapping)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {
valid: false,
error: 'Model mapping must be a valid JSON object',
}
}
if (Object.values(parsed).some((value) => typeof value !== 'string')) {
return {
valid: false,
error: 'Model mapping values must be strings',
}
}
return { valid: true }
} catch {
return {
valid: false,
error: 'Model mapping must be valid JSON format',
}
}
}
/**
* Get redirect models that are also in the models list
* (These should be removed from models list to keep /v1/models clean)
*/
export function findExposedTargetModels(
modelMapping: string,
currentModels: string[]
): string[] {
const redirectModels = extractRedirectModels(modelMapping)
if (redirectModels.length === 0) return []
const normalizedModels = currentModels.map((m) => normalizeModelName(m))
const modelSet = new Set(normalizedModels)
return redirectModels.filter((model) =>
modelSet.has(normalizeModelName(model))
)
}
/**
* Categorize models into different sets for UI display
*/
export function categorizeModelsWithRedirect(
currentModels: string[],
redirectModels: string[]
): {
normalizedCurrentModels: Set<string>
normalizedRedirectModels: Set<string>
classificationSet: Set<string>
redirectOnlySet: Set<string>
} {
const normalizedCurrentModels = new Set(
currentModels.map((m) => normalizeModelName(m)).filter(Boolean)
)
const normalizedRedirectModels = new Set(
redirectModels.map((m) => normalizeModelName(m)).filter(Boolean)
)
const classificationSet = new Set([
...normalizedCurrentModels,
...normalizedRedirectModels,
])
const redirectOnlySet = new Set(
Array.from(normalizedRedirectModels).filter(
(m) => !normalizedCurrentModels.has(m)
)
)
return {
normalizedCurrentModels,
normalizedRedirectModels,
classificationSet,
redirectOnlySet,
}
}