Files
ttstd-infinite-canvas/backend/internal/canvas/capability/descriptor.go

276 lines
7.2 KiB
Go

package capability
import (
"fmt"
"sort"
"strings"
"unicode"
)
const (
patchKindString = "string"
patchKindNumber = "number"
patchKindBoolean = "boolean"
)
type ConnectionPolicy struct {
CanSource bool
CanTarget bool
CanReference bool
AcceptedInputKinds []string
RejectedInputKinds []string
MaxInputCount int
}
type PatchField struct {
Path string
Kind string
Label string
Order int
Description string
MaxRunes int
}
// Descriptor is the server-owned canvas contract. Agent tools, creation,
// persistence and state projection all consume this registry; no caller owns a
// second node allow-list.
type Descriptor struct {
Type string
Version string
Label string
DefaultWidth float64
DefaultHeight float64
InputKind string
GenerationMode string
Connection ConnectionPolicy
CanUpdate bool
SummaryFields []string
DetailFields []string
ProjectionKind string
ProjectionField string
PatchFields map[string]PatchField
CreateMetadata func(content string) map[string]any
}
func (d Descriptor) Metadata(content string) map[string]any {
if d.CreateMetadata != nil {
return d.CreateMetadata(content)
}
return map[string]any{"content": content, "status": "idle"}
}
func (d Descriptor) AllowsInput(kind string) bool {
if !d.Connection.CanTarget {
return false
}
kind = normalizeType(kind)
for _, rejected := range d.Connection.RejectedInputKinds {
if rejected == kind {
return false
}
}
if len(d.Connection.AcceptedInputKinds) == 0 {
return true
}
for _, accepted := range d.Connection.AcceptedInputKinds {
if accepted == kind {
return true
}
}
return false
}
func (d Descriptor) ValidatePatch(patch map[string]any) error {
if !d.CanUpdate {
return fmt.Errorf("%s 节点不支持更新", d.Label)
}
if len(patch) == 0 {
return fmt.Errorf("%s 节点更新内容不能为空", d.Label)
}
for key, value := range patch {
field, allowed := d.PatchFields[key]
if !allowed {
return fmt.Errorf("%s 节点不支持更新字段 %s", d.Label, key)
}
switch field.Kind {
case "string":
text, ok := value.(string)
if !ok {
return fmt.Errorf("%s 字段 %s 必须是字符串", d.Label, key)
}
if field.MaxRunes > 0 && len([]rune(text)) > field.MaxRunes {
return fmt.Errorf("%s 字段 %s 超出长度限制", d.Label, key)
}
case "number":
if _, ok := value.(float64); !ok {
return fmt.Errorf("%s 字段 %s 必须是数字", d.Label, key)
}
case "boolean":
if _, ok := value.(bool); !ok {
return fmt.Errorf("%s 字段 %s 必须是布尔值", d.Label, key)
}
default:
return fmt.Errorf("%s 字段 %s 的类型契约无效", d.Label, key)
}
}
return nil
}
func (d Descriptor) ApplyPatch(node map[string]any, patch map[string]any) error {
if err := d.ValidatePatch(patch); err != nil {
return err
}
for key, value := range patch {
field := d.PatchFields[key]
parts := strings.Split(field.Path, ".")
target := node
for _, part := range parts[:len(parts)-1] {
child, ok := target[part].(map[string]any)
if !ok {
child = map[string]any{}
target[part] = child
}
target = child
}
target[parts[len(parts)-1]] = value
}
return nil
}
func (d Descriptor) ValidateConnection(fromKind string) error {
if !d.Connection.CanTarget || d.GenerationMode == "" {
return fmt.Errorf("%s 节点不能接收参考输入", d.Label)
}
if !d.AllowsInput(fromKind) {
return fmt.Errorf("%s生成节点不接受%s输入", d.Label, inputKindLabel(fromKind))
}
return nil
}
func inputKindLabel(kind string) string {
switch kind {
case "image":
return "图片"
case "video":
return "视频"
case "audio":
return "音频"
default:
return "文本"
}
}
func normalizeType(value string) string { return strings.ToLower(strings.TrimSpace(value)) }
func normalizeInputKind(value string) (string, error) {
return normalizeCapabilityIdentifier(value, "canvas input kind")
}
func normalizeGenerationMode(value string) (string, error) {
return normalizeCapabilityIdentifier(value, "canvas generation mode")
}
func normalizeProjectionKind(value string) (string, error) {
return normalizeCapabilityIdentifier(value, "canvas projection kind")
}
func normalizeCapabilityIdentifier(value, label string) (string, error) {
value = normalizeType(value)
if value == "" {
return "", nil
}
for index, r := range value {
if index == 0 {
if r < 'a' || r > 'z' {
return "", fmt.Errorf("invalid %s %q", label, value)
}
continue
}
if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '_' && r != '-' {
return "", fmt.Errorf("invalid %s %q", label, value)
}
}
return value, nil
}
func normalizeConnectionKinds(values []string) ([]string, error) {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
kind, err := normalizeInputKind(value)
if err != nil {
return nil, err
}
if kind == "" {
return nil, fmt.Errorf("canvas connection input kind cannot be empty")
}
if _, exists := seen[kind]; exists {
return nil, fmt.Errorf("duplicate canvas connection input kind %q", kind)
}
seen[kind] = struct{}{}
out = append(out, kind)
}
sort.Strings(out)
return out, nil
}
func validatePatchPath(path string) error {
path = strings.TrimSpace(path)
if path == "" {
return fmt.Errorf("canvas patch field path cannot be empty")
}
for _, segment := range strings.Split(path, ".") {
if segment == "" || segment == "." || segment == ".." || segment == "__proto__" || segment == "constructor" || segment == "prototype" {
return fmt.Errorf("invalid canvas patch field path %q", path)
}
for index, r := range segment {
if index == 0 {
if r != '_' && !unicode.IsLetter(r) {
return fmt.Errorf("invalid canvas patch field path %q", path)
}
continue
}
if r != '_' && r != '-' && !unicode.IsLetter(r) && !unicode.IsDigit(r) {
return fmt.Errorf("invalid canvas patch field path %q", path)
}
}
}
return nil
}
func normalizePatchFields(fields map[string]PatchField) (map[string]PatchField, error) {
out := make(map[string]PatchField, len(fields))
for key, field := range fields {
key = strings.TrimSpace(key)
if key == "" {
return nil, fmt.Errorf("canvas patch field key cannot be empty")
}
if _, exists := out[key]; exists {
return nil, fmt.Errorf("duplicate canvas patch field %q", key)
}
field.Path = strings.TrimSpace(field.Path)
if err := validatePatchPath(field.Path); err != nil {
return nil, err
}
field.Kind = normalizeType(field.Kind)
field.Label = strings.TrimSpace(field.Label)
field.Description = strings.TrimSpace(field.Description)
if field.Label == "" {
return nil, fmt.Errorf("canvas patch field %q requires a user-facing label", key)
}
if field.Order <= 0 {
return nil, fmt.Errorf("canvas patch field %q requires a positive display order", key)
}
switch field.Kind {
case patchKindString, patchKindNumber, patchKindBoolean:
default:
return nil, fmt.Errorf("unsupported canvas patch field kind %q", field.Kind)
}
if field.MaxRunes < 0 {
return nil, fmt.Errorf("canvas patch field %q has invalid max runes", key)
}
out[key] = field
}
return out, nil
}