Files
ttstd-infinite-canvas/backend/internal/protocol/image_parameters.go
huiku 7466119867 feat(image): 以插件声明统一图片参数、尺寸选择与定价合同 (#760)
* feat(image): 图片参数 - 统一插件声明配置与生成合同

* docs(image): 图片参数 - 说明声明合同与验证边界

* fix(plugins): 图片声明 - 保留内联素材支持并同步制品文档

* fix(image): 参数合同 - 对齐审批意图和尺寸预检调用方

* fix(image): 调用链 - 保留公开意图并接通独立分辨率

* fix(image): 图片参数 - 补齐分辨率接线与目录价格投影
2026-10-13 19:18:21 +08:00

293 lines
9.1 KiB
Go

package protocol
import (
"encoding/json"
"fmt"
"math"
"regexp"
"slices"
"strings"
)
// ImageParameter is the image input contract shared by plugins, model
// configuration and generation. Binding is interpreted only at the provider
// boundary; names and values are never inferred from a model's display name.
type ImageParameter struct {
Name string `json:"name"`
Label string `json:"label"`
Type string `json:"type"`
Description string `json:"description,omitempty"`
Values []any `json:"values,omitempty"`
Min *float64 `json:"min,omitempty"`
Max *float64 `json:"max,omitempty"`
Step *float64 `json:"step,omitempty"`
MaxLength int `json:"maxLength,omitempty"`
Default any `json:"default,omitempty"`
Required bool `json:"required,omitempty"`
Pricing bool `json:"pricing,omitempty"`
Binding string `json:"binding,omitempty"`
Encoding string `json:"encoding,omitempty"`
Models []string `json:"models,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
func (p ImageParameter) IsEnabled() bool { return p.Enabled == nil || *p.Enabled }
func (p ImageParameter) AppliesTo(model string) bool {
return len(p.Models) == 0 || slices.Contains(p.Models, strings.TrimPrefix(strings.TrimSpace(model), "models/"))
}
var imageParameterName = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{0,63}$`)
func ValidateImageParameters(parameters []ImageParameter) error {
seen := map[string]bool{}
bindings := map[string]bool{}
for _, p := range parameters {
if !imageParameterName.MatchString(p.Name) || seen[p.Name] || strings.TrimSpace(p.Label) == "" {
return fmt.Errorf("invalid or duplicate image parameter %q", p.Name)
}
seen[p.Name] = true
if p.Encoding != "" && (p.Binding != "aspectRatio" || p.Encoding != "pixels" && p.Encoding != "ratio") {
return fmt.Errorf("image parameter %s has invalid encoding", p.Name)
}
switch p.Type {
case "string", "number", "integer", "boolean":
default:
return fmt.Errorf("image parameter %s has unsupported type %s", p.Name, p.Type)
}
if p.Min != nil && (!finite(*p.Min) || p.Max != nil && *p.Min > *p.Max) || p.Max != nil && !finite(*p.Max) || p.Step != nil && (!finite(*p.Step) || *p.Step <= 0) || p.MaxLength < 0 {
return fmt.Errorf("image parameter %s has invalid bounds", p.Name)
}
if p.Binding != "" {
if !validImageParameterBinding(p.Binding) || bindings[p.Binding] {
return fmt.Errorf("image parameter %s has invalid or duplicate binding", p.Name)
}
bindings[p.Binding] = true
}
for _, value := range p.Values {
constraint := p
constraint.Values = nil
if !constraint.Accepts(value) {
return fmt.Errorf("image parameter %s has invalid enum value", p.Name)
}
}
if p.Default != nil && !p.Accepts(p.Default) {
return fmt.Errorf("image parameter %s default is outside its constraints", p.Name)
}
}
return nil
}
func finite(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) }
func (p ImageParameter) Accepts(value any) bool {
if value == nil {
return !p.Required
}
switch p.Type {
case "string":
text, ok := value.(string)
if !ok || p.MaxLength > 0 && len([]rune(text)) > p.MaxLength {
return false
}
case "boolean":
if _, ok := value.(bool); !ok {
return false
}
case "number", "integer":
number, ok := imageParameterNumber(value)
if !ok || !finite(number) || p.Type == "integer" && math.Trunc(number) != number {
return false
}
if p.Min != nil && number < *p.Min || p.Max != nil && number > *p.Max {
return false
}
if p.Step != nil {
base := 0.0
if p.Min != nil {
base = *p.Min
}
steps := (number - base) / *p.Step
if math.Abs(steps-math.Round(steps)) > 1e-8 {
return false
}
}
default:
return false
}
if len(p.Values) == 0 {
return true
}
for _, candidate := range p.Values {
if ImageParameterValuesEqual(candidate, value) {
return true
}
}
return false
}
func imageParameterNumber(value any) (float64, bool) {
switch v := value.(type) {
case float64:
return v, true
case float32:
return float64(v), true
case int:
return float64(v), true
case int64:
return float64(v), true
case json.Number:
n, err := v.Float64()
return n, err == nil
default:
return 0, false
}
}
func ImageParameterValuesEqual(left, right any) bool {
l, lok := imageParameterNumber(left)
r, rok := imageParameterNumber(right)
if lok || rok {
return lok && rok && l == r
}
lb, _ := json.Marshal(left)
rb, _ := json.Marshal(right)
return string(lb) == string(rb)
}
func validImageParameterBinding(binding string) bool {
switch binding {
case "aspectRatio", "resolution", "quality", "imageCount", "transparentBackground":
return true
}
parts := strings.Split(binding, ".")
return len(parts) == 3 && parts[0] == "providerOptions" && validManifestIdentifier(parts[1]) && imageParameterName.MatchString(parts[2])
}
// ResolveImageParameters takes only channel-editable restrictions. Labels,
// types, pricing eligibility and bindings always come from the plugin.
func ResolveImageParameters(definitions, configured []ImageParameter, model string) ([]ImageParameter, error) {
if err := ValidateImageParameters(definitions); err != nil {
return nil, err
}
settings := map[string]ImageParameter{}
for _, p := range configured {
if _, duplicate := settings[p.Name]; duplicate {
return nil, fmt.Errorf("duplicate image parameter %s", p.Name)
}
settings[p.Name] = p
}
result := make([]ImageParameter, 0, len(definitions))
for _, definition := range definitions {
if !definition.AppliesTo(model) {
continue
}
p := definition
if setting, ok := settings[p.Name]; ok {
p.Enabled = setting.Enabled
if !p.IsEnabled() && p.Required {
return nil, fmt.Errorf("%s is required", p.Label)
}
if setting.Values != nil {
for _, value := range setting.Values {
if !definition.Accepts(value) {
return nil, fmt.Errorf("%s value exceeds plugin constraints", p.Label)
}
}
if len(setting.Values) == 0 && len(definition.Values) > 0 && p.IsEnabled() {
return nil, fmt.Errorf("%s requires supported values", p.Label)
}
p.Values = setting.Values
}
if setting.Min != nil {
if definition.Min != nil && *setting.Min < *definition.Min {
return nil, fmt.Errorf("%s minimum exceeds plugin constraints", p.Label)
}
p.Min = setting.Min
}
if setting.Max != nil {
if definition.Max != nil && *setting.Max > *definition.Max {
return nil, fmt.Errorf("%s maximum exceeds plugin constraints", p.Label)
}
p.Max = setting.Max
}
if setting.Step != nil {
if definition.Step != nil && (math.Abs(*setting.Step / *definition.Step - math.Round(*setting.Step / *definition.Step)) > 1e-8 || *setting.Step < *definition.Step) {
return nil, fmt.Errorf("%s step exceeds plugin constraints", p.Label)
}
p.Step = setting.Step
}
if definition.Step != nil && p.Min != nil {
base := 0.0
if definition.Min != nil {
base = *definition.Min
}
steps := (*p.Min - base) / *definition.Step
if math.Abs(steps-math.Round(steps)) > 1e-8 {
return nil, fmt.Errorf("%s minimum does not align with plugin step", p.Label)
}
}
p.Default = setting.Default
delete(settings, p.Name)
}
result = append(result, p)
}
for name, p := range settings {
if p.IsEnabled() {
return nil, fmt.Errorf("plugin does not declare image parameter %s for this model", name)
}
}
return result, ValidateImageParameters(result)
}
// ApplyImageParameters runs before building a request. It preserves false and
// zero, rejects undeclared values, and maps only plugin-declared destinations.
func ApplyImageParameters(request GenerationRequest, definitions []ImageParameter) (GenerationRequest, error) {
if request.Capability != CapabilityImage || request.ImageParameters == nil {
return request, nil
}
allowed := map[string]ImageParameter{}
for _, p := range definitions {
if p.AppliesTo(request.Model) {
allowed[p.Name] = p
}
}
if request.ProviderOptions == nil {
request.ProviderOptions = map[string]map[string]any{}
}
if request.Extra == nil {
request.Extra = map[string]any{}
}
for name, value := range request.ImageParameters {
p, ok := allowed[name]
if !ok || !p.Accepts(value) {
return request, fmt.Errorf("unsupported image parameter %s", name)
}
if value == nil {
continue
}
switch p.Binding {
case "aspectRatio":
request.AspectRatio = fmt.Sprint(value)
case "resolution":
request.Resolution = fmt.Sprint(value)
case "quality":
request.Quality = fmt.Sprint(value)
case "imageCount":
n, _ := imageParameterNumber(value)
request.ImageCount = int(n)
case "transparentBackground":
request.Extra["transparentBackground"] = fmt.Sprint(value)
default:
parts := strings.Split(p.Binding, ".")
if len(parts) != 3 || parts[0] != "providerOptions" {
return request, fmt.Errorf("image parameter %s has no request binding", name)
}
if request.ProviderOptions[parts[1]] == nil {
request.ProviderOptions[parts[1]] = map[string]any{}
}
request.ProviderOptions[parts[1]][parts[2]] = value
}
}
return request, nil
}