fix(platform): 模型能力 - 修复前台默认参数与后台空状态

This commit is contained in:
ddCat
2026-08-19 09:08:34 +08:00
parent d2a089f1ec
commit 6a698129bd
14 changed files with 518 additions and 120 deletions

View File

@@ -6,6 +6,12 @@
- 项目开源协议由 AGPL-3.0 调整为 MIT,保留上游及项目贡献者版权声明。
- 修复任务中心等资源入口仍由应用服务器转发对象存储媒体的问题:配置 CDN 加速域名后,登录资源、公开画布分享和管理员媒体预览统一重定向到 CDN;内部 Blob 读取保留同源代理兼容。
## v1.1.2
- 修复后台逻辑模型默认参数未应用到前台创作端的问题,支持默认画面比例、视频时长和输出分辨率正确初始化。
- 恢复图片模型完整尺寸/比例能力,并兼容视频 `720` 与 `720p` 分辨率配置。
- 移除逻辑模型能力配置中占用空间较大的无能力来源警告卡片,简化后台空状态。
## v1.0.59
- 画布媒体工作流支持批量连线、失败重试与模型能力增强。

View File

@@ -1 +1 @@
v1.0.59
v1.1.2

View File

@@ -165,6 +165,11 @@ func (s *Service) PublicLogicalModels(intent *ModelRequestIntent) ([]PublicLogic
func publicLogicalModel(cached cachedLogicalModel, available bool) PublicLogicalModel {
item := cached.Model
routeSpecs := make([]CapabilitySpec, 0, len(cached.Routes))
for _, route := range cached.Routes {
routeSpecs = append(routeSpecs, route.VariantSpec)
}
productSpec := capabilitySpecWithRoutePresets(cached.ProductSpec, routeSpecs)
variants := make([]CapabilitySpec, 0, len(cached.Routes))
seen := make(map[string]bool, len(cached.Routes))
for _, route := range cached.Routes {
@@ -177,7 +182,37 @@ func publicLogicalModel(cached cachedLogicalModel, available bool) PublicLogical
variants = append(variants, route.VariantSpec)
}
}
return PublicLogicalModel{ID: item.ID, Code: item.Code, Name: item.Name, Icon: item.Icon, Description: item.Description, Capability: item.Capability, SortOrder: item.SortOrder, PricePolicy: item.PricePolicy, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputPriceMicrocredits: item.InputPriceMicrocredits, OutputPriceMicrocredits: item.OutputPriceMicrocredits, CachedPriceMicrocredits: item.CachedPriceMicrocredits, CapabilitySpec: cached.ProductSpec, CapabilityProfiles: variants, DefaultOptions: cached.Defaults, Available: available}
return PublicLogicalModel{ID: item.ID, Code: item.Code, Name: item.Name, Icon: item.Icon, Description: item.Description, Capability: item.Capability, SortOrder: item.SortOrder, PricePolicy: item.PricePolicy, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputPriceMicrocredits: item.InputPriceMicrocredits, OutputPriceMicrocredits: item.OutputPriceMicrocredits, CachedPriceMicrocredits: item.CachedPriceMicrocredits, CapabilitySpec: productSpec, CapabilityProfiles: variants, DefaultOptions: cached.Defaults, Available: available}
}
// capabilitySpecWithRoutePresets repairs old front-model snapshots that stored
// only `*` for a custom size. The wildcard remains for matching custom values,
// while route presets are restored for admin and creator-side selectors.
func capabilitySpecWithRoutePresets(spec CapabilitySpec, routes []CapabilitySpec) CapabilitySpec {
result := spec
result.Options = make(map[string]OptionConstraint, len(spec.Options))
for name, constraint := range spec.Options {
if !isWildcardOptionConstraint(constraint) {
result.Options[name] = constraint
continue
}
values := append([]any(nil), constraint.Values...)
seen := make(map[string]bool, len(values))
for _, value := range values {
seen[normalizedScalar(value)] = true
}
for _, route := range routes {
for _, value := range route.Options[name].Values {
key := normalizedScalar(value)
if key != "" && !seen[key] {
seen[key] = true
values = append(values, value)
}
}
}
result.Options[name] = OptionConstraint{Values: values}
}
return result
}
// capabilityFingerprint 用规范化后的结构去重能力画像;不能直接依赖原始 JSON,
@@ -250,10 +285,6 @@ func (s *Service) buildAdminLogicalModel(item model.LogicalModel, graph *reposit
if err != nil {
return nil, err
}
defaults, err := decodeLogicalDefaults(graph.Revision.DefaultOptionsJSON, productSpec)
if err != nil {
return nil, err
}
variantByID := make(map[string]model.PhysicalCapabilityVariant, len(graph.Variants))
for _, variant := range graph.Variants {
variantByID[variant.ID] = variant
@@ -262,7 +293,7 @@ func (s *Service) buildAdminLogicalModel(item model.LogicalModel, graph *reposit
for _, channelModel := range graph.ChannelModels {
channelModelByID[channelModel.ID] = channelModel
}
admin := AdminLogicalModel{PublicLogicalModel: publicLogicalModel(cachedLogicalModel{Model: item, ProductSpec: productSpec, Defaults: defaults}, false), Enabled: item.Enabled, ActiveRevisionID: graph.Revision.ID, RevisionVersion: graph.Revision.Version, Routes: []AdminLogicalRoute{}}
admin := AdminLogicalModel{PublicLogicalModel: publicLogicalModel(cachedLogicalModel{Model: item, ProductSpec: productSpec, Defaults: map[string]any{}}, false), Enabled: item.Enabled, ActiveRevisionID: graph.Revision.ID, RevisionVersion: graph.Revision.Version, Routes: []AdminLogicalRoute{}}
for _, route := range graph.Routes {
variant, variantOK := variantByID[route.PhysicalVariantID]
if !variantOK {
@@ -281,6 +312,20 @@ func (s *Service) buildAdminLogicalModel(item model.LogicalModel, graph *reposit
available := structurallyAvailable && (item.PricePolicy != "channel" || channelModel.PriceConfigured)
admin.Routes = append(admin.Routes, AdminLogicalRoute{ID: route.ID, PhysicalVariantID: variant.ID, PhysicalVariantName: variant.Name, ChannelModelID: channelModel.ID, ChannelID: channelModel.ChannelID, PhysicalModelKey: channelModel.ModelKey, PhysicalModelName: channelModel.DisplayName, Enabled: route.Enabled, Priority: route.Priority, Weight: route.Weight, Available: available, structurallyAvailable: structurallyAvailable, CapabilitySpec: variantSpec})
}
routeSpecs := make([]CapabilitySpec, 0, len(admin.Routes))
for _, route := range admin.Routes {
routeSpecs = append(routeSpecs, route.CapabilitySpec)
}
productSpec = capabilitySpecWithRoutePresets(productSpec, routeSpecs)
defaults, err := decodeLogicalDefaults(graph.Revision.DefaultOptionsJSON, productSpec)
if err != nil {
return nil, err
}
admin.PublicLogicalModel = publicLogicalModel(cachedLogicalModel{Model: item, ProductSpec: productSpec, Defaults: defaults}, false)
// publicLogicalModel above has no route list; admin routes are already attached
// and the enriched product spec is the source used by the editor.
admin.CapabilitySpec = productSpec
admin.DefaultOptions = defaults
structuralRouteSpecs := structuralAdminRouteSpecs(admin.Routes)
settlementRouteSpecs := settlementReadyAdminRouteSpecs(admin.Routes)
admin.ConfigurationError = logicalModelConfigurationError(productSpec, structuralRouteSpecs)
@@ -532,9 +577,18 @@ func normalizeLogicalDefaults(spec CapabilitySpec, defaults map[string]any) (map
return nil, BadAuthRequest("默认参数存在重复别名:" + name)
}
constraint, ok := spec.Options[name]
if !ok || !matchOptionConstraint(constraint, value) {
if !ok || !matchOptionConstraint(name, constraint, value) {
return nil, BadAuthRequest("默认参数 " + name + " 不在前台模型能力范围内")
}
// `*` 只表示允许任意自定义值,不能作为创作端默认参数发送。
if normalizedScalar(value) == "*" {
for _, candidate := range constraint.Values {
if normalizedScalar(candidate) != "*" {
value = candidate
break
}
}
}
result[name] = value
}
return result, nil
@@ -751,14 +805,11 @@ func validateVariantWithinChannelModel(channelModel model.ChannelModel, spec Cap
}
}
physical := map[string]OptionConstraint{
"size": anyValues(image.Size.Values),
"size": imageSizeOptionConstraint(image.Size),
"quality": anyValues(image.Quality.Values),
"transparentBackground": boolValues(image.TransparentBackground.Supported),
"count": numericRange(1, float64(image.MaxOutputs), 1),
}
if image.Size.AllowCustom {
physical["size"] = OptionConstraint{Values: []any{"*"}}
}
return validateKnownVariantOptions(spec.Options, physical)
case "video":
if profile == nil || profile.Video == nil {
@@ -884,13 +935,13 @@ func optionConstraintCovered(candidate OptionConstraint, name string, routeSpecs
return false
}
for _, routeConstraint := range routeConstraints {
if len(routeConstraint.Values) == 1 && normalizedScalar(routeConstraint.Values[0]) == "*" {
if isWildcardOptionConstraint(routeConstraint) {
return true
}
}
if len(candidate.Values) > 0 {
for _, value := range candidate.Values {
if !optionValueSupported(value, routeConstraints) {
if !optionValueSupported(name, value, routeConstraints) {
return false
}
}
@@ -900,7 +951,7 @@ func optionConstraintCovered(candidate OptionConstraint, name string, routeSpecs
return false
}
if math.Abs(*candidate.Max-*candidate.Min) < 1e-9 {
return optionValueSupported(*candidate.Min, routeConstraints)
return optionValueSupported(name, *candidate.Min, routeConstraints)
}
if candidate.Step == nil {
return continuousOptionRangeCovered(*candidate.Min, *candidate.Max, routeConstraints)
@@ -910,7 +961,7 @@ func optionConstraintCovered(candidate OptionConstraint, name string, routeSpecs
if count <= 10000 {
for index := 0; index < count; index++ {
value := *candidate.Min + float64(index)*step
if !optionValueSupported(value, routeConstraints) {
if !optionValueSupported(name, value, routeConstraints) {
return false
}
}
@@ -933,12 +984,21 @@ func optionConstraintCovered(candidate OptionConstraint, name string, routeSpecs
return false
}
func optionValueSupported(value any, constraints []OptionConstraint) bool {
func optionValueSupported(name string, value any, constraints []OptionConstraint) bool {
for _, constraint := range constraints {
if len(constraint.Values) == 1 && normalizedScalar(constraint.Values[0]) == "*" {
if isWildcardOptionConstraint(constraint) {
return true
}
if matchOptionConstraint(constraint, value) {
if matchOptionConstraint(name, constraint, value) {
return true
}
}
return false
}
func isWildcardOptionConstraint(constraint OptionConstraint) bool {
for _, value := range constraint.Values {
if normalizedScalar(value) == "*" {
return true
}
}

View File

@@ -40,6 +40,31 @@ func TestMatchCapabilityAcceptsWildcardOptionValues(t *testing.T) {
}
}
func TestMatchCapabilityAcceptsWildcardAlongsidePresetOptionValues(t *testing.T) {
intent := ModelRequestIntent{Capability: "image", Options: map[string]any{"size": "2:1"}}
spec := CapabilitySpec{
Version: 1,
Capability: "image",
Options: map[string]OptionConstraint{"size": {Values: []any{"1:1", "9:16", "*"}}},
}
match := MatchCapability(spec, intent)
if !match.Matched {
t.Fatalf("wildcard alongside presets did not match custom value: %#v", match)
}
}
func TestMatchCapabilityTreatsVideoResolutionSuffixAsEquivalent(t *testing.T) {
spec := CapabilitySpec{
Version: 1,
Capability: "video",
Options: map[string]OptionConstraint{"vquality": {Values: []any{"720p"}}},
}
intent := ModelRequestIntent{Capability: "video", Options: map[string]any{"vquality": "720"}}
if match := MatchCapability(spec, intent); !match.Matched {
t.Fatalf("720 and 720p should match the same video resolution: %#v", match)
}
}
func TestValidateProductSpecWithinRoutesRejectsUnsupportedCapabilityValue(t *testing.T) {
routes := []CapabilitySpec{
{

View File

@@ -174,6 +174,15 @@ func defaultImageSizeValues() []string {
}
}
// legacyImageSizeValues 用于修复旧数据中仅保存了 "*" 的图片尺寸能力。
// 这组值是前后台共同展示的基础预设,不能让历史通配符配置继续污染用户生成参数。
func legacyImageSizeValues() []string {
return []string{
"1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "21:9", "9:16",
"1024x1024", "1536x1024", "1024x1536",
}
}
func DefaultModelCapabilityConfigForModel(protocol string, modelName string) *ModelCapabilityConfig {
// 文本模型是否支持视觉输入不能从协议或模型名可靠推断,默认关闭,由管理员按真实上游能力开启。
text := &TextCapabilityConfig{References: TextReferenceConfig{PromptMaxChars: 32000}}
@@ -297,11 +306,7 @@ func CapabilitySpecFromModelCapabilityConfig(config *ModelCapabilityConfig, capa
addInputConstraint(spec.Inputs, "mask", 0, 1)
}
if image.Size.Parameter != "none" {
if image.Size.AllowCustom {
spec.Options["size"] = OptionConstraint{Values: []any{"*"}}
} else {
spec.Options["size"] = anyValues(image.Size.Values)
}
spec.Options["size"] = imageSizeOptionConstraint(image.Size)
}
if image.Quality.Supported {
spec.Options["quality"] = anyValues(image.Quality.Values)
@@ -350,6 +355,34 @@ func CapabilitySpecFromModelCapabilityConfig(config *ModelCapabilityConfig, capa
return spec, nil
}
// imageSizeOptionConstraint 保留可见的标准尺寸/比例,同时用 * 表示允许自定义。
// * 不能替代标准值,否则管理端只能看到一个没有业务含义的通配符。
func imageSizeOptionConstraint(size ImageSizeConfig) OptionConstraint {
values := make([]string, 0, len(size.Values)+1)
seen := make(map[string]struct{}, len(size.Values)+1)
for _, value := range size.Values {
value = strings.TrimSpace(value)
if value == "" || value == "*" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
values = append(values, value)
}
if size.AllowCustom {
if len(values) == 0 {
for _, value := range legacyImageSizeValues() {
seen[value] = struct{}{}
values = append(values, value)
}
}
values = append(values, "*")
}
return anyValues(values)
}
func addInputConstraint(inputs map[string]InputConstraint, name string, min int, max int) {
if min <= 0 && max <= 0 {
return

View File

@@ -83,6 +83,36 @@ func TestDefaultVideoCapabilityUsesProtocolSpecificResolutionTiers(t *testing.T)
}
}
func TestCapabilitySpecFromModelCapabilityConfigRestoresLegacyWildcardImageSizes(t *testing.T) {
config := &ModelCapabilityConfig{
Version: 1,
Image: &ImageCapabilityConfig{
Size: ImageSizeConfig{Parameter: "size", Values: []string{"*"}, AllowCustom: true},
},
}
spec, err := CapabilitySpecFromModelCapabilityConfig(config, "image")
if err != nil {
t.Fatalf("CapabilitySpecFromModelCapabilityConfig() error = %v", err)
}
constraint, ok := spec.Options["size"]
if !ok {
t.Fatal("size constraint is missing")
}
values := make(map[string]int)
for _, value := range constraint.Values {
values[fmt.Sprint(value)]++
}
for _, value := range legacyImageSizeValues() {
if values[value] != 1 {
t.Fatalf("size constraint missing %q: %v", value, constraint.Values)
}
}
if values["*"] != 1 {
t.Fatalf("size constraint wildcard count = %d, values = %v", values["*"], constraint.Values)
}
}
func TestNormalizeResolutionSupportsCommonAliases(t *testing.T) {
tests := map[string]string{
"1440": "1440p",
@@ -145,8 +175,8 @@ func TestCapabilitySpecFromModelCapabilityConfigProjectsImageSizeOnce(t *testing
if err != nil {
t.Fatalf("CapabilitySpecFromModelCapabilityConfig() error = %v", err)
}
if got := spec.Options["size"].Values; len(got) != 1 || got[0] != "*" {
t.Fatalf("size projection = %#v, want wildcard", got)
if got := spec.Options["size"].Values; len(got) != 3 || got[0] != "1:1" || got[1] != "16:9" || got[2] != "*" {
t.Fatalf("size projection = %#v, want configured values plus wildcard", got)
}
if got := spec.Inputs["image"].Max; got != 3 {
t.Fatalf("image input max = %d, want 3", got)
@@ -169,8 +199,8 @@ func TestCapabilitySpecFromModelCapabilityConfigProjectsCustomImageSizeAsWildcar
if err != nil {
t.Fatalf("CapabilitySpecFromModelCapabilityConfig() error = %v", err)
}
if got := spec.Options["size"].Values; len(got) != 1 || got[0] != "*" {
t.Fatalf("custom size projection = %#v, want wildcard", got)
if got := spec.Options["size"].Values; len(got) != 2 || got[0] != "1:1" || got[1] != "*" {
t.Fatalf("custom size projection = %#v, want configured value plus wildcard", got)
}
}

View File

@@ -241,7 +241,7 @@ func MatchCapability(spec CapabilitySpec, intent ModelRequestIntent) CapabilityM
reasons = append(reasons, "不支持参数 "+capabilityOptionLabel(name))
continue
}
if !matchOptionConstraint(constraint, value) {
if !matchOptionConstraint(name, constraint, value) {
reasons = append(reasons, "参数 "+capabilityOptionLabel(name)+"超出支持范围")
}
}
@@ -294,13 +294,13 @@ func capabilityOptionLabel(name string) string {
}
}
func matchOptionConstraint(constraint OptionConstraint, value any) bool {
func matchOptionConstraint(name string, constraint OptionConstraint, value any) bool {
if len(constraint.Values) > 0 {
for _, candidate := range constraint.Values {
if normalizedScalar(candidate) == "*" {
return true
}
if normalizedScalar(candidate) == normalizedScalar(value) {
if capabilityOptionValuesEqual(name, candidate, value) {
return true
}
}
@@ -323,6 +323,16 @@ func matchOptionConstraint(constraint OptionConstraint, value any) bool {
return true
}
func capabilityOptionValuesEqual(name string, candidate any, value any) bool {
left := normalizedScalar(candidate)
right := normalizedScalar(value)
if canonicalCapabilityOptionName(name) == "vquality" {
left = strings.TrimSuffix(left, "p")
right = strings.TrimSuffix(right, "p")
}
return left == right
}
func normalizedScalar(value any) string {
switch typed := value.(type) {
case string:
@@ -519,11 +529,6 @@ func (s *Service) loadRouteCatalog() (*routeCatalogSnapshot, error) {
log.Printf("logical model omitted from route catalog id=%s: invalid product capability: %v", item.ID, decodeErr)
continue
}
defaults, defaultsErr := decodeLogicalDefaults(graph.Revision.DefaultOptionsJSON, productSpec)
if defaultsErr != nil {
log.Printf("logical model omitted from route catalog id=%s: invalid defaults: %v", item.ID, defaultsErr)
continue
}
variantByID := make(map[string]model.PhysicalCapabilityVariant, len(graph.Variants))
for _, variant := range graph.Variants {
variantByID[variant.ID] = variant
@@ -532,7 +537,7 @@ func (s *Service) loadRouteCatalog() (*routeCatalogSnapshot, error) {
for _, channelModel := range graph.ChannelModels {
channelModelByID[channelModel.ID] = channelModel
}
cached := cachedLogicalModel{Model: item, Revision: *graph.Revision, ProductSpec: productSpec, Defaults: defaults}
cached := cachedLogicalModel{Model: item, Revision: *graph.Revision, ProductSpec: productSpec, Defaults: map[string]any{}}
for _, route := range graph.Routes {
variant, ok := variantByID[route.PhysicalVariantID]
if !ok || !variant.Enabled {
@@ -552,6 +557,18 @@ func (s *Service) loadRouteCatalog() (*routeCatalogSnapshot, error) {
}
cached.Routes = append(cached.Routes, cachedLogicalRoute{Route: route, Variant: variant, VariantSpec: variantSpec, ChannelModel: channelModel})
}
routeSpecs := make([]CapabilitySpec, 0, len(cached.Routes))
for _, route := range cached.Routes {
routeSpecs = append(routeSpecs, route.VariantSpec)
}
productSpec = capabilitySpecWithRoutePresets(productSpec, routeSpecs)
defaults, defaultsErr := decodeLogicalDefaults(graph.Revision.DefaultOptionsJSON, productSpec)
if defaultsErr != nil {
log.Printf("logical model omitted from route catalog id=%s: invalid defaults: %v", item.ID, defaultsErr)
continue
}
cached.ProductSpec = productSpec
cached.Defaults = defaults
snapshot.Models[item.ID] = cached
snapshot.Ordered = append(snapshot.Ordered, item.ID)
}

View File

@@ -59,11 +59,14 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
const count = Math.max(1, Math.min(effectiveMaxCount, Number(normalized.count)));
const activeSize = normalized.size;
const pixelSizeValues = profile.size.values.filter((value) => value.trim().toLowerCase() !== "auto");
const usesResolutionPicker = supportsImageResolutionPresets(profile.size);
const resolutionOptions = usesResolutionPicker ? buildImageResolutionOptions(pixelSizeValues) : [];
const hasResolutionPresets = supportsImageResolutionPresets(profile.size);
const resolutionOptions = hasResolutionPresets ? buildImageResolutionOptions(pixelSizeValues) : [];
const activeResolution = activeSize === "auto" ? undefined : imageResolutionOption(resolutionOptions, activeSize);
const activeRatio = activeResolution?.ratio || imageRatioForSize(activeSize);
const resolutionChoices = usesResolutionPicker ? imageResolutionChoices(profile.size.values) : [];
const resolutionChoices = hasResolutionPresets ? imageResolutionChoices(profile.size.values) : [];
// 只有一个分辨率层级时,分辨率切换器没有实际选择意义;更重要的是不能因此把比例列表裁剪成当前层级的 3 个像素尺寸。
// 例如历史 `*` 配置恢复为标准值后,虽然包含 1024x1024/1536x1024/1024x1536,实际仍应展示完整的比例和尺寸选项。
const usesResolutionPicker = resolutionChoices.length > 1;
const availableAspects: AspectOption[] = usesResolutionPicker && activeSize === "auto"
? []
: usesResolutionPicker && activeResolution
@@ -158,7 +161,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
</div>
</div> : null}
{availableAspects.length ? <div className="space-y-2">
<SettingTitle color={theme.node.muted}>宽高比</SettingTitle>
<SettingTitle color={theme.node.muted}>尺寸或比例</SettingTitle>
<div className="grid grid-cols-4 gap-1.5 min-[380px]:grid-cols-5">
{availableAspects.map((item) => (
<button

View File

@@ -74,6 +74,21 @@ export type VideoCapabilityConfig = {
defaultOperation: string;
};
// 旧版本的“允许自定义”可能只保存了 `*`,前台需要用这组标准值恢复可选项。
export const STANDARD_IMAGE_SIZE_VALUES = [
"1:1",
"3:2",
"2:3",
"4:3",
"3:4",
"16:9",
"21:9",
"9:16",
"1024x1024",
"1536x1024",
"1024x1536",
] as const;
// Keep explicit pixel presets for each resolution tier so the settings panel can
// switch between 1K, 2K and 4K without silently converting the requested ratio.
const defaultImageSizes = [
@@ -258,7 +273,30 @@ export function modelCapabilityConfigFor(config: { channels: Array<{ id: string;
if (!cost?.capabilityConfig) return fallback;
const text = cost.capabilityConfig.text ? { ...fallback.text!, ...cost.capabilityConfig.text, references: { ...fallback.text!.references, ...cost.capabilityConfig.text.references } } : fallback.text;
const video = cost.capabilityConfig.video ? { ...fallback.video!, ...cost.capabilityConfig.video, references: { ...fallback.video!.references, ...cost.capabilityConfig.video.references } } : fallback.video;
return { ...fallback, ...cost.capabilityConfig, text, image: cost.capabilityConfig.image || fallback.image, video };
const configuredImage = cost.capabilityConfig.image;
const image = configuredImage
? (() => {
const configuredSize = configuredImage.size;
const configuredValues = configuredSize?.values;
const allowCustom = Boolean(configuredSize?.allowCustom || configuredValues?.includes("*"));
const concreteValues = configuredValues?.filter((value) => value !== "*") || [];
const values = !configuredValues ? fallback.image!.size.values : concreteValues.length || !allowCustom ? concreteValues : [...STANDARD_IMAGE_SIZE_VALUES];
const configuredDefault = configuredSize?.default?.trim();
const defaultValue = configuredDefault && configuredDefault !== "*" && values.includes(configuredDefault) ? configuredDefault : values.find((value) => value !== "*") || fallback.image!.size.default;
return {
...fallback.image!,
...configuredImage,
size: {
...fallback.image!.size,
...configuredSize,
values,
default: defaultValue,
allowCustom,
},
};
})()
: fallback.image;
return { ...fallback, ...cost.capabilityConfig, text, image, video };
}
export function normalizeImageValue(profile: ImageCapabilityConfig, value: { size?: string; quality?: string; count?: string; transparentBackground?: string }) {
@@ -287,7 +325,8 @@ export function imageSizeRequest(profile: ImageCapabilityConfig, value?: string)
export function normalizeVideoValue(profile: VideoCapabilityConfig, value: { seconds?: string; ratio?: string; resolution?: string }) {
const duration = profile.duration.selection === "enum" ? ((profile.duration.values || []).includes(Number(value.seconds)) ? Number(value.seconds) : profile.duration.default) : normalizeRangeDuration(profile, Number(value.seconds));
const ratio = profile.ratios.includes(value.ratio || "") ? value.ratio! : profile.defaultRatio;
const resolution = profile.resolutions.includes(value.resolution || "") ? value.resolution! : profile.defaultResolution;
// 前端状态历史上保存过 `720`,而能力配置和供应商通常使用 `720p`;统一按能力中的原始值返回,避免被误判为不支持。
const resolution = videoResolutionRequest(profile, value.resolution) || profile.defaultResolution || profile.resolutions[0] || "";
return { seconds: String(duration), ratio, resolution };
}

View File

@@ -1,4 +1,4 @@
import { defaultImageCapabilityConfig, modelCapabilityConfigFor, videoDurationAllowed, type ImageCapabilityConfig } from "@/lib/model-capabilities";
import { defaultImageCapabilityConfig, modelCapabilityConfigFor, STANDARD_IMAGE_SIZE_VALUES, videoDurationAllowed, type ImageCapabilityConfig } from "@/lib/model-capabilities";
import { modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
export type ModelInputSummary = {
@@ -133,13 +133,13 @@ function logicalModelCompatibilityError(spec: NonNullable<NonNullable<AiConfig["
for (const [name, value] of Object.entries(options)) {
if (value === undefined || value === null || value === "") continue;
const constraint = spec.options?.[name];
if (!constraint || !logicalOptionMatches(constraint, value)) return logicalOptionError(name);
if (!constraint || !logicalOptionMatches(name, constraint, value)) return logicalOptionError(name);
}
return "";
}
function logicalOptionMatches(constraint: { values?: unknown[]; min?: number; max?: number; step?: number }, value: unknown) {
if (constraint.values?.length) return constraint.values.some((candidate) => String(candidate).toLowerCase() === String(value).toLowerCase());
function logicalOptionMatches(name: string, constraint: { values?: unknown[]; min?: number; max?: number; step?: number }, value: unknown) {
if (constraint.values?.length) return constraint.values.some((candidate) => logicalOptionValueMatches(name, candidate, value));
const numeric = Number(value);
if (!Number.isFinite(numeric)) return false;
if (constraint.min !== undefined && numeric < constraint.min) return false;
@@ -148,6 +148,13 @@ function logicalOptionMatches(constraint: { values?: unknown[]; min?: number; ma
return true;
}
function logicalOptionValueMatches(name: string, candidate: unknown, value: unknown) {
const left = String(candidate).trim().toLowerCase();
const right = String(value).trim().toLowerCase();
if (name === "vquality" || name === "resolution") return left.replace(/p$/, "") === right.replace(/p$/, "");
return left === right;
}
function logicalOptionError(name: string) {
const label: Record<string, string> = {
size: "尺寸",
@@ -196,17 +203,21 @@ export function mergedImageCapabilityConfig(config: AiConfig, selected: string):
const selectedProfile = modelCapabilityConfigFor(config, selected).image;
const profiles = models.map((model) => modelCapabilityConfigFor(config, model).image).filter((profile): profile is ImageCapabilityConfig => Boolean(profile));
if (profiles.length <= 1) return selectedProfile || defaultImageCapabilityConfig();
const values = Array.from(new Set(profiles.flatMap((profile) => profile.size.values)));
const concreteValues = Array.from(new Set(profiles.flatMap((profile) => profile.size.values).filter((value) => value !== "*")));
const allowCustom = profiles.some((profile) => profile.size.allowCustom || profile.size.values.includes("*"));
const values = concreteValues.length ? concreteValues : allowCustom ? [...STANDARD_IMAGE_SIZE_VALUES] : [];
const base = selectedProfile || profiles[0];
return { ...base, size: { ...base.size, values } };
return { ...base, size: { ...base.size, values, allowCustom } };
}
// 切换模型后初始化图片参数为该模型能力默认值,避免旧参数在目标模型族不兼容导致无法切换。
export function defaultImageParamsForModel(config: AiConfig, model: string): Pick<AiConfig, "size" | "quality" | "transparentBackground"> {
const image = modelCapabilityConfigFor(config, model).image;
if (!image) return { size: "1:1", quality: "auto", transparentBackground: "false" };
const sizeValues = image.size.values.filter((value) => value !== "*");
const sizeDefault = image.size.default !== "*" && image.size.default ? image.size.default : sizeValues[0] || "1:1";
return {
size: image.size.default || image.size.values[0] || "1:1",
size: sizeDefault,
quality: image.quality.default || "auto",
transparentBackground: String(image.transparentBackground.default ?? false),
};

View File

@@ -6,7 +6,7 @@ import { scopedLocalStorage, setActiveUserScope } from "@/lib/user-scope";
import { CANVAS_STORE_KEY, flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-store";
import { CONFIG_STORE_KEY, PUBLIC_MODEL_CATALOG_ID, defaultConfig, normalizeConfigSnapshot, useConfigStore, type ModelChannel } from "@/stores/use-config-store";
import { defaultModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities";
import { defaultModelCapabilityConfig, STANDARD_IMAGE_SIZE_VALUES, type ModelCapabilityConfig } from "@/lib/model-capabilities";
import { useUserStore } from "@/stores/use-user-store";
import { installRemoteUserDataAutoSync, resetRemoteUserDataSync, syncRemoteUserData, withRemoteUserDataSyncPaused } from "@/services/user-data-sync";
import { withGenerationConsumersPaused } from "@/services/generation-consumer-lifecycle";
@@ -109,9 +109,14 @@ function projectLogicalCapability(spec: CapabilitySpec, defaults: Record<string,
projected.image.size = { parameter: "none", values: [], default: "auto", allowCustom: false };
projected.image.quality = { supported: false, values: [], default: "auto" };
projected.image.transparentBackground = { supported: false, default: false };
applyStringOption(spec.options?.size || spec.options?.aspectRatio, defaults.size, (values, initial) => {
projected.image!.size = { parameter: "size", values, default: initial, allowCustom: false };
});
const sizeOption = spec.options?.size || spec.options?.aspectRatio;
const sizeValues = stringValues(sizeOption);
const sizeAllowsCustom = sizeValues.includes("*");
const concreteSizeValues = sizeValues.filter((value) => value !== "*");
const sizePresets = concreteSizeValues.length ? concreteSizeValues : sizeAllowsCustom ? [...STANDARD_IMAGE_SIZE_VALUES] : [];
if (sizePresets.length || sizeAllowsCustom) {
projected.image!.size = { parameter: "size", values: sizePresets, default: concreteDefault(defaults.size, sizePresets, "1:1"), allowCustom: sizeAllowsCustom };
}
applyStringOption(spec.options?.quality, defaults.quality, (values, initial) => {
projected.image!.quality = { supported: true, values, default: initial };
});
@@ -129,7 +134,7 @@ function projectLogicalCapability(spec: CapabilitySpec, defaults: Record<string,
if (duration?.values?.length) projected.video.duration = { selection: "enum", values: duration.values.map(Number).filter(Number.isFinite), default: Number(defaults.videoSeconds ?? duration.values[0]) };
else if (duration?.min !== undefined && duration.max !== undefined) projected.video.duration = { selection: "range", min: duration.min, max: duration.max, step: duration.step || 1, default: Number(defaults.videoSeconds ?? duration.min) };
projected.video.ratios = stringValues(spec.options?.size || spec.options?.aspectRatio);
projected.video.defaultRatio = String(defaults.size ?? projected.video.ratios[0] ?? "");
projected.video.defaultRatio = concreteDefault(defaults.size, projected.video.ratios, "");
projected.video.resolutions = stringValues(spec.options?.vquality || spec.options?.resolution);
projected.video.defaultResolution = String(defaults.vquality ?? projected.video.resolutions[0] ?? "");
projected.video.generateAudio = booleanOption(spec.options?.videoGenerateAudio, defaults.videoGenerateAudio);
@@ -140,11 +145,19 @@ function projectLogicalCapability(spec: CapabilitySpec, defaults: Record<string,
function applyStringOption(option: OptionConstraint | undefined, fallback: unknown, apply: (values: string[], initial: string) => void) {
const values = stringValues(option);
if (values.length) apply(values, String(fallback ?? values[0]));
if (values.length) apply(values, concreteDefault(fallback, values, values[0]));
}
function stringValues(option?: OptionConstraint) {
return (option?.values || []).map(String);
return (option?.values || [])
.map(String)
.map((value) => value.trim())
.filter(Boolean);
}
function concreteDefault(value: unknown, values: string[], fallback: string) {
const candidate = String(value ?? "").trim();
return candidate && candidate !== "*" && values.includes(candidate) ? candidate : values.find((item) => item !== "*") || fallback;
}
function maxNumericOption(option: OptionConstraint | undefined, fallback: number) {

View File

@@ -32,6 +32,7 @@ import {
capabilitySourceError,
emptyCapabilitySpec,
mergeCapabilitySpecs,
normalizeCapabilitySpecForSources,
operationLabel,
sanitizeDefaults,
type CapabilityKind,
@@ -157,7 +158,7 @@ export default function LogicalModelsPage() {
}
setSaving(true);
try {
const payload = logicalModelPayload(values);
const payload = logicalModelPayload(values, modelSourceSpecs);
await (editingModel ? updateAdminLogicalModel(editingModel.id, payload) : createAdminLogicalModel(payload));
setEditingModel(undefined);
await reload();
@@ -450,7 +451,7 @@ function RouteFields({
const options = selectOptions.map((option) => ({ ...option, disabled: option.disabled || selectedByOthers.has(option.value) }));
const selected = variants.find((item) => item.id === routes[field.name]?.physicalVariantId);
return (
<div key={field.key} className="rounded-lg border border-border bg-muted/5 p-4">
<div key={field.key} className="rounded-lg border border-border bg-muted/5 p-4">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-xs font-semibold">供应配置 {fields.indexOf(field) + 1}</div>
@@ -616,8 +617,8 @@ function logicalModelToForm(item: AdminLogicalModel): LogicalModelFormValues {
};
}
function logicalModelPayload(values: LogicalModelFormValues): LogicalModelMutation {
const capabilitySpec = { ...values.capabilitySpec, capability: values.capability, version: 1 as const };
function logicalModelPayload(values: LogicalModelFormValues, sourceSpecs: CapabilitySpec[] = []): LogicalModelMutation {
const capabilitySpec = normalizeCapabilitySpecForSources({ ...values.capabilitySpec, capability: values.capability, version: 1 as const }, sourceSpecs) || emptyCapabilitySpec(values.capability);
return {
code: values.code.trim(),
name: values.name.trim(),

View File

@@ -4,6 +4,7 @@ import type { ReactNode } from "react";
import type { 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;
@@ -64,6 +65,13 @@ 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) => 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;
@@ -91,12 +99,7 @@ export function capabilitySpecFromChannelModel(item?: ChannelModel): CapabilityS
mask: { min: 0, max: image.references.maskSupported ? 1 : 0 },
}),
options: compactOptions({
size:
image.size.parameter === "none"
? undefined
: image.size.allowCustom
? { values: ["*"] }
: { values: image.size.values },
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 },
@@ -106,9 +109,7 @@ export function capabilitySpecFromChannelModel(item?: ChannelModel): CapabilityS
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 };
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,
@@ -146,11 +147,13 @@ export function mergeCapabilitySpecs(capability: CapabilityKind, specs: Capabili
const constraints = matching.map((item) => item.options?.[definition.name]).filter(Boolean) as OptionConstraint[];
if (constraints.length === 0) continue;
if (constraints.some(isWildcardConstraint)) {
result.options![definition.name] = { values: ["*"] };
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)) {
result.options![definition.name] = { values: uniqueScalars(constraints.flatMap((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");
@@ -173,6 +176,22 @@ export function mergeCapabilitySpecs(capability: CapabilityKind, specs: Capabili
return result;
}
/**
* 旧版本把“允许自定义”保存成只有 `*` 的能力范围。
* 管理端编辑时用当前供应线路的标准值补回展示范围,保存后即可完成数据修复。
*/
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 };
}
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);
@@ -193,9 +212,9 @@ export function capabilitySourceError(capability: CapabilityKind, sourceSpecs: C
return undefined;
}
export function CapabilityScopeEditor({ capability, sourceSpecs, value, onChange, mode }: CapabilityScopeEditorProps) {
export function CapabilityScopeEditor({ capability, sourceSpecs, value, onChange }: CapabilityScopeEditorProps) {
const source = mergeCapabilitySpecs(capability, sourceSpecs);
const current = value?.capability === capability ? value : emptyCapabilitySpec(capability);
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]);
@@ -204,14 +223,14 @@ export function CapabilityScopeEditor({ capability, sourceSpecs, value, onChange
const update = (patch: Partial<CapabilitySpec>) => onChange?.({ ...current, ...patch, version: 1, capability });
const reset = () => onChange?.(source);
if (!hasSource) {
return <Alert type="warning" showIcon message="没有可用的能力来源" description={mode === "route" ? "请先到渠道模型补充能力配置。" : "请先选择至少一条已配置的供应线路。"} />;
}
if (!hasSource) return null;
return (
<div className="space-y-5">
<div className="flex justify-end">
<Button size="small" type="text" icon={<RotateCcw className="size-3.5" />} onClick={reset}>恢复全部可用范围</Button>
<Button size="small" type="text" icon={<RotateCcw className="size-3.5" />} onClick={reset}>
恢复全部可用范围
</Button>
</div>
{sourceError ? <Alert type="warning" showIcon message="当前选择需要调整" description={`${sourceError}。可重新选择,或采用全部可用范围。`} /> : null}
@@ -237,10 +256,37 @@ export function CapabilityScopeEditor({ capability, sourceSpecs, value, onChange
const selected = current.inputs?.[definition.name];
return (
<div key={definition.name} className="grid grid-cols-12 items-center gap-2 py-3 first:pt-0 last:pb-0">
<div className="col-span-12 sm:col-span-5"><div className="text-sm font-medium">{definition.label}</div><div className="text-xs text-foreground/45">线路支持 {limit.min}-{limit.max} {definition.unit}</div></div>
<div className="col-span-12 sm:col-span-1"><Switch size="small" checked={Boolean(selected)} onChange={(enabled) => updateInput(current, definition.name, enabled ? limit : undefined, update)} /></div>
<div className="col-span-6 sm:col-span-3"><NumberInput label="最少" unit={definition.unit} value={selected?.min} min={limit.min} max={selected?.max ?? limit.max} disabled={!selected} onChange={(next) => updateInput(current, definition.name, { min: next ?? limit.min, max: selected?.max ?? limit.max }, update)} /></div>
<div className="col-span-6 sm:col-span-3"><NumberInput label="最多" unit={definition.unit} value={selected?.max} min={selected?.min ?? limit.min} max={limit.max} disabled={!selected} onChange={(next) => updateInput(current, definition.name, { min: selected?.min ?? limit.min, max: next ?? limit.max }, update)} /></div>
<div className="col-span-12 sm:col-span-5">
<div className="text-sm font-medium">{definition.label}</div>
<div className="text-xs text-foreground/45">
线路支持 {limit.min}-{limit.max} {definition.unit}
</div>
</div>
<div className="col-span-12 sm:col-span-1">
<Switch size="small" checked={Boolean(selected)} onChange={(enabled) => updateInput(current, definition.name, enabled ? limit : undefined, update)} />
</div>
<div className="col-span-6 sm:col-span-3">
<NumberInput
label="最少"
unit={definition.unit}
value={selected?.min}
min={limit.min}
max={selected?.max ?? limit.max}
disabled={!selected}
onChange={(next) => updateInput(current, definition.name, { min: next ?? limit.min, max: selected?.max ?? limit.max }, update)}
/>
</div>
<div className="col-span-6 sm:col-span-3">
<NumberInput
label="最多"
unit={definition.unit}
value={selected?.max}
min={selected?.min ?? limit.min}
max={limit.max}
disabled={!selected}
onChange={(next) => updateInput(current, definition.name, { min: selected?.min ?? limit.min, max: next ?? limit.max }, update)}
/>
</div>
</div>
);
})}
@@ -252,7 +298,13 @@ export function CapabilityScopeEditor({ capability, sourceSpecs, value, onChange
<CapabilityBlock title="生成参数">
<div className="divide-y divide-border">
{sourceOptions.map((definition) => (
<OptionRuleEditor key={definition.name} definition={definition} source={source.options![definition.name]} value={current.options?.[definition.name]} onChange={(constraint) => updateOption(current, definition.name, constraint, update)} />
<OptionRuleEditor
key={definition.name}
definition={definition}
source={source.options![definition.name]}
value={current.options?.[definition.name]}
onChange={(constraint) => updateOption(current, definition.name, constraint, update)}
/>
))}
</div>
</CapabilityBlock>
@@ -273,13 +325,23 @@ export function DefaultOptionsEditor({ spec, value, onChange }: { spec?: Capabil
const selected = value?.[definition.name] as Scalar | undefined;
return (
<div key={definition.name} className="grid grid-cols-12 items-center gap-2 py-3 first:pt-0 last:pb-0">
<div className="col-span-12 sm:col-span-5"><div className="text-sm font-medium">{definition.label}</div><div className="text-xs text-foreground/45">留空时由创作端或线路默认值决定</div></div>
<div className="col-span-12 sm:col-span-7"><ConstraintValueInput constraint={constraint} value={selected} allowClear onChange={(next) => {
const defaults = { ...(value || {}) } as Record<string, Scalar>;
if (next === undefined) delete defaults[definition.name];
else defaults[definition.name] = next;
onChange?.(defaults);
}} /></div>
<div className="col-span-12 sm:col-span-5">
<div className="text-sm font-medium">{definition.label}</div>
<div className="text-xs text-foreground/45">留空时由创作端或线路默认值决定</div>
</div>
<div className="col-span-12 sm:col-span-7">
<ConstraintValueInput
constraint={constraint}
value={selected}
allowClear
onChange={(next) => {
const defaults = { ...(value || {}) } as Record<string, Scalar>;
if (next === undefined) delete defaults[definition.name];
else defaults[definition.name] = next;
onChange?.(defaults);
}}
/>
</div>
</div>
);
})}
@@ -287,7 +349,13 @@ export function DefaultOptionsEditor({ spec, value, onChange }: { spec?: Capabil
);
}
export function CapabilityRequestEditor({ spec, inputs, options, onInputsChange, onOptionsChange }: {
export function CapabilityRequestEditor({
spec,
inputs,
options,
onInputsChange,
onOptionsChange,
}: {
spec: CapabilitySpec;
inputs: Record<string, number>;
options: Record<string, unknown>;
@@ -325,7 +393,10 @@ export function CapabilityRequestEditor({ spec, inputs, options, onInputsChange,
const constraint = spec.options![definition.name];
return (
<label key={definition.name} className="min-w-0">
<span className="mb-1 block text-xs text-foreground/45">{definition.label}{definition.unit ? ` (${definition.unit})` : ""}</span>
<span className="mb-1 block text-xs text-foreground/45">
{definition.label}
{definition.unit ? ` (${definition.unit})` : ""}
</span>
<ConstraintValueInput
constraint={constraint}
value={options[definition.name] as Scalar | undefined}
@@ -365,23 +436,53 @@ export function CapabilitySummary({ spec }: { spec: CapabilitySpec }) {
}
if (spec.operations?.length) labels.unshift(spec.operations.map(operationLabel).join("/"));
if (!labels.length) return <span className="text-xs text-foreground/45">基础能力</span>;
return <div className="flex max-w-lg flex-wrap gap-1">{labels.slice(0, 4).map((label) => <Tag key={label}>{label}</Tag>)}{labels.length > 4 ? <Tag>+{labels.length - 4}</Tag> : null}</div>;
return (
<div className="flex max-w-lg flex-wrap gap-1">
{labels.slice(0, 4).map((label) => (
<Tag key={label}>{label}</Tag>
))}
{labels.length > 4 ? <Tag>+{labels.length - 4}</Tag> : null}
</div>
);
}
export function sanitizeDefaults(spec: CapabilitySpec, defaults: Record<string, unknown> | undefined) {
return Object.fromEntries(Object.entries(defaults || {}).filter(([name, value]) => spec.options?.[name] && constraintIncludes(spec.options[name], value)));
const result: Record<string, Scalar> = {};
for (const [name, rawValue] of Object.entries(defaults || {})) {
const constraint = spec.options?.[name];
if (!constraint || !constraintIncludes(constraint, rawValue)) continue;
// `*` 是能力匹配用的通配符,不是可发送给模型的默认参数。
if (scalarKey(rawValue) === "*") {
const concrete = constraint.values?.find((item) => scalarKey(item) !== "*");
if (concrete !== undefined) result[name] = normalizeScalar(concrete) as Scalar;
continue;
}
result[name] = normalizeScalar(rawValue) as Scalar;
}
return result;
}
function CapabilityBlock({ title, children }: { title: string; children: ReactNode }) {
return <section className="space-y-2"><div className="text-xs font-semibold text-foreground/65">{title}</div>{children}</section>;
return (
<section className="space-y-2">
<div className="text-xs font-semibold text-foreground/65">{title}</div>
{children}
</section>
);
}
function OptionRuleEditor({ definition, source, value, onChange }: { definition: { name: string; label: string; unit?: string }; source: OptionConstraint; value?: OptionConstraint; onChange: (value?: OptionConstraint) => void }) {
return (
<div className="grid grid-cols-12 items-center gap-2 py-3 first:pt-0 last:pb-0">
<div className="col-span-12 sm:col-span-5"><div className="text-sm font-medium">{definition.label}</div><div className="text-xs text-foreground/45">{constraintSummary(source, definition.unit)}</div></div>
<div className="col-span-12 sm:col-span-1"><Switch size="small" checked={Boolean(value)} onChange={(enabled) => onChange(enabled ? source : undefined)} /></div>
<div className="col-span-12 sm:col-span-6">{source.values ? (
<div className="col-span-12 sm:col-span-5">
<div className="text-sm font-medium">{definition.label}</div>
<div className="text-xs text-foreground/45">{constraintSummary(source, definition.unit)}</div>
</div>
<div className="col-span-12 sm:col-span-1">
<Switch size="small" checked={Boolean(value)} onChange={(enabled) => onChange(enabled ? source : undefined)} />
</div>
<div className="col-span-12 sm:col-span-6">
{source.values ? (
<Select
className="w-full"
mode="multiple"
@@ -392,24 +493,70 @@ function OptionRuleEditor({ definition, source, value, onChange }: { definition:
/>
) : (
<div className="grid grid-cols-3 gap-2">
<label><span className="mb-1 block text-xs text-foreground/45">最小值</span><InputNumber className="w-full" disabled={!value} min={source.min} max={value?.max ?? source.max} value={value?.min} onChange={(next) => onChange({ ...value, min: next ?? source.min, max: value?.max ?? source.max, step: value?.step ?? source.step })} /></label>
<label><span className="mb-1 block text-xs text-foreground/45">最大值</span><InputNumber className="w-full" disabled={!value} min={value?.min ?? source.min} max={source.max} value={value?.max} onChange={(next) => onChange({ ...value, min: value?.min ?? source.min, max: next ?? source.max, step: value?.step ?? source.step })} /></label>
<label><span className="mb-1 block text-xs text-foreground/45">调整步长</span><InputNumber className="w-full" disabled={!value} min={source.step || 0.000001} max={source.max} value={value?.step} onChange={(next) => onChange({ ...value, min: value?.min ?? source.min, max: value?.max ?? source.max, step: next ?? source.step })} /></label>
<label>
<span className="mb-1 block text-xs text-foreground/45">最小值</span>
<InputNumber
className="w-full"
disabled={!value}
min={source.min}
max={value?.max ?? source.max}
value={value?.min}
onChange={(next) => onChange({ ...value, min: next ?? source.min, max: value?.max ?? source.max, step: value?.step ?? source.step })}
/>
</label>
<label>
<span className="mb-1 block text-xs text-foreground/45">最大值</span>
<InputNumber
className="w-full"
disabled={!value}
min={value?.min ?? source.min}
max={source.max}
value={value?.max}
onChange={(next) => onChange({ ...value, min: value?.min ?? source.min, max: next ?? source.max, step: value?.step ?? source.step })}
/>
</label>
<label>
<span className="mb-1 block text-xs text-foreground/45">调整步长</span>
<InputNumber
className="w-full"
disabled={!value}
min={source.step || 0.000001}
max={source.max}
value={value?.step}
onChange={(next) => onChange({ ...value, min: value?.min ?? source.min, max: value?.max ?? source.max, step: next ?? source.step })}
/>
</label>
</div>
)}</div>
)}
</div>
</div>
);
}
function ConstraintValueInput({ constraint, value, allowClear, onChange }: { constraint: OptionConstraint; value?: Scalar; allowClear?: boolean; onChange: (value?: Scalar) => void }) {
if (constraint.values) {
return <Select className="w-full" allowClear={allowClear} value={value === undefined ? undefined : scalarKey(value)} options={constraint.values.map((item) => ({ value: scalarKey(item), label: scalarLabel(item) }))} onChange={(key) => onChange(key === undefined ? undefined : normalizeScalar(constraint.values!.find((item) => scalarKey(item) === key)) as Scalar)} />;
return (
<Select
className="w-full"
allowClear={allowClear}
value={value === undefined ? undefined : scalarKey(value)}
options={constraint.values.map((item) => ({ value: scalarKey(item), label: scalarLabel(item) }))}
onChange={(key) => onChange(key === undefined ? undefined : (normalizeScalar(constraint.values!.find((item) => scalarKey(item) === key)) as Scalar))}
/>
);
}
return <InputNumber className="w-full" min={constraint.min} max={constraint.max} step={constraint.step} value={typeof value === "number" ? value : undefined} onChange={(next) => onChange(next ?? undefined)} />;
}
function NumberInput({ label, unit, value, min, max, disabled, onChange }: { label: string; unit: string; value?: number; min?: number; max?: number; disabled?: boolean; onChange: (value: number | null) => void }) {
return <label className="min-w-0"><span className="mb-1 block text-xs text-foreground/45">{label} ({unit})</span><InputNumber className="w-full" precision={0} disabled={disabled} value={value} min={min} max={max} onChange={onChange} /></label>;
return (
<label className="min-w-0">
<span className="mb-1 block text-xs text-foreground/45">
{label} ({unit})
</span>
<InputNumber className="w-full" precision={0} disabled={disabled} value={value} min={min} max={max} onChange={onChange} />
</label>
);
}
function updateInput(current: CapabilitySpec, name: string, constraint: { min: number; max: number } | undefined, update: (patch: Partial<CapabilitySpec>) => void) {
@@ -450,12 +597,12 @@ function widestCoveredInputRange(constraints: Array<{ min: number; max: number }
return merged.reduce((best, current) => {
const bestSize = best.max - best.min;
const currentSize = current.max - current.min;
return currentSize > bestSize || currentSize === bestSize && current.min < best.min ? current : best;
return currentSize > bestSize || (currentSize === bestSize && current.min < best.min) ? current : best;
});
}
function preferredSourceConstraint(constraints: OptionConstraint[]) {
return constraints.reduce((best, current) => constraintBreadth(current) > constraintBreadth(best) ? current : best);
return constraints.reduce((best, current) => (constraintBreadth(current) > constraintBreadth(best) ? current : best));
}
function constraintBreadth(constraint: OptionConstraint) {
@@ -474,6 +621,7 @@ function scalarLabel(value: unknown) {
const normalized = normalizeScalar(value);
if (normalized === true) return "支持";
if (normalized === false) return "不启用";
if (normalized === "*") return "自定义";
return String(normalized);
}
@@ -483,16 +631,18 @@ function normalizeScalar(value: unknown) {
}
export function operationLabel(value: string) {
return {
text_to_video: "文生视频",
image_to_video: "图生视频",
extend: "视频续写",
inpaint: "局部修改",
replace_element: "元素替换",
camera_motion: "运镜调整",
style_transfer: "风格迁移",
audio_to_video: "音频生视频",
}[value] || value;
return (
{
text_to_video: "文生视频",
image_to_video: "图生视频",
extend: "视频续写",
inpaint: "局部修改",
replace_element: "元素替换",
camera_motion: "运镜调整",
style_transfer: "风格迁移",
audio_to_video: "音频生视频",
}[value] || value
);
}
function inputLabel(capability: CapabilityKind, name: string) {
@@ -509,7 +659,7 @@ function constraintSummary(constraint: OptionConstraint, unit = "") {
}
function constraintIncludes(constraint: OptionConstraint, value: unknown) {
if (constraint.values?.length === 1 && constraint.values[0] === "*") return true;
if (isWildcardConstraint(constraint)) return true;
if (constraint.values) return constraint.values.some((item) => scalarKey(item) === scalarKey(value));
if (typeof value !== "number") return false;
if ((constraint.min !== undefined && value < constraint.min - 1e-9) || (constraint.max !== undefined && value > constraint.max + 1e-9)) return false;
@@ -585,7 +735,7 @@ function continuousOptionRangeCovered(minimum: number, maximum: number, constrai
}
function isWildcardConstraint(constraint: OptionConstraint) {
return constraint.values?.length === 1 && constraint.values[0] === "*";
return constraint.values?.some((value) => normalizeScalar(value) === "*") || false;
}
function approximatelyInteger(value: number) {

View File

@@ -196,7 +196,12 @@ export default function CreatePage() {
useEffect(() => {
if (mode !== "image") return;
const normalized = normalizeImageValue(imageProfile, { size: ratio, quality, count });
// 前台逻辑模型的默认参数优先于旧的全局创作参数;否则旧的合法值会一直覆盖后台刚配置的默认值。
const normalized = normalizeImageValue(imageProfile, {
size: imageProfile.size.default,
quality: imageProfile.quality.default,
count,
});
setRatio(normalized.size);
setQuality(normalized.quality);
setCount(normalized.count);
@@ -204,7 +209,12 @@ export default function CreatePage() {
useEffect(() => {
if (mode !== "video") return;
const normalized = normalizeVideoValue(videoProfile, { seconds, ratio, resolution: `${videoQuality}p` });
// 前台逻辑模型的默认参数必须直接落到创作端状态,提交任务时才不会被旧状态覆盖。
const normalized = normalizeVideoValue(videoProfile, {
seconds: String(videoProfile.duration.default),
ratio: videoProfile.defaultRatio,
resolution: videoProfile.defaultResolution,
});
setSeconds(normalized.seconds);
setRatio(normalized.ratio);
setVideoQuality(normalized.resolution.replace(/p$/i, ""));