844 lines
37 KiB
TypeScript
844 lines
37 KiB
TypeScript
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';
|
||
}
|