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`
254 lines
6.5 KiB
TypeScript
Vendored
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,
|
|
}
|
|
}
|