Files
ttstd-infinite-canvas/web/src/components/model-capability-editor.tsx

218 lines
21 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 { Input, InputNumber, Segmented, Select, Switch } from "antd";
import type { ReactNode } from "react";
import { defaultImageCapabilityConfig, defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type ImageCapabilityConfig, type ModelCapabilityConfig, type TextCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import type { ModelProtocol } from "@/lib/model-protocols";
import { VIDEO_RESOLUTION_CAPABILITY_OPTIONS } from "@/lib/video-generation-options";
const ratioOptions = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"];
const operationOptions = [
{ label: "文生视频", value: "text_to_video" },
{ label: "图生视频", value: "image_to_video" },
{ label: "视频续写", value: "extend" },
{ label: "局部修改", value: "inpaint" },
{ label: "元素替换", value: "replace_element" },
{ label: "运镜调整", value: "camera_motion" },
{ label: "风格迁移", value: "style_transfer" },
{ label: "音频生视频", value: "audio_to_video" },
];
type Props = {
value?: ModelCapabilityConfig;
onChange?: (value: ModelCapabilityConfig) => void;
protocol?: ModelProtocol;
capability?: "text" | "image" | "video";
model?: string;
disabled?: boolean;
};
export function ModelCapabilityEditor({ value, onChange, protocol, capability = "video", model = "", disabled = false }: Props) {
if (capability === "text") {
return <TextCapabilityEditor value={value} onChange={onChange} protocol={protocol} disabled={disabled} />;
}
if (capability === "image") {
return <ImageCapabilityEditor value={value} onChange={onChange} protocol={protocol} model={model} disabled={disabled} />;
}
const profile = normalizeModelCapabilityConfig(value || defaultModelCapabilityConfig(protocol)).video!;
const update = (patch: Partial<VideoCapabilityConfig>) => onChange?.({ version: 1, video: { ...profile, ...patch } });
const updateReferences = (patch: Partial<VideoCapabilityConfig["references"]>) => update({ references: { ...profile.references, ...patch } });
const updateDuration = (patch: Partial<VideoCapabilityConfig["duration"]>) => update({ duration: { ...profile.duration, ...patch } });
const durationValues = (profile.duration.values || []).join(",");
const resolutionOptions = Array.from(new Set([...VIDEO_RESOLUTION_CAPABILITY_OPTIONS, ...profile.resolutions]));
return (
<div className="admin-capability-editor space-y-3 rounded-md bg-muted/10 p-3">
<div className="flex items-center justify-between gap-3">
<div><div className="text-sm font-medium">视频能力参数</div><div className="mt-0.5 text-[var(--fs-tiny)] text-foreground/48">这些参数会同步到创造页、画布和生成校验</div></div>
<span className="text-[var(--fs-tiny)] text-foreground/40">协议模板可继续调整</span>
</div>
<CapabilityGroup title="引用限制">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<NumberField label="提示词字符数" value={profile.references.promptMaxChars} min={1} disabled={disabled} onChange={(value) => updateReferences({ promptMaxChars: value || 1 })} />
<NumberField label="最少图片引用" value={profile.references.minImages} min={0} max={profile.references.maxImages} disabled={disabled} onChange={(value) => updateReferences({ minImages: value || 0 })} />
<NumberField label="最大图片引用" value={profile.references.maxImages} min={0} disabled={disabled} onChange={(value) => { const maxImages = value || 0; updateReferences({ maxImages, minImages: Math.min(profile.references.minImages, maxImages) }); }} />
<NumberField label="图片上限 MB" value={bytesToMB(profile.references.maxImageBytes)} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxImageBytes: mbToBytes(value) })} />
<NumberField label="最大视频引用" value={profile.references.maxVideos} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxVideos: value || 0 })} />
<NumberField label="视频上限 MB" value={bytesToMB(profile.references.maxVideoBytes)} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxVideoBytes: mbToBytes(value) })} />
<NumberField label="视频最长秒数" value={profile.references.maxVideoDurationSeconds} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxVideoDurationSeconds: value || 0 })} />
<NumberField label="最大音频引用" value={profile.references.maxAudios} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxAudios: value || 0 })} />
<NumberField label="音频上限 MB" value={bytesToMB(profile.references.maxAudioBytes)} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxAudioBytes: mbToBytes(value) })} />
<NumberField label="音频最长秒数" value={profile.references.maxAudioDurationSeconds} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxAudioDurationSeconds: value || 0 })} />
</div>
</CapabilityGroup>
<CapabilityGroup title="视频时长">
<Segmented block disabled={disabled} value={profile.duration.selection} options={[{ label: "范围", value: "range" }, { label: "固定值", value: "enum" }]} onChange={(value) => updateDuration(value === "enum" ? { selection: "enum", values: profile.duration.values?.length ? profile.duration.values : [profile.duration.default] } : { selection: "range", min: profile.duration.min || 1, max: profile.duration.max || 15, step: profile.duration.step || 1 })} />
{profile.duration.selection === "enum" ? (
<Field label="固定时长(秒)"><Input disabled={disabled} value={durationValues} placeholder="例如:5,10" onChange={(event) => updateDuration({ values: parseIntegerList(event.target.value), default: parseIntegerList(event.target.value)[0] || profile.duration.default })} /></Field>
) : (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<NumberField label="最短" value={profile.duration.min} min={1} disabled={disabled} onChange={(value) => updateDuration({ min: value || 1 })} />
<NumberField label="最长" value={profile.duration.max} min={1} disabled={disabled} onChange={(value) => updateDuration({ max: value || 1 })} />
<NumberField label="步长" value={profile.duration.step} min={1} disabled={disabled} onChange={(value) => updateDuration({ step: value || 1 })} />
<NumberField label="默认" value={profile.duration.default} min={1} disabled={disabled} onChange={(value) => updateDuration({ default: value || 1 })} />
</div>
)}
</CapabilityGroup>
<CapabilityGroup title="输出参数">
<div className="grid gap-2 sm:grid-cols-2">
<Field label="画面比例"><Select mode="multiple" className="w-full" disabled={disabled} value={profile.ratios} options={ratioOptions.map((item) => ({ label: item, value: item }))} onChange={(ratios) => update({ ratios, defaultRatio: ratios.includes(profile.defaultRatio) ? profile.defaultRatio : ratios[0] || "16:9" })} /></Field>
<Field label="默认比例"><Select className="w-full" disabled={disabled} value={profile.defaultRatio} options={profile.ratios.map((item) => ({ label: item, value: item }))} onChange={(defaultRatio) => update({ defaultRatio })} /></Field>
<Field label="输出分辨率"><Select mode="tags" className="admin-capability-tags w-full" disabled={disabled} value={profile.resolutions} tokenSeparators={[","]} placeholder="选择标准档位或输入 768p 等模型专属值" options={resolutionOptions.map((item) => ({ label: item.toUpperCase(), value: item }))} onChange={(resolutions) => update({ resolutions, defaultResolution: resolutions.includes(profile.defaultResolution) ? profile.defaultResolution : resolutions[0] || "" })} /></Field>
<Field label="默认分辨率"><Select className="w-full" disabled={disabled} value={profile.defaultResolution} options={profile.resolutions.map((item) => ({ label: item.toUpperCase(), value: item }))} onChange={(defaultResolution) => update({ defaultResolution })} /></Field>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<BooleanField label="同步音频" value={profile.generateAudio} disabled={disabled} onChange={(generateAudio) => update({ generateAudio })} />
<BooleanField label="水印" value={profile.watermark} disabled={disabled} onChange={(watermark) => update({ watermark })} />
</div>
</CapabilityGroup>
<CapabilityGroup title="生成模式">
<div className="grid gap-2 sm:grid-cols-2">
<Field label="支持模式"><Select mode="multiple" className="w-full" disabled={disabled} value={profile.operations} options={operationOptions} onChange={(operations) => update({ operations, defaultOperation: operations.includes(profile.defaultOperation) ? profile.defaultOperation : operations[0] || "text_to_video" })} /></Field>
<Field label="默认模式"><Select className="w-full" disabled={disabled} value={profile.defaultOperation} options={operationOptions.filter((item) => profile.operations.includes(item.value))} onChange={(defaultOperation) => update({ defaultOperation })} /></Field>
</div>
</CapabilityGroup>
</div>
);
}
function TextCapabilityEditor({ value, onChange, protocol, disabled }: Pick<Props, "value" | "onChange" | "protocol" | "disabled">) {
const profile = value?.text || defaultModelCapabilityConfig(protocol).text!;
const updateReferences = (patch: Partial<TextCapabilityConfig["references"]>) => {
onChange?.({ version: 1, text: { references: { ...profile.references, ...patch } } });
};
return (
<div className="admin-capability-editor space-y-3 rounded-md bg-muted/20 p-3">
<div>
<div className="text-sm font-medium">文本理解能力</div>
<div className="mt-0.5 text-[var(--fs-tiny)] text-foreground/48">默认不假设支持图片或视频,只有明确配置后相关请求才会进入该模型。</div>
</div>
<CapabilityGroup title="输入限制">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
<NumberField label="提示词最大字符数" value={profile.references.promptMaxChars} min={1} max={1_000_000} disabled={Boolean(disabled)} onChange={(next) => updateReferences({ promptMaxChars: next || 1 })} />
<NumberField label="最大参考图片数" value={profile.references.maxImages} min={0} max={100} disabled={Boolean(disabled)} onChange={(next) => updateReferences({ maxImages: next || 0 })} />
<NumberField label="单张图片上限 MB" value={bytesToMB(profile.references.maxImageBytes)} min={0} disabled={Boolean(disabled)} onChange={(next) => updateReferences({ maxImageBytes: mbToBytes(next) })} />
<NumberField label="最大参考视频数" value={profile.references.maxVideos} min={0} max={100} disabled={Boolean(disabled)} onChange={(next) => updateReferences({ maxVideos: next || 0 })} />
<NumberField label="单个视频上限 MB" value={bytesToMB(profile.references.maxVideoBytes)} min={0} disabled={Boolean(disabled)} onChange={(next) => updateReferences({ maxVideoBytes: mbToBytes(next) })} />
</div>
</CapabilityGroup>
</div>
);
}
function ImageCapabilityEditor({ value, onChange, protocol, model, disabled }: Required<Pick<Props, "model" | "disabled">> & Pick<Props, "value" | "onChange" | "protocol">) {
const profile = normalizeModelCapabilityConfig(value || { version: 1, image: defaultImageCapabilityConfig(protocol, model) }).image!;
const update = (patch: Partial<ImageCapabilityConfig>) => onChange?.({ version: 1, image: { ...profile, ...patch } });
const updateReferences = (patch: Partial<ImageCapabilityConfig["references"]>) => update({ references: { ...profile.references, ...patch } });
const updateSize = (patch: Partial<ImageCapabilityConfig["size"]>) => update({ size: { ...profile.size, ...patch } });
const updateQuality = (patch: Partial<ImageCapabilityConfig["quality"]>) => update({ quality: { ...profile.quality, ...patch } });
return (
<div className="admin-capability-editor space-y-3 rounded-md bg-muted/10 p-3">
<div className="flex items-center justify-between gap-3">
<div><div className="text-sm font-medium">图片能力参数</div><div className="mt-0.5 text-[var(--fs-tiny)] text-foreground/48">生成界面和后端请求都会按此处裁剪参数</div></div>
<span className="text-[var(--fs-tiny)] text-foreground/40">当前模型独立生效</span>
</div>
<CapabilityGroup title="输入与输出限制">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<NumberField label="提示词字符数" value={profile.references.promptMaxChars} min={1} disabled={disabled} onChange={(promptMaxChars) => updateReferences({ promptMaxChars: promptMaxChars || 1 })} />
<NumberField label="最大参考图" value={profile.references.maxImages} min={0} disabled={disabled} onChange={(maxImages) => updateReferences({ maxImages: maxImages || 0 })} />
<NumberField label="单图上限 MB" value={bytesToMB(profile.references.maxImageBytes)} min={0} disabled={disabled} onChange={(maxImageBytes) => updateReferences({ maxImageBytes: mbToBytes(maxImageBytes) })} />
<NumberField label="单次生成张数" value={profile.maxOutputs} min={1} disabled={disabled} onChange={(maxOutputs) => update({ maxOutputs: maxOutputs || 1 })} />
</div>
<ParameterField label="蒙版编辑" description="允许调用图片编辑接口并提交 mask" supported={profile.references.maskSupported} disabled={disabled} onChange={(maskSupported) => updateReferences({ maskSupported })} />
</CapabilityGroup>
<CapabilityGroup title="尺寸参数">
<Segmented
block
disabled={disabled}
value={profile.size.parameter}
options={[{ label: "不发送", value: "none" }, { label: "size", value: "size" }, { label: "aspect_ratio", value: "aspect_ratio" }]}
onChange={(value) => {
const parameter = value as ImageCapabilityConfig["size"]["parameter"];
updateSize(parameter === "none" ? { parameter, values: [], default: "auto", allowCustom: false } : { parameter, values: profile.size.values.length ? profile.size.values : ["1:1"], default: profile.size.default === "auto" ? "1:1" : profile.size.default });
}}
/>
{profile.size.parameter !== "none" ? <>
<Field label="支持值"><Select mode="tags" className="admin-capability-tags w-full" disabled={disabled} value={profile.size.values} tokenSeparators={[","]} placeholder="例如 1:1、1024x1024" onChange={(values) => updateSize({ values, default: values.includes(profile.size.default) || profile.size.allowCustom ? profile.size.default : values[0] || "auto" })} /></Field>
<div className="grid gap-2 sm:grid-cols-2">
<Field label="默认值"><Select className="w-full" disabled={disabled} value={profile.size.default} options={profile.size.values.map((item) => ({ label: item, value: item }))} onChange={(defaultValue) => updateSize({ default: defaultValue })} /></Field>
<ParameterField label="允许自定义" description="允许用户输入支持值之外的尺寸" supported={profile.size.allowCustom} disabled={disabled} onChange={(allowCustom) => updateSize({ allowCustom })} />
</div>
</> : null}
</CapabilityGroup>
<CapabilityGroup title="可选生成参数">
<ParameterField label="图片质量" description="发送 quality 参数" supported={profile.quality.supported} disabled={disabled} onChange={(supported) => updateQuality({ supported })} />
{profile.quality.supported ? <div className="grid gap-2 sm:grid-cols-2">
<Field label="质量支持值"><Select mode="tags" className="admin-capability-tags w-full" disabled={disabled} value={profile.quality.values} tokenSeparators={[","]} onChange={(values) => updateQuality({ values, default: values.includes(profile.quality.default) ? profile.quality.default : values[0] || "auto" })} /></Field>
<Field label="默认质量"><Select className="w-full" disabled={disabled} value={profile.quality.default} options={profile.quality.values.map((item) => ({ label: item, value: item }))} onChange={(defaultValue) => updateQuality({ default: defaultValue })} /></Field>
</div> : null}
<BooleanField label="透明背景" value={profile.transparentBackground} disabled={disabled} onChange={(transparentBackground) => update({ transparentBackground })} />
<div className="grid gap-2 sm:grid-cols-2">
<ParameterField label="response_format" description="发送 b64_json 响应格式" supported={profile.responseFormat.supported} disabled={disabled} onChange={(supported) => update({ responseFormat: { supported } })} />
<ParameterField label="output_format" description="发送 PNG 输出格式" supported={profile.outputFormat.supported} disabled={disabled} onChange={(supported) => update({ outputFormat: { supported } })} />
</div>
</CapabilityGroup>
</div>
);
}
function CapabilityGroup({ title, children }: { title: string; children: ReactNode }) {
return <details open className="admin-capability-group rounded-lg bg-muted/10 p-3"><summary className="cursor-pointer list-none text-xs font-semibold text-foreground/70">{title}</summary><div className="mt-3 space-y-2">{children}</div></details>;
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return <label className="block min-w-0"><span className="mb-1 block text-[var(--fs-tiny)] text-foreground/48">{label}</span>{children}</label>;
}
function NumberField({ label, value, min, max, disabled, onChange }: { label: string; value?: number; min: number; max?: number; disabled: boolean; onChange: (value: number | null) => void }) {
return <div className="flex items-center justify-between gap-3"><span className="min-w-0 text-xs text-foreground/62">{label}</span><InputNumber className="w-32 shrink-0" disabled={disabled} min={min} max={max} precision={0} value={value} onChange={onChange} /></div>;
}
function BooleanField({ label, value, disabled, onChange }: { label: string; value: { supported: boolean; default: boolean }; disabled: boolean; onChange: (value: { supported: boolean; default: boolean }) => void }) {
return <div className="flex items-center justify-between rounded-md border border-border/60 px-2.5 py-2"><div><div className="text-xs font-medium">{label}</div><div className="text-[var(--fs-tiny)] text-foreground/45">支持该参数</div></div><div className="flex items-center gap-2"><Switch size="small" disabled={disabled} checked={value.supported} onChange={(supported) => onChange({ ...value, supported })} /><Switch size="small" disabled={disabled || !value.supported} checked={value.default} onChange={(defaultValue) => onChange({ ...value, default: defaultValue })} /></div></div>;
}
function ParameterField({ label, description, supported, disabled, onChange }: { label: string; description: string; supported: boolean; disabled: boolean; onChange: (supported: boolean) => void }) {
return <div className="flex min-h-11 items-center justify-between gap-3 rounded-md border border-border/60 px-2.5 py-2"><div className="min-w-0"><div className="truncate text-xs font-medium">{label}</div><div className="mt-0.5 text-[var(--fs-tiny)] text-foreground/45">{description}</div></div><Switch size="small" disabled={disabled} checked={supported} onChange={onChange} /></div>;
}
function bytesToMB(value: number) {
return value ? Math.round((value / (1024 * 1024)) * 10) / 10 : 0;
}
function mbToBytes(value: number | null) {
return Math.max(0, Math.round(Number(value || 0) * 1024 * 1024));
}
function parseIntegerList(value: string) {
return Array.from(new Set(value.split(",").map((item) => Number(item.trim())).filter((item) => Number.isInteger(item) && item > 0))).sort((left, right) => left - right);
}