import { Button, InputNumber, Select, Tag } from "antd"; import { Switch } from "@/pages/admin/ui/controls"; import { Callout } from "@/pages/admin/ui/controls"; import { RotateCcw } from "lucide-react"; import type { ReactNode } from "react"; import type { CapabilityImageSize, CapabilitySpec, OptionConstraint } from "@/services/api/logical-models"; import type { ChannelModel } from "@/services/api/wallet"; import { STANDARD_IMAGE_SIZE_VALUES } from "@/lib/model-capabilities"; export type CapabilityKind = CapabilitySpec["capability"]; export type Scalar = string | number | boolean; type CapabilityScopeEditorProps = { capability: CapabilityKind; sourceSpecs: CapabilitySpec[]; value?: CapabilitySpec; onChange?: (value: CapabilitySpec) => void; mode: "front" | "route"; }; const inputDefinitions: Record> = { text: [ { name: "image", label: "参考图片", unit: "张" }, { name: "video", label: "参考视频", unit: "个" }, ], image: [ { name: "image", label: "参考图片", unit: "张" }, { name: "mask", label: "蒙版", unit: "张" }, ], video: [ { name: "image", label: "参考图片", unit: "张" }, { name: "video", label: "参考视频", unit: "个" }, { name: "audio", label: "参考音频", unit: "个" }, ], audio: [], }; const optionDefinitions: Record> = { text: [], image: [ { name: "size", label: "画面尺寸或比例" }, { name: "quality", label: "生成质量" }, { name: "transparentBackground", label: "透明背景" }, { name: "count", label: "输出数量", unit: "张" }, ], video: [ { name: "size", label: "画面比例" }, { name: "videoSeconds", label: "视频时长", unit: "秒" }, { name: "vquality", label: "输出分辨率" }, { name: "videoGenerateAudio", label: "同步生成音频" }, { name: "videoWatermark", label: "输出水印" }, ], audio: [ { name: "audioVoice", label: "音色" }, { name: "audioFormat", label: "音频格式" }, { name: "audioSpeed", label: "语速", unit: "倍" }, { name: "audioInstructions", label: "朗读指令" }, ], }; export function capabilityLabel(value: CapabilityKind) { return { text: "文本", image: "图片", video: "视频", audio: "音频" }[value]; } export function emptyCapabilitySpec(capability: CapabilityKind): CapabilitySpec { return { version: 1, capability, operations: [], inputs: {}, options: {} }; } function imageSizeOptionValues(values: string[], allowCustom: boolean) { const concreteValues = Array.from(new Set(values.map((value) => String(normalizeScalar(value)).trim()).filter((value) => value && value !== "*"))); const result = concreteValues.length || !allowCustom ? concreteValues : [...STANDARD_IMAGE_SIZE_VALUES]; if (allowCustom) result.push("*"); return result; } export function capabilitySpecFromChannelModel(item?: ChannelModel): CapabilitySpec | null { if (!item?.capability) return null; const capability = item.capability; if (capability === "text") { const text = item.capabilityConfig?.text; if (!text) return null; return { version: 1, capability, inputs: compactInputs({ image: { min: 0, max: text.references.maxImages }, video: { min: 0, max: text.references.maxVideos }, }), options: {}, }; } if (capability === "image") { const image = item.capabilityConfig?.image; if (!image) return null; return { version: 1, capability, inputs: compactInputs({ image: { min: 0, max: image.references.maxImages }, mask: { min: 0, max: image.references.maskSupported ? 1 : 0 }, }), options: compactOptions({ size: image.size.parameter === "none" ? undefined : { values: imageSizeOptionValues(image.size.values, image.size.allowCustom) }, quality: image.quality.supported ? { values: image.quality.values } : undefined, transparentBackground: { values: image.transparentBackground.supported ? [false, true] : [false] }, count: { min: 1, max: image.maxOutputs, step: 1 }, }), imageSize: image.size.parameter === "none" ? undefined : { parameter: image.size.parameter, allowCustom: image.size.allowCustom, presets: image.size.presets }, }; } if (capability === "video") { const video = item.capabilityConfig?.video; if (!video) return null; const duration: OptionConstraint = video.duration.selection === "enum" ? { values: video.duration.values || [] } : { min: video.duration.min, max: video.duration.max, step: video.duration.step }; return { version: 1, capability, operations: video.operations, inputs: compactInputs({ image: { min: video.references.minImages, max: video.references.maxImages }, video: { min: 0, max: video.references.maxVideos }, audio: { min: 0, max: video.references.maxAudios }, }), options: compactOptions({ videoSeconds: duration, size: { values: video.ratios }, vquality: video.resolutions.length ? { values: video.resolutions } : undefined, videoGenerateAudio: { values: video.generateAudio.supported ? [false, true] : [false] }, videoWatermark: { values: video.watermark.supported ? [false, true] : [false] }, }), }; } return emptyCapabilitySpec("audio"); } export function mergeCapabilitySpecs(capability: CapabilityKind, specs: CapabilitySpec[]) { const matching = specs.filter((item) => item.capability === capability); if (matching.length === 0) return emptyCapabilitySpec(capability); const result = emptyCapabilitySpec(capability); result.operations = Array.from(new Set(matching.flatMap((item) => item.operations || []))); for (const definition of inputDefinitions[capability]) { const declared = matching.some((item) => item.inputs?.[definition.name]); if (declared) { const constraints = matching.map((item) => item.inputs?.[definition.name] || { min: 0, max: 0 }); result.inputs![definition.name] = widestCoveredInputRange(constraints); } } for (const definition of optionDefinitions[capability]) { const constraints = matching.map((item) => item.options?.[definition.name]).filter(Boolean) as OptionConstraint[]; if (constraints.length === 0) continue; if (constraints.some(isWildcardConstraint)) { const values = uniqueScalars(constraints.flatMap((item) => item.values || [])); result.options![definition.name] = { values: definition.name === "size" ? imageSizeOptionValues(values.map(String), true) : values }; continue; } if (constraints.every((item) => item.values)) { const values = uniqueScalars(constraints.flatMap((item) => item.values || [])); result.options![definition.name] = { values: definition.name === "size" ? imageSizeOptionValues(values.map(String), values.includes("*")) : values }; continue; } const minimums = constraints.map((item) => item.min).filter((value): value is number => typeof value === "number"); const maximums = constraints.map((item) => item.max).filter((value): value is number => typeof value === "number"); const steps = constraints.map((item) => item.step).filter((value): value is number => typeof value === "number" && value > 0); if (minimums.length && maximums.length) { const candidate = { min: Math.min(...minimums), max: Math.max(...maximums), step: steps.length ? Math.min(...steps) : undefined }; if (optionConstraintCovered(candidate, definition.name, matching)) { result.options![definition.name] = candidate; continue; } } const enumValues = uniqueScalars(constraints.flatMap((item) => item.values || [])); if (enumValues.length && optionConstraintCovered({ values: enumValues }, definition.name, matching)) { result.options![definition.name] = { values: enumValues }; continue; } result.options![definition.name] = preferredSourceConstraint(constraints); } result.imageSize = mergeImageSize(matching); return result; } function mergeImageSize(specs: CapabilitySpec[]): CapabilityImageSize | undefined { const parts = specs.map((item) => item.imageSize).filter((item): item is CapabilityImageSize => Boolean(item)); if (!parts.length) return undefined; const parameters = [...new Set(parts.map((item) => item.parameter).filter((value): value is NonNullable => Boolean(value)))]; const presets = [...new Map(parts.flatMap((item) => item.presets || []).map((preset) => [`${preset.tier}:${preset.ratio}:${preset.size}`, preset])).values()]; return { parameter: parameters.includes("aspect_ratio") ? "aspect_ratio" : parameters[0], allowCustom: parts.some((item) => item.allowCustom), presets, }; } /** * 旧版本把“允许自定义”保存成只有 `*` 的能力范围。 * 管理端编辑时用当前供应线路的标准值补回展示范围,保存后即可完成数据修复。 */ export function normalizeCapabilitySpecForSources(value: CapabilitySpec | undefined, sourceSpecs: CapabilitySpec[]) { if (!value) return value; const source = mergeCapabilitySpecs(value.capability, sourceSpecs); const options = { ...(value.options || {}) }; for (const [name, constraint] of Object.entries(options)) { const sourceConstraint = source.options?.[name]; if (!sourceConstraint || !isWildcardConstraint(constraint) || !sourceConstraint.values) continue; options[name] = { values: uniqueScalars([...(sourceConstraint.values || []), ...(constraint.values || [])]) }; } return { ...value, options, imageSize: value.imageSize?.presets?.length ? value.imageSize : source.imageSize || value.imageSize }; } export function capabilitySourceError(capability: CapabilityKind, sourceSpecs: CapabilitySpec[], value?: CapabilitySpec) { const matching = sourceSpecs.filter((item) => item.capability === capability); const current = value?.capability === capability ? value : emptyCapabilitySpec(capability); const currentOperations = current.operations || []; if (!matching.length) return "没有可用的渠道模型能力来源"; if (!currentOperations.length && matching.every((item) => (item.operations || []).length > 0)) { return "请选择至少一种供应线路支持的生成方式"; } if (currentOperations.some((operation) => !matching.some((item) => !(item.operations || []).length || item.operations?.includes(operation)))) { return "当前生成方式已不受供应线路支持"; } for (const [name, constraint] of Object.entries(current.inputs || {})) { if (!inputConstraintCovered(constraint, name, matching)) return `当前${inputLabel(capability, name)}范围存在供应线路无法覆盖的数量`; } for (const [name, constraint] of Object.entries(current.options || {})) { if (!optionConstraintCovered(constraint, name, matching)) return `当前${optionLabel(capability, name)}范围存在供应线路无法覆盖的值`; } return undefined; } export function CapabilityScopeEditor({ capability, sourceSpecs, value, onChange }: CapabilityScopeEditorProps) { const source = mergeCapabilitySpecs(capability, sourceSpecs); const current = value?.capability === capability ? normalizeCapabilitySpecForSources(value, sourceSpecs)! : emptyCapabilitySpec(capability); const hasSource = sourceSpecs.some((item) => item.capability === capability); const sourceInputs = inputDefinitions[capability].filter((item) => source.inputs?.[item.name]); const sourceOptions = optionDefinitions[capability].filter((item) => source.options?.[item.name]); const sourceError = capabilitySourceError(capability, sourceSpecs, current); const update = (patch: Partial) => onChange?.({ ...current, ...patch, version: 1, capability }); const reset = () => onChange?.(source); if (!hasSource) return null; return (
{sourceError ? {`${sourceError}。可重新选择,或采用全部可用范围。`} : null} {source.operations?.length ? ( ({ value: scalarKey(item), label: scalarLabel(item) }))} onChange={(keys) => onChange({ values: keys.map((key) => normalizeScalar(source.values!.find((item) => scalarKey(item) === key)) as Scalar) })} /> ) : (
)}
); } function ConstraintValueInput({ constraint, value, allowClear, onChange }: { constraint: OptionConstraint; value?: Scalar; allowClear?: boolean; onChange: (value?: Scalar) => void }) { if (constraint.values) { return (