easyai-ai-gateway/apps/web/src/pages/admin/BaseModelCapabilityEditor.tsx

844 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { Brain, Image, ListChecks, Plus, Trash2, Video } from 'lucide-react';
import { Button, Checkbox, Input, Select } from '../../components/ui';
import {
addCapabilityType,
defaultCapabilityConfig,
modelTypeGroup,
modelTypeLabel,
removeCapabilityType,
updateCapabilityConfig,
type CapabilityEditorState,
} from './base-model-capabilities';
type FieldType =
| 'boolean'
| 'number'
| 'text'
| 'list'
| 'numberList'
| 'range'
| 'ratioRange'
| 'scopedList'
| 'scopedNumberList'
| 'scopedRange'
| 'scopedNumber';
type FieldScope = 'mode' | 'resolution' | 'mixed';
type FieldDefinition = {
key: string;
label: string;
hint?: string;
placeholder?: string;
type: FieldType;
scope?: FieldScope;
};
type ValueOption = { label: string; value: string };
const textFields: FieldDefinition[] = [
{ key: 'supportTool', label: '工具调用', hint: 'function calling / tools', type: 'boolean' },
{ key: 'supportStructuredOutput', label: '结构化输出', hint: 'JSON Schema 等输出', type: 'boolean' },
{ key: 'supportThinking', label: '推理能力', hint: '支持 reasoning / thinking 参数', type: 'boolean' },
{ key: 'supportThinkingModeSwitch', label: '思考开关', hint: '可按请求切换', type: 'boolean' },
{ key: 'supportWebSearch', label: '联网搜索', type: 'boolean' },
{ key: 'max_context_tokens', label: '上下文 Token', placeholder: '128000', type: 'number' },
{ key: 'max_input_tokens', label: '最大输入 Token', placeholder: '64000', type: 'number' },
{ key: 'max_output_tokens', label: '最大输出 Token', placeholder: '8192', type: 'number' },
{ key: 'max_thinking_tokens', label: '最大思考 Token', placeholder: '32768', type: 'number' },
{ key: 'thinkingEffortLevels', label: '推理深度', hint: '声明模型支持的 reasoning_effort 取值,可填写 max 等供应商自定义值', placeholder: 'none, minimal, low, medium, high, xhigh, max', type: 'list' },
];
const embeddingFields: FieldDefinition[] = [
{ key: 'dimensions', label: '向量维度', placeholder: '1024, 768, 512', type: 'numberList' },
];
const imageFields: FieldDefinition[] = [
{ key: 'support_base64_input', label: 'Base64 输入', type: 'boolean' },
{ key: 'support_url_input', label: 'URL 输入', type: 'boolean' },
{ key: 'input_multiple_images', label: '多图输入', type: 'boolean' },
{ key: 'input_max_images_count', label: '最多输入图片', placeholder: '10', type: 'number' },
{ key: 'output_multiple_images', label: '多图输出', type: 'boolean' },
{ key: 'output_max_images_count', label: '最多输出图片', placeholder: '4', type: 'number' },
{ key: 'output_resolutions', label: '输出分辨率', placeholder: '1K, 2K, 4K', type: 'list' },
{ key: 'output_max_size', label: '最大输出尺寸', placeholder: '16777216', type: 'number' },
{ key: 'aspect_ratio_allowed', label: '支持宽高比', placeholder: '16:9, 1:1, 9:16', type: 'list' },
{ key: 'aspect_ratio_range', label: '宽高比范围', hint: '按宽/高数值限制,如 0.125 - 8', placeholder: '0.125 - 8', type: 'ratioRange' },
];
const videoFields: FieldDefinition[] = [
{ key: 'support_base64_input', label: 'Base64 输入', type: 'boolean' },
{ key: 'support_url_input', label: 'URL 输入', type: 'boolean' },
{ key: 'output_resolutions', label: '输出分辨率', hint: '可直接配置,也可按首帧/首尾帧等模式配置', placeholder: '720p, 1080p', type: 'scopedList', scope: 'mode' },
{ key: 'aspect_ratio_allowed', label: '支持宽高比', hint: '支持按分辨率配置可选宽高比', placeholder: '16:9, 9:16, 1:1', type: 'scopedList', scope: 'resolution' },
{ key: 'aspect_ratio_range', label: '宽高比范围', hint: '按宽/高数值限制,如 0.125 - 8', placeholder: '0.125 - 8', type: 'ratioRange' },
{ key: 'duration_range', label: '时长范围', hint: '支持按分辨率或模式配置不同范围', placeholder: '5 - 10', type: 'scopedRange', scope: 'mixed' },
{ key: 'duration_options', label: '可选时长', hint: '与时长范围二选一;只允许这些秒数', placeholder: '5, 10', type: 'scopedNumberList', scope: 'mixed' },
{ key: 'duration_step', label: '时长间隔', hint: '自定义时长按该秒数递增或取整', placeholder: '1', type: 'scopedNumber', scope: 'mixed' },
{ key: 'input_audio', label: '音频输入', type: 'boolean' },
{ key: 'output_audio', label: '输出音频', type: 'boolean' },
{ key: 'output_bgm', label: '背景音乐', type: 'boolean' },
{ key: 'input_first_frame', label: '首帧输入', type: 'boolean' },
{ key: 'input_last_frame', label: '尾帧输入', type: 'boolean' },
{ key: 'input_first_last_frame', label: '首尾帧模式', type: 'boolean' },
{ key: 'input_reference_generate_single', label: '单参考图', type: 'boolean' },
{ key: 'input_reference_generate_multiple', label: '多参考图', type: 'boolean' },
{ key: 'input_smart_multi_frame', label: '智能多帧', type: 'boolean' },
{ key: 'smart_multi_frame_range', label: '智能多帧数量', placeholder: '2 - 9', type: 'range' },
{ key: 'smart_multi_frame_mode', label: '智能多帧模式', placeholder: 'native 或 stitch', type: 'text' },
{ key: 'smart_multi_frame_duration_range', label: '智能多帧每段时长', placeholder: '2 - 7', type: 'range' },
{ key: 'support_video_effect_template', label: '视频特效模板', type: 'boolean' },
{ key: 'supported_modes', label: '支持模式', placeholder: 'text_to_video, image_reference', type: 'list' },
{ key: 'max_videos', label: '最多视频', placeholder: '1', type: 'number' },
{ key: 'max_images', label: '最多图片', placeholder: '4', type: 'number' },
{ key: 'max_audios', label: '最多音频', placeholder: '1', type: 'number' },
{ key: 'max_elements', label: '最多主体', placeholder: '4', type: 'number' },
{ key: 'max_images_and_elements', label: '图片主体合计', placeholder: '4', type: 'number' },
{ key: 'support_instruction_edit', label: '指令编辑', type: 'boolean' },
];
const model3dFields: FieldDefinition[] = [
{ key: 'support_texture', label: '纹理生成', type: 'boolean' },
{ key: 'support_part_generation', label: '分部生成', type: 'boolean' },
{ key: 'support_image_autofix', label: '图片自动优化', type: 'boolean' },
{ key: 'support_quad', label: '四边面输出', type: 'boolean' },
{ key: 'support_smart_low_poly', label: '智能低模', type: 'boolean' },
{ key: 'geometry_quality_options', label: '几何质量', placeholder: 'standard, detailed', type: 'list' },
{ key: 'max_face_limit', label: '三角面上限', placeholder: '20000', type: 'number' },
{ key: 'max_face_limit_quad', label: '四边面上限', placeholder: '10000', type: 'number' },
];
export function BaseModelCapabilityEditor(props: {
modelType: string;
typeOptions: string[];
value: CapabilityEditorState;
onChange: (value: CapabilityEditorState) => void;
}) {
const enabledTypes = props.value.types;
const [activeType, setActiveType] = useState(enabledTypes[0] ?? props.modelType);
const [pendingType, setPendingType] = useState('');
const availableTypes = useMemo(
() => Array.from(new Set([props.modelType, ...props.typeOptions].filter(Boolean))),
[props.modelType, props.typeOptions],
);
const addableTypes = availableTypes.filter((type) => !enabledTypes.includes(type));
const activeConfig = props.value.typeConfigs[activeType] ?? defaultCapabilityConfig(activeType);
const fieldGroups = capabilityFieldGroups(activeType);
const capabilitySummary = useMemo(
() => summarizeEnabledCapabilities(enabledTypes, props.value.typeConfigs),
[enabledTypes, props.value.typeConfigs],
);
useEffect(() => {
if (!enabledTypes.includes(activeType)) setActiveType(enabledTypes[0] ?? props.modelType);
}, [activeType, enabledTypes, props.modelType]);
function addType() {
const nextType = pendingType || addableTypes[0];
if (!nextType) return;
props.onChange(addCapabilityType(props.value, nextType));
setActiveType(nextType);
setPendingType('');
}
function removeType(type: string) {
const next = removeCapabilityType(props.value, type);
props.onChange(next);
setActiveType(next.types[0] ?? props.modelType);
}
function patchConfig(key: string, value: unknown) {
const nextConfig = { ...activeConfig };
if (value === undefined) delete nextConfig[key];
else nextConfig[key] = value;
const exclusiveKey = exclusiveCapabilityFields[key];
if (exclusiveKey && hasMeaningfulValue(value)) delete nextConfig[exclusiveKey];
props.onChange(updateCapabilityConfig(props.value, activeType, sanitizeCapabilityConfig(nextConfig)));
}
return (
<section className="baseCapabilityEditor spanTwo bg-card text-foreground">
<header>
<div>
<strong></strong>
<span></span>
</div>
</header>
<div className="capabilityWorkbench">
<aside className="capabilitySidebar">
<div className="capabilitySidebarTitle">
<ListChecks size={15} />
<strong></strong>
</div>
<div className="capabilityList">
{enabledTypes.map((type) => (
<button className="capabilityListItem" data-active={type === activeType} key={type} type="button" onClick={() => setActiveType(type)}>
<span>
<strong>{modelTypeLabel(type)}</strong>
<small>{modelTypeGroup(type)} / {type}</small>
</span>
{enabledTypes.length > 1 && (
<Trash2
size={14}
onClick={(event) => {
event.stopPropagation();
removeType(type);
}}
/>
)}
</button>
))}
</div>
<div className="capabilityAddBox">
<Select size="sm" value={pendingType} onChange={(event) => setPendingType(event.target.value)}>
<option value="">{addableTypes.length ? '选择要添加的能力' : '没有可添加能力'}</option>
{addableTypes.map((type) => <option key={type} value={type}>{modelTypeLabel(type)} / {type}</option>)}
</Select>
<Button size="sm" type="button" variant="outline" disabled={!addableTypes.length} onClick={addType}>
<Plus size={14} />
</Button>
</div>
<div className="capabilitySummary">
<strong></strong>
<p>{capabilitySummary}</p>
</div>
</aside>
<div className="capabilityFormPanel">
{fieldGroups.length ? (
fieldGroups.map((group) => (
<CapabilityFormGroup key={group.title} icon={group.icon} title={group.title}>
{group.fields.map((field) => (
<CapabilityFormRow
key={field.key}
config={activeConfig}
defaultConfig={defaultCapabilityConfig(activeType)}
field={field}
onChange={(value) => patchConfig(field.key, value)}
/>
))}
</CapabilityFormGroup>
))
) : (
<div className="capabilityPreserveNote">
<strong></strong>
<span></span>
</div>
)}
<CapabilityFormGroup icon={<Brain size={15} />} title="保留字段">
<div className="capabilityPreserveNote">
<strong>{preservedConfigCount(activeConfig, fieldGroups)} </strong>
<span> {activeType} </span>
</div>
</CapabilityFormGroup>
</div>
</div>
</section>
);
}
function CapabilityFormGroup(props: { children: ReactNode; icon: ReactNode; title: string }) {
return (
<section className="capabilityFormGroup">
<header>
{props.icon}
<strong>{props.title}</strong>
</header>
<div className="capabilityFormRows">{props.children}</div>
</section>
);
}
function CapabilityFormRow(props: {
config: Record<string, unknown>;
defaultConfig: Record<string, unknown>;
field: FieldDefinition;
onChange: (value: unknown) => void;
}) {
const value = props.config[props.field.key];
return (
<div className="capabilityFormRow">
<div>
<strong>{props.field.label}</strong>
{props.field.hint && <small title={props.field.hint}>{props.field.hint}</small>}
</div>
<FieldControl
config={props.config}
defaultValue={props.defaultConfig[props.field.key]}
field={props.field}
value={value}
onChange={props.onChange}
/>
</div>
);
}
function FieldControl(props: {
config: Record<string, unknown>;
defaultValue: unknown;
field: FieldDefinition;
value: unknown;
onChange: (value: unknown) => void;
}) {
if (props.field.type === 'boolean') {
return <BooleanFieldControl defaultText={booleanDefaultText(props.field, props.defaultValue)} value={props.value} onChange={props.onChange} />;
}
if (props.field.type === 'range') {
const range = Array.isArray(props.value) ? props.value : [];
return (
<div className="capabilityRange">
<Input size="sm" min="0" step="1" type="number" value={stringValue(range[0])} placeholder="最小" onChange={(event) => props.onChange([numberValue(event.target.value), numberValue(range[1])])} />
<Input size="sm" min="0" step="1" type="number" value={stringValue(range[1])} placeholder="最大" onChange={(event) => props.onChange([numberValue(range[0]), numberValue(event.target.value)])} />
</div>
);
}
if (props.field.type === 'ratioRange') {
const range = Array.isArray(props.value) ? props.value : [];
return (
<div className="capabilityRange">
<Input size="sm" min="0" step="0.001" type="number" value={stringValue(range[0])} placeholder="最小宽/高" onChange={(event) => props.onChange([numberValue(event.target.value), numberValue(range[1])])} />
<Input size="sm" min="0" step="0.001" type="number" value={stringValue(range[1])} placeholder="最大宽/高" onChange={(event) => props.onChange([numberValue(range[0]), numberValue(event.target.value)])} />
</div>
);
}
if (props.field.type === 'number') {
return <Input size="sm" min="0" step="1" type="number" value={stringValue(props.value)} placeholder={props.field.placeholder} onChange={(event) => props.onChange(numberValue(event.target.value))} />;
}
if (isScopedField(props.field)) {
return <ScopedFieldControl field={props.field} value={props.value} config={props.config} onChange={props.onChange} />;
}
if (props.field.type === 'text') {
return <Input size="sm" value={stringValue(props.value)} placeholder={props.field.placeholder} onChange={(event) => props.onChange(event.target.value)} />;
}
return <MultiValueControl config={props.config} field={props.field} value={props.value} onChange={props.onChange} />;
}
function BooleanFieldControl(props: {
defaultText: string;
onChange: (value: unknown) => void;
value: unknown;
}) {
return (
<Select
className="capabilityBooleanSelect"
size="sm"
value={booleanSelectValue(props.value)}
onChange={(event) => props.onChange(booleanValueFromSelect(event.target.value))}
>
<option value="unset">{props.defaultText}</option>
<option value="true"></option>
<option value="false"></option>
</Select>
);
}
function ScopedFieldControl(props: {
config: Record<string, unknown>;
field: FieldDefinition;
value: unknown;
onChange: (value: unknown) => void;
}) {
const grouped = isPlainRecord(props.value);
const directValue = grouped ? defaultDirectValue(props.field) : props.value;
const scopeOptions = scopeOptionsForField(props.field, props.config, props.value);
const entries = grouped ? Object.entries(props.value as Record<string, unknown>) : [];
const canGroup = scopeOptions.length > 0 || entries.length > 0;
function switchMode(nextGrouped: boolean) {
if (nextGrouped === grouped) return;
if (!nextGrouped) {
props.onChange(entries[0]?.[1] ?? defaultDirectValue(props.field));
return;
}
const firstKey = firstAvailableScopeKey(scopeOptions, []);
props.onChange({ [firstKey]: normalizeScopedValue(props.field, props.value) });
}
function setEntryKey(oldKey: string, nextKey: string) {
if (!nextKey || !grouped) return;
const next = { ...(props.value as Record<string, unknown>) };
const current = next[oldKey];
delete next[oldKey];
next[nextKey] = current;
props.onChange(next);
}
function setEntryValue(key: string, nextValue: unknown) {
props.onChange({ ...(props.value as Record<string, unknown>), [key]: nextValue });
}
function removeEntry(key: string) {
const next = { ...(props.value as Record<string, unknown>) };
delete next[key];
props.onChange(Object.keys(next).length ? next : defaultDirectValue(props.field));
}
function addEntry() {
const used = entries.map(([key]) => key);
const nextKey = firstAvailableScopeKey(scopeOptions, used);
const current = grouped ? (props.value as Record<string, unknown>) : {};
props.onChange({ ...current, [nextKey]: defaultDirectValue(props.field) });
}
return (
<div className="capabilityScopedControl">
<div className="capabilitySegmented" role="tablist" aria-label={`${props.field.label}配置方式`}>
<button type="button" data-active={!grouped} onClick={() => switchMode(false)}></button>
<button type="button" disabled={!canGroup} data-active={grouped} onClick={() => switchMode(true)}></button>
</div>
{!grouped ? (
<ScopedValueInput config={props.config} field={props.field} value={directValue} placeholder={props.field.placeholder} onChange={props.onChange} />
) : (
<div className="capabilityScopedRows">
{entries.map(([key, value]) => (
<div className="capabilityScopedRow" key={key}>
<Select size="sm" value={key} onChange={(event) => setEntryKey(key, event.target.value)}>
{ensureScopeOption(scopeOptions, key).map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</Select>
<ScopedValueInput config={props.config} field={props.field} value={value} placeholder={props.field.placeholder} onChange={(nextValue) => setEntryValue(key, nextValue)} />
<Button type="button" size="icon" variant="ghost" aria-label="删除条件" onClick={() => removeEntry(key)}>
<Trash2 size={14} />
</Button>
</div>
))}
<Button type="button" size="sm" variant="outline" onClick={addEntry}>
<Plus size={14} />
</Button>
</div>
)}
</div>
);
}
function ScopedValueInput(props: {
config: Record<string, unknown>;
field: FieldDefinition;
onChange: (value: unknown) => void;
placeholder?: string;
value: unknown;
}) {
if (props.field.type === 'scopedRange') {
const range = Array.isArray(props.value) ? props.value : [];
return (
<div className="capabilityRange">
<Input size="sm" min="0" step="1" type="number" value={stringValue(range[0])} placeholder="最小" onChange={(event) => props.onChange([numberValue(event.target.value), numberValue(range[1])])} />
<Input size="sm" min="0" step="1" type="number" value={stringValue(range[1])} placeholder="最大" onChange={(event) => props.onChange([numberValue(range[0]), numberValue(event.target.value)])} />
</div>
);
}
if (props.field.type === 'scopedNumber') {
return <Input size="sm" min="0" step="1" type="number" value={stringValue(props.value)} placeholder={props.placeholder} onChange={(event) => props.onChange(numberValue(event.target.value))} />;
}
return <MultiValueControl config={props.config} field={props.field} value={props.value} onChange={props.onChange} />;
}
function MultiValueControl(props: {
config: Record<string, unknown>;
field: FieldDefinition;
onChange: (value: unknown) => void;
value: unknown;
}) {
const [customValue, setCustomValue] = useState('');
const selected = valueArray(props.value);
const options = multiOptionsForField(props.field, props.config, props.value);
const selectedSet = new Set(selected);
function emit(nextValues: string[]) {
const values = uniqueStrings(nextValues);
props.onChange(isNumberListField(props.field) ? values.map(Number).filter(Number.isFinite) : values);
}
function toggle(value: string) {
emit(selectedSet.has(value) ? selected.filter((item) => item !== value) : [...selected, value]);
}
function addCustom() {
const values = customValue.split(/[,]/).map((item) => item.trim()).filter(Boolean);
if (!values.length) return;
emit([...selected, ...values]);
setCustomValue('');
}
return (
<div className="capabilityMultiValue">
<div className="capabilityMultiOptions">
{options.map((option) => (
<label key={option.value} className="capabilityCheckboxOption">
<Checkbox
checked={selectedSet.has(option.value)}
onCheckedChange={() => toggle(option.value)}
/>
{option.label}
</label>
))}
</div>
<div className="capabilityCustomValue">
<Input
size="sm"
value={customValue}
placeholder={props.field.placeholder ? `自定义:${props.field.placeholder}` : '自定义值'}
onChange={(event) => setCustomValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addCustom();
}
}}
/>
<Button className="capabilityAddValueButton" type="button" size="sm" variant="outline" onClick={addCustom}></Button>
</div>
</div>
);
}
type ScopeOption = { label: string; requires?: string; value: string };
const modeScopeOptions: ScopeOption[] = [
{ value: 'input_first_frame', label: '首帧模式 (input_first_frame)', requires: 'input_first_frame' },
{ value: 'input_first_last_frame', label: '首尾帧模式 (input_first_last_frame)', requires: 'input_first_last_frame' },
{ value: 'input_last_frame', label: '尾帧模式 (input_last_frame)', requires: 'input_last_frame' },
{ value: 'input_smart_multi_frame', label: '智能多帧 (input_smart_multi_frame)', requires: 'input_smart_multi_frame' },
];
const videoResolutionOptions = ['480p', '720p', '1080p', '1440p', '2160p'];
const imageResolutionOptions = ['1K', '2K', '3K', '4K', '8K'];
const videoAspectRatioOptions = ['adaptive', '16:9', '9:16', '1:1', '4:3', '3:4'];
const imageAspectRatioOptions = [
'adaptive',
'1:1',
'16:9',
'9:16',
'4:3',
'3:4',
'3:2',
'2:3',
'5:4',
'4:5',
'5:3',
'3:5',
'21:9',
'9:21',
'2:1',
'1:2',
'4:1',
'1:4',
'8:1',
'1:8',
'7:4',
'4:7',
];
const thinkingEffortOptions = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
const omniVideoModeOptions = ['text_to_video', 'image_reference', 'element_reference', 'first_last_frame', 'video_reference', 'video_edit', 'multi_shot'];
const durationOptionValues = ['1', '2', '3', '4', '5', '6', '8', '10', '15', '20', '25', '30'];
const exclusiveCapabilityFields: Record<string, string> = {
duration_range: 'duration_options',
duration_options: 'duration_range',
};
function isScopedField(field: FieldDefinition) {
return field.type === 'scopedList' || field.type === 'scopedNumberList' || field.type === 'scopedRange' || field.type === 'scopedNumber';
}
function scopeOptionsForField(field: FieldDefinition, config: Record<string, unknown>, value: unknown): ScopeOption[] {
const options: ScopeOption[] = [];
if (field.scope === 'mode' || field.scope === 'mixed') {
options.push(...modeScopeOptions.filter((option) => !option.requires || config[option.requires] === true));
flattenStringValues(config.supported_modes).forEach((mode) => options.push({ value: mode, label: `${friendlyScopeLabel(mode)} (${mode})` }));
}
if (field.scope === 'resolution' || field.scope === 'mixed') {
[...flattenStringValues(config.output_resolutions), ...videoResolutionOptions].forEach((resolution) => {
options.push({ value: resolution, label: `${friendlyScopeLabel(resolution)} (${resolution})` });
});
}
if (isPlainRecord(value)) {
Object.keys(value).forEach((key) => options.push({ value: key, label: `${friendlyScopeLabel(key)} (${key})` }));
}
return dedupeScopeOptions(options);
}
function ensureScopeOption(options: ScopeOption[], value: string) {
if (!value || options.some((option) => option.value === value)) return options;
return [...options, { value, label: `${friendlyScopeLabel(value)} (${value})` }];
}
function firstAvailableScopeKey(options: ScopeOption[], used: string[]) {
return options.find((option) => !used.includes(option.value))?.value ?? `condition_${used.length + 1}`;
}
function sanitizeCapabilityConfig(config: Record<string, unknown>) {
const supportedModeKeys = new Set([
...modeScopeOptions.filter((option) => !option.requires || config[option.requires] === true).map((option) => option.value),
...flattenStringValues(config.supported_modes),
]);
const keepScopedModeKey = (key: string) => !isKnownModeScopeKey(key) || supportedModeKeys.has(key);
return Object.fromEntries(
Object.entries(config).map(([key, value]) => {
if (!isScopedConfigKey(key) || !isPlainRecord(value)) return [key, value];
const filtered = Object.fromEntries(Object.entries(value).filter(([scopeKey]) => keepScopedModeKey(scopeKey)));
if (Object.keys(filtered).length > 0) return [key, filtered];
return [key, defaultDirectValueForConfigKey(key)];
}),
);
}
function isScopedConfigKey(key: string) {
return key === 'output_resolutions' || key === 'aspect_ratio_allowed' || key === 'duration_range' || key === 'duration_options' || key === 'duration_step';
}
function isKnownModeScopeKey(key: string) {
return modeScopeOptions.some((option) => option.value === key) || ['text_to_video', 'image_reference', 'element_reference', 'first_last_frame', 'video_reference', 'video_edit', 'multi_shot'].includes(key);
}
function defaultDirectValueForConfigKey(key: string) {
if (key === 'duration_range') return [0, 0];
if (key === 'duration_step') return 0;
return [];
}
function multiOptionsForField(field: FieldDefinition, config: Record<string, unknown>, value: unknown): ValueOption[] {
let options: string[] = [];
if (field.key === 'output_resolutions') options = field.scope ? videoResolutionOptions : imageResolutionOptions;
if (field.key === 'aspect_ratio_allowed') options = field.scope ? videoAspectRatioOptions : imageAspectRatioOptions;
if (field.key === 'supported_modes') options = omniVideoModeOptions;
if (field.key === 'thinkingEffortLevels') options = thinkingEffortOptions;
if (field.key === 'duration_options') options = durationOptionValues;
if (field.key === 'dimensions') options = ['3072', '1536', '1024', '768', '512', '256'];
if (field.key === 'geometry_quality_options') options = ['standard', 'detailed'];
options = orderedOptionValues(options, [...valueArray(value), ...flattenStringValues(config[field.key])], field);
return options.map((item) => ({ value: item, label: friendlyOptionLabel(field, item) }));
}
function orderedOptionValues(baseOptions: string[], extraOptions: string[], field: FieldDefinition) {
const base = uniqueStrings(baseOptions);
const baseSet = new Set(base);
const extras = uniqueStrings(extraOptions).filter((item) => !baseSet.has(item));
if (field.key === 'duration_options') {
return uniqueStrings([...base, ...extras]).sort((a, b) => Number(a) - Number(b));
}
const sortedExtras = isNumberListField(field)
? extras.sort((a, b) => Number(a) - Number(b))
: extras.sort((a, b) => a.localeCompare(b));
return [...base, ...sortedExtras];
}
function friendlyOptionLabel(field: FieldDefinition, value: string) {
if (field.key === 'duration_options') return `${value}`;
if (field.key === 'supported_modes') return friendlyScopeLabel(value);
return value;
}
function valueArray(value: unknown): string[] {
if (Array.isArray(value)) return value.map(String).filter(Boolean);
if (value === undefined || value === null || value === '') return [];
return [String(value)];
}
function isNumberListField(field: FieldDefinition) {
return field.type === 'numberList' || field.type === 'scopedNumberList';
}
function friendlyScopeLabel(key: string) {
const labels: Record<string, string> = {
input_first_frame: '首帧模式',
input_first_last_frame: '首尾帧模式',
input_last_frame: '尾帧模式',
input_smart_multi_frame: '智能多帧',
text_to_video: '文生视频',
image_reference: '图片参考',
element_reference: '主体参考',
first_last_frame: '首尾帧',
video_reference: '视频参考',
video_edit: '视频编辑',
multi_shot: '多镜头',
};
return labels[key] ?? key;
}
function dedupeScopeOptions(options: ScopeOption[]) {
const seen = new Set<string>();
return options.filter((option) => {
if (!option.value || seen.has(option.value)) return false;
seen.add(option.value);
return true;
});
}
function flattenStringValues(value: unknown): string[] {
if (Array.isArray(value)) return value.map(String).filter(Boolean);
if (!isPlainRecord(value)) return [];
return Object.values(value).flatMap((item) => Array.isArray(item) ? item.map(String).filter(Boolean) : []);
}
function defaultDirectValue(field: FieldDefinition) {
if (field.type === 'scopedRange') return [0, 0];
if (field.type === 'scopedNumber') return 0;
return [];
}
function normalizeScopedValue(field: FieldDefinition, value: unknown) {
if (isPlainRecord(value)) return Object.values(value)[0] ?? defaultDirectValue(field);
if (field.type === 'scopedRange') return Array.isArray(value) ? value : defaultDirectValue(field);
if (field.type === 'scopedNumber') return typeof value === 'number' || typeof value === 'string' ? numberValue(value) : 0;
return Array.isArray(value) ? value : defaultDirectValue(field);
}
function hasMeaningfulValue(value: unknown): boolean {
if (value === undefined || value === null || value === '') return false;
if (Array.isArray(value)) return value.length > 0;
if (isPlainRecord(value)) return Object.keys(value).length > 0;
return true;
}
function capabilityFieldGroups(type: string): Array<{ title: string; icon: ReactNode; fields: FieldDefinition[] }> {
if (isTextType(type)) return [{ title: '文本默认能力', icon: <Brain size={15} />, fields: textFields }];
if (type === 'text_embedding') return [{ title: '向量默认能力', icon: <Brain size={15} />, fields: embeddingFields }];
if (isImageType(type)) return [{ title: '图像默认能力', icon: <Image size={15} />, fields: imageFieldsFor(type) }];
if (isVideoType(type)) return [{ title: '视频默认能力', icon: <Video size={15} />, fields: videoFieldsFor(type) }];
if (isModel3dType(type)) return [{ title: '3D 默认能力', icon: <Brain size={15} />, fields: model3dFields }];
return [];
}
function imageFieldsFor(type: string) {
if (type === 'image_edit') return imageFields;
return imageFields.filter((field) => !field.key.startsWith('input_'));
}
function videoFieldsFor(type: string) {
if (type === 'video_generate') return videoFields.filter((field) => !field.key.startsWith('input_') && field.key !== 'supported_modes');
if (type === 'image_to_video') return videoFields.filter((field) => field.key !== 'supported_modes' && field.key !== 'max_audios');
if (type === 'video_edit') return videoFields.filter((field) => !field.key.includes('reference_generate') && field.key !== 'supported_modes');
return videoFields;
}
function preservedConfigCount(config: Record<string, unknown>, groups: Array<{ fields: FieldDefinition[] }>) {
const shown = new Set(groups.flatMap((group) => group.fields.map((field) => field.key)));
return Object.keys(config).filter((key) => !shown.has(key)).length;
}
function summarizeEnabledCapabilities(types: string[], typeConfigs: Record<string, Record<string, unknown>>) {
if (!types.length) return '暂无启用能力,添加能力后会在这里显示模型支持范围。';
const groups = uniqueStrings(types.map(modelTypeGroup));
const names = types.map(modelTypeLabel);
const nameText = names.length > 3 ? `${names.slice(0, 3).join('、')}${names.length} 项能力` : names.join('、');
const highlights = capabilitySummaryHighlights(types, typeConfigs);
const base = `已启用 ${types.length} 项能力,覆盖${groups.join('、')}。包含 ${nameText}`;
return highlights.length ? `${base}${highlights.join('')}` : `${base}`;
}
function capabilitySummaryHighlights(types: string[], typeConfigs: Record<string, Record<string, unknown>>) {
const highlights: string[] = [];
const textLimits = uniqueStrings(types.flatMap((type) => stringValue(typeConfigs[type]?.max_context_tokens ? typeConfigs[type].max_context_tokens : '').split(',').filter(Boolean)));
if (textLimits.length) highlights.push(`上下文 ${textLimits[0]} Token`);
const resolutions = uniqueStrings(types.flatMap((type) => flattenStringValues(typeConfigs[type]?.output_resolutions)));
if (resolutions.length) highlights.push(`输出分辨率 ${resolutions.slice(0, 4).join('/')}`);
const aspectRatioRanges = uniqueStrings(types.flatMap((type) => aspectRatioRangeSummaryValues(typeConfigs[type])));
if (aspectRatioRanges.length) highlights.push(`宽高比 ${aspectRatioRanges.slice(0, 2).join('/')}`);
const durations = uniqueStrings(types.flatMap((type) => durationSummaryValues(typeConfigs[type])));
if (durations.length) highlights.push(`时长 ${durations.slice(0, 3).join('/')}`);
const booleanLabels = types.flatMap((type) => enabledBooleanLabels(typeConfigs[type]));
if (booleanLabels.length) highlights.push(`支持${uniqueStrings(booleanLabels).slice(0, 3).join('、')}`);
return highlights.slice(0, 4);
}
function aspectRatioRangeSummaryValues(config?: Record<string, unknown>) {
const range = config?.aspect_ratio_range;
if (!Array.isArray(range) || range.length < 2) return [];
return [`${range[0]}-${range[1]}`];
}
function durationSummaryValues(config?: Record<string, unknown>) {
if (!config) return [];
const options = flattenStringValues(config.duration_options).map((item) => `${item}`);
if (options.length) return options;
const range = config.duration_range;
if (Array.isArray(range) && range.length >= 2) return [`${range[0]}-${range[1]}`];
if (!isPlainRecord(range)) return [];
return Object.values(range).flatMap((item) => Array.isArray(item) && item.length >= 2 ? [`${item[0]}-${item[1]}`] : []);
}
function enabledBooleanLabels(config?: Record<string, unknown>) {
if (!config) return [];
const labels: Record<string, string> = {
supportTool: '工具调用',
supportStructuredOutput: '结构化输出',
supportThinking: '思考能力',
supportWebSearch: '联网搜索',
support_base64_input: 'Base64 输入',
support_url_input: 'URL 输入',
input_multiple_images: '多图输入',
output_multiple_images: '多图输出',
input_audio: '音频输入',
output_audio: '输出音频',
output_bgm: '背景音乐',
input_first_frame: '首帧输入',
input_first_last_frame: '首尾帧模式',
input_reference_generate_single: '单参考图',
input_reference_generate_multiple: '多参考图',
support_video_effect_template: '视频特效模板',
};
return Object.entries(labels).filter(([key]) => config[key] === true).map(([, label]) => label);
}
function isTextType(type: string) {
return ['text_generate', 'image_analysis', 'video_understanding', 'audio_understanding', 'omni', 'tools_call'].includes(type);
}
function isImageType(type: string) {
return type === 'image_generate' || type === 'image_edit';
}
function isVideoType(type: string) {
return ['video_generate', 'image_to_video', 'video_edit', 'omni_video'].includes(type);
}
function isModel3dType(type: string) {
return ['text_to_model', 'image_to_model', 'multiview_to_model', 'mesh_edit'].includes(type);
}
function numberValue(value: unknown) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function booleanSelectValue(value: unknown) {
if (value === true) return 'true';
if (value === false) return 'false';
return 'unset';
}
function booleanDefaultText(field: FieldDefinition, defaultValue: unknown) {
if (field.key === 'support_base64_input' || field.key === 'support_url_input') return '默认自动判断';
return defaultValue === true ? '默认支持' : '默认不支持';
}
function booleanValueFromSelect(value: string) {
if (value === 'true') return true;
if (value === 'false') return false;
return undefined;
}
function stringValue(value: unknown) {
return value === undefined || value === null ? '' : String(value);
}
function uniqueStrings(values: string[]) {
return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean)));
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && !Array.isArray(value) && typeof value === 'object';
}