feat(canvas): 云端 Agent - 完成策略分层、节点能力注册与可解释审批改造
This commit is contained in:
79
backend/internal/canvas/capability/builtin.go
Normal file
79
backend/internal/canvas/capability/builtin.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package capability
|
||||
|
||||
const (
|
||||
maxAgentNodeTitleRunes = 240
|
||||
maxAgentNodeContentRunes = 16000
|
||||
)
|
||||
|
||||
func BuiltinRegistry() *Registry {
|
||||
registry, err := NewRegistry([]Descriptor{
|
||||
{
|
||||
Type: "text", Version: "1", Label: "文本", DefaultWidth: 340, DefaultHeight: 240,
|
||||
InputKind: "text", Connection: ConnectionPolicy{CanSource: true}, CanUpdate: true,
|
||||
SummaryFields: []string{"content"}, DetailFields: []string{"content"},
|
||||
PatchFields: editableNodeFields("metadata.content", "正文", "节点正文"),
|
||||
CreateMetadata: func(content string) map[string]any {
|
||||
return map[string]any{"content": content, "status": "idle", "fontSize": float64(14)}
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "markdown", Version: "1", Label: "Markdown", DefaultWidth: 420, DefaultHeight: 320,
|
||||
InputKind: "text", Connection: ConnectionPolicy{CanSource: true}, CanUpdate: true,
|
||||
SummaryFields: []string{"content"}, DetailFields: []string{"content"},
|
||||
PatchFields: editableNodeFields("metadata.content", "Markdown 正文", "Markdown 正文"),
|
||||
},
|
||||
generatedMediaDescriptor("image", "2", "图片", 720, 405, "image", ConnectionPolicy{
|
||||
CanSource: true, CanTarget: true, CanReference: true, AcceptedInputKinds: []string{"text", "image"},
|
||||
}),
|
||||
generatedMediaDescriptor("video", "2", "视频", 720, 405, "video", ConnectionPolicy{
|
||||
CanSource: true, CanTarget: true, CanReference: true, AcceptedInputKinds: []string{"text", "image", "video", "audio"},
|
||||
}),
|
||||
generatedMediaDescriptor("audio", "2", "音频", 340, 120, "audio", ConnectionPolicy{
|
||||
CanSource: true, CanTarget: true, CanReference: true, MaxInputCount: 1, AcceptedInputKinds: []string{"text"},
|
||||
}),
|
||||
{
|
||||
Type: "frame", Version: "1", Label: "背板", DefaultWidth: 760, DefaultHeight: 520,
|
||||
SummaryFields: []string{"label"}, DetailFields: []string{"label"},
|
||||
CreateMetadata: func(string) map[string]any {
|
||||
return map[string]any{"frame": map[string]any{"collapsed": false, "expandedWidth": float64(760), "expandedHeight": float64(520)}}
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "script", Version: "1", Label: "分镜脚本", DefaultWidth: 920, DefaultHeight: 360,
|
||||
SummaryFields: []string{"storyboard"}, DetailFields: []string{"storyboard"}, ProjectionKind: "storyboard", ProjectionField: "storyboard",
|
||||
CreateMetadata: func(string) map[string]any {
|
||||
return map[string]any{"status": "idle", "workflowKind": "script", "storyboard": map[string]any{"rows": []any{}, "visibleColumns": []any{"shotNumber", "durationSeconds", "videoMotionPrompt", "dialogue", "assets"}, "referenceNodeIds": []any{}}}
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
func generatedMediaDescriptor(nodeType, version, label string, width, height float64, generationMode string, connection ConnectionPolicy) Descriptor {
|
||||
return Descriptor{
|
||||
Type: nodeType, Version: version, Label: label, DefaultWidth: width, DefaultHeight: height,
|
||||
InputKind: nodeType, GenerationMode: generationMode, Connection: connection, CanUpdate: true,
|
||||
SummaryFields: []string{"prompt", "composerContent", "assetTags", "referenceNodeIds"},
|
||||
DetailFields: []string{"prompt", "composerContent", "assetTags", "referenceNodeIds"},
|
||||
PatchFields: editableNodeFields("metadata.composerContent", "下一版提示词", "下次生成使用的提示词草稿;不覆盖已提交提示词或媒体结果"),
|
||||
CreateMetadata: generatedMetadata,
|
||||
}
|
||||
}
|
||||
|
||||
func editableNodeFields(contentPath, contentLabel, contentDescription string) map[string]PatchField {
|
||||
return map[string]PatchField{
|
||||
"title": {
|
||||
Path: "title", Kind: patchKindString, Label: "节点名称", Order: 10, Description: "节点标题", MaxRunes: maxAgentNodeTitleRunes,
|
||||
},
|
||||
"content": {
|
||||
Path: contentPath, Kind: patchKindString, Label: contentLabel, Order: 20, Description: contentDescription, MaxRunes: maxAgentNodeContentRunes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func generatedMetadata(prompt string) map[string]any {
|
||||
return map[string]any{"content": "", "prompt": prompt, "composerContent": prompt, "status": "idle"}
|
||||
}
|
||||
275
backend/internal/canvas/capability/descriptor.go
Normal file
275
backend/internal/canvas/capability/descriptor.go
Normal file
@@ -0,0 +1,275 @@
|
||||
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
|
||||
}
|
||||
255
backend/internal/canvas/capability/registry.go
Normal file
255
backend/internal/canvas/capability/registry.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package capability
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const SetVersion = "canvas-capabilities/v3"
|
||||
|
||||
type Registry struct{ descriptors map[string]Descriptor }
|
||||
|
||||
func NewRegistry(descriptors []Descriptor) (*Registry, error) {
|
||||
items := make(map[string]Descriptor, len(descriptors))
|
||||
generationModes := make(map[string]string, len(descriptors))
|
||||
for _, descriptor := range descriptors {
|
||||
descriptor.Type = normalizeType(descriptor.Type)
|
||||
descriptor.Version = strings.TrimSpace(descriptor.Version)
|
||||
descriptor.Label = strings.TrimSpace(descriptor.Label)
|
||||
if descriptor.Type == "" || descriptor.Version == "" || descriptor.Label == "" || descriptor.DefaultWidth <= 0 || descriptor.DefaultHeight <= 0 {
|
||||
return nil, fmt.Errorf("invalid canvas capability descriptor %q", descriptor.Type)
|
||||
}
|
||||
inputKind, err := normalizeInputKind(descriptor.InputKind)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("capability %q: %w", descriptor.Type, err)
|
||||
}
|
||||
descriptor.InputKind = inputKind
|
||||
generationMode, err := normalizeGenerationMode(descriptor.GenerationMode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("capability %q: %w", descriptor.Type, err)
|
||||
}
|
||||
descriptor.GenerationMode = generationMode
|
||||
if generationMode != "" {
|
||||
if existing, exists := generationModes[generationMode]; exists {
|
||||
return nil, fmt.Errorf("generation mode %q is registered by both %q and %q", generationMode, existing, descriptor.Type)
|
||||
}
|
||||
generationModes[generationMode] = descriptor.Type
|
||||
}
|
||||
projectionKind, err := normalizeProjectionKind(descriptor.ProjectionKind)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("capability %q: %w", descriptor.Type, err)
|
||||
}
|
||||
descriptor.ProjectionKind = projectionKind
|
||||
descriptor.ProjectionField = strings.TrimSpace(descriptor.ProjectionField)
|
||||
if (descriptor.ProjectionKind == "") != (descriptor.ProjectionField == "") {
|
||||
return nil, fmt.Errorf("capability %q must declare projection kind and field together", descriptor.Type)
|
||||
}
|
||||
if descriptor.ProjectionField != "" {
|
||||
if err := validatePatchPath(descriptor.ProjectionField); err != nil {
|
||||
return nil, fmt.Errorf("capability %q has invalid projection field: %w", descriptor.Type, err)
|
||||
}
|
||||
}
|
||||
if descriptor.Connection.MaxInputCount < 0 {
|
||||
return nil, fmt.Errorf("capability %q has invalid max input count", descriptor.Type)
|
||||
}
|
||||
accepted, err := normalizeConnectionKinds(descriptor.Connection.AcceptedInputKinds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("capability %q: %w", descriptor.Type, err)
|
||||
}
|
||||
rejected, err := normalizeConnectionKinds(descriptor.Connection.RejectedInputKinds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("capability %q: %w", descriptor.Type, err)
|
||||
}
|
||||
rejectedSet := make(map[string]struct{}, len(rejected))
|
||||
for _, kind := range rejected {
|
||||
rejectedSet[kind] = struct{}{}
|
||||
}
|
||||
for _, kind := range accepted {
|
||||
if _, conflict := rejectedSet[kind]; conflict {
|
||||
return nil, fmt.Errorf("capability %q accepts and rejects input kind %q", descriptor.Type, kind)
|
||||
}
|
||||
}
|
||||
if descriptor.Connection.CanSource && descriptor.InputKind == "" {
|
||||
return nil, fmt.Errorf("capability %q can source without an input kind", descriptor.Type)
|
||||
}
|
||||
descriptor.Connection.AcceptedInputKinds = accepted
|
||||
descriptor.Connection.RejectedInputKinds = rejected
|
||||
descriptor.PatchFields, err = normalizePatchFields(descriptor.PatchFields)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("capability %q: %w", descriptor.Type, err)
|
||||
}
|
||||
if descriptor.CanUpdate != (len(descriptor.PatchFields) > 0) {
|
||||
return nil, fmt.Errorf("capability %q must declare both canUpdate and patch fields, or neither", descriptor.Type)
|
||||
}
|
||||
descriptor.SummaryFields = normalizeFields(descriptor.SummaryFields)
|
||||
descriptor.DetailFields = normalizeFields(descriptor.DetailFields)
|
||||
if _, exists := items[descriptor.Type]; exists {
|
||||
return nil, fmt.Errorf("duplicate canvas capability %q", descriptor.Type)
|
||||
}
|
||||
items[descriptor.Type] = cloneDescriptor(descriptor)
|
||||
}
|
||||
return &Registry{descriptors: items}, nil
|
||||
}
|
||||
|
||||
func (r *Registry) Resolve(nodeType string) (Descriptor, bool) {
|
||||
if r == nil {
|
||||
return Descriptor{}, false
|
||||
}
|
||||
d, ok := r.descriptors[normalizeType(nodeType)]
|
||||
if !ok {
|
||||
return Descriptor{}, false
|
||||
}
|
||||
d = cloneDescriptor(d)
|
||||
return d, ok
|
||||
}
|
||||
func (r *Registry) List() []Descriptor {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]Descriptor, 0, len(r.descriptors))
|
||||
for _, d := range r.descriptors {
|
||||
out = append(out, cloneDescriptor(d))
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Type < out[j].Type })
|
||||
return out
|
||||
}
|
||||
func (r *Registry) Types() []string {
|
||||
items := r.List()
|
||||
out := make([]string, 0, len(items))
|
||||
for _, d := range items {
|
||||
out = append(out, d.Type)
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (r *Registry) GenerationModeNames() []string {
|
||||
seen := map[string]struct{}{}
|
||||
for _, d := range r.List() {
|
||||
if d.GenerationMode != "" {
|
||||
seen[d.GenerationMode] = struct{}{}
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for mode := range seen {
|
||||
out = append(out, mode)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Registry) SupportsGenerationMode(mode string) bool {
|
||||
_, ok := r.ResolveGenerationMode(mode)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (r *Registry) ResolveGenerationMode(mode string) (Descriptor, bool) {
|
||||
mode = normalizeType(mode)
|
||||
if mode == "" || r == nil {
|
||||
return Descriptor{}, false
|
||||
}
|
||||
for _, descriptor := range r.descriptors {
|
||||
if descriptor.GenerationMode == mode {
|
||||
return cloneDescriptor(descriptor), true
|
||||
}
|
||||
}
|
||||
return Descriptor{}, false
|
||||
}
|
||||
func (r *Registry) Hash() string {
|
||||
data, _ := json.Marshal(registryHashItems(r.List()))
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func normalizeFields(fields []string) []string {
|
||||
seen := make(map[string]struct{}, len(fields))
|
||||
out := make([]string, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
field = strings.TrimSpace(field)
|
||||
if field == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[field]; exists {
|
||||
continue
|
||||
}
|
||||
seen[field] = struct{}{}
|
||||
out = append(out, field)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneDescriptor(descriptor Descriptor) Descriptor {
|
||||
descriptor.Connection.AcceptedInputKinds = append([]string(nil), descriptor.Connection.AcceptedInputKinds...)
|
||||
descriptor.Connection.RejectedInputKinds = append([]string(nil), descriptor.Connection.RejectedInputKinds...)
|
||||
descriptor.SummaryFields = append([]string(nil), descriptor.SummaryFields...)
|
||||
descriptor.DetailFields = append([]string(nil), descriptor.DetailFields...)
|
||||
patchFields := make(map[string]PatchField, len(descriptor.PatchFields))
|
||||
for key, field := range descriptor.PatchFields {
|
||||
patchFields[key] = field
|
||||
}
|
||||
descriptor.PatchFields = patchFields
|
||||
return descriptor
|
||||
}
|
||||
|
||||
type hashDescriptor struct {
|
||||
Type string
|
||||
Version string
|
||||
Label string
|
||||
DefaultWidth float64
|
||||
DefaultHeight float64
|
||||
InputKind string
|
||||
GenerationMode string
|
||||
Connection hashConnectionPolicy
|
||||
CanUpdate bool
|
||||
SummaryFields []string
|
||||
DetailFields []string
|
||||
ProjectionKind string
|
||||
ProjectionField string
|
||||
PatchFields []hashPatchField
|
||||
}
|
||||
|
||||
type hashConnectionPolicy struct {
|
||||
CanSource bool
|
||||
CanTarget bool
|
||||
CanReference bool
|
||||
AcceptedInputKinds []string
|
||||
RejectedInputKinds []string
|
||||
MaxInputCount int
|
||||
}
|
||||
|
||||
type hashPatchField struct {
|
||||
Key string
|
||||
Path string
|
||||
Kind string
|
||||
Label string
|
||||
Order int
|
||||
Description string
|
||||
MaxRunes int
|
||||
}
|
||||
|
||||
func registryHashItems(descriptors []Descriptor) []hashDescriptor {
|
||||
items := make([]hashDescriptor, 0, len(descriptors))
|
||||
for _, descriptor := range descriptors {
|
||||
keys := make([]string, 0, len(descriptor.PatchFields))
|
||||
for key := range descriptor.PatchFields {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
patchFields := make([]hashPatchField, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
field := descriptor.PatchFields[key]
|
||||
patchFields = append(patchFields, hashPatchField{Key: key, Path: field.Path, Kind: field.Kind, Label: field.Label, Order: field.Order, Description: field.Description, MaxRunes: field.MaxRunes})
|
||||
}
|
||||
items = append(items, hashDescriptor{
|
||||
Type: descriptor.Type, Version: descriptor.Version, Label: descriptor.Label,
|
||||
DefaultWidth: descriptor.DefaultWidth, DefaultHeight: descriptor.DefaultHeight,
|
||||
InputKind: descriptor.InputKind, GenerationMode: descriptor.GenerationMode,
|
||||
ProjectionKind: descriptor.ProjectionKind, ProjectionField: descriptor.ProjectionField,
|
||||
Connection: hashConnectionPolicy{CanSource: descriptor.Connection.CanSource, CanTarget: descriptor.Connection.CanTarget, CanReference: descriptor.Connection.CanReference, AcceptedInputKinds: descriptor.Connection.AcceptedInputKinds, RejectedInputKinds: descriptor.Connection.RejectedInputKinds, MaxInputCount: descriptor.Connection.MaxInputCount},
|
||||
CanUpdate: descriptor.CanUpdate, SummaryFields: descriptor.SummaryFields, DetailFields: descriptor.DetailFields, PatchFields: patchFields,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
287
backend/internal/canvas/capability/registry_test.go
Normal file
287
backend/internal/canvas/capability/registry_test.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package capability
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testDescriptor() Descriptor {
|
||||
return Descriptor{
|
||||
Type: " custom ", Version: "1", Label: "自定义", DefaultWidth: 100, DefaultHeight: 80,
|
||||
InputKind: " TEXT ", GenerationMode: " IMAGE ",
|
||||
Connection: ConnectionPolicy{CanSource: true, CanTarget: true, CanReference: true, AcceptedInputKinds: []string{"image", " TEXT "}, RejectedInputKinds: []string{"audio"}, MaxInputCount: 2},
|
||||
CanUpdate: true, SummaryFields: []string{" prompt ", "prompt", "content"}, DetailFields: []string{"content"},
|
||||
PatchFields: map[string]PatchField{
|
||||
"title": {Path: " title ", Kind: " STRING ", Label: " 节点名称 ", Order: 10, Description: " 节点标题 ", MaxRunes: 20},
|
||||
"enabled": {Path: "metadata.enabled", Kind: "boolean", Label: "启用状态", Order: 20},
|
||||
"weight": {Path: "metadata.weight", Kind: "number", Label: "权重", Order: 30},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryNormalizesAndProtectsDescriptors(t *testing.T) {
|
||||
input := testDescriptor()
|
||||
registry, err := NewRegistry([]Descriptor{input})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input.Connection.AcceptedInputKinds[0] = "video"
|
||||
input.PatchFields["title"] = PatchField{Path: "unsafe", Kind: "number"}
|
||||
|
||||
descriptor, ok := registry.Resolve(" CUSTOM ")
|
||||
if !ok {
|
||||
t.Fatal("normalized type was not resolved")
|
||||
}
|
||||
if descriptor.Type != "custom" || descriptor.InputKind != "text" || descriptor.GenerationMode != "image" {
|
||||
t.Fatalf("descriptor was not normalized: %#v", descriptor)
|
||||
}
|
||||
if descriptor.Connection.AcceptedInputKinds[0] != "image" {
|
||||
t.Fatalf("registry retained caller mutation: %#v", descriptor.Connection.AcceptedInputKinds)
|
||||
}
|
||||
if descriptor.PatchFields["title"].Path != "title" || descriptor.PatchFields["title"].Kind != "string" || descriptor.PatchFields["title"].Label != "节点名称" || descriptor.PatchFields["title"].Description != "节点标题" {
|
||||
t.Fatalf("registry retained caller mutation: %#v", descriptor.PatchFields)
|
||||
}
|
||||
|
||||
descriptor.Connection.AcceptedInputKinds[0] = "video"
|
||||
descriptor.PatchFields["title"] = PatchField{Path: "changed", Kind: "number"}
|
||||
fresh, _ := registry.Resolve("custom")
|
||||
if fresh.Connection.AcceptedInputKinds[0] != "image" || fresh.PatchFields["title"].Path != "title" {
|
||||
t.Fatal("Resolve returned mutable registry state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryAcceptsExtensibleIdentifiers(t *testing.T) {
|
||||
descriptor := testDescriptor()
|
||||
descriptor.InputKind = "table_data"
|
||||
descriptor.GenerationMode = "table-render"
|
||||
descriptor.Connection.AcceptedInputKinds = []string{"table_data", "text"}
|
||||
descriptor.Connection.RejectedInputKinds = nil
|
||||
registry, err := NewRegistry([]Descriptor{descriptor})
|
||||
if err != nil {
|
||||
t.Fatalf("extensible kind or mode rejected: %v", err)
|
||||
}
|
||||
resolved, ok := registry.ResolveGenerationMode(" TABLE-RENDER ")
|
||||
if !ok || resolved.Type != "custom" || resolved.InputKind != "table_data" {
|
||||
t.Fatalf("generation mode did not resolve descriptor: %#v, %v", resolved, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryHashIsStableAndTracksDeclarativeContract(t *testing.T) {
|
||||
first := testDescriptor()
|
||||
second := testDescriptor()
|
||||
second.Connection.AcceptedInputKinds = []string{"text", "image"}
|
||||
second.Connection.RejectedInputKinds = []string{"audio"}
|
||||
first.CreateMetadata = func(string) map[string]any { return map[string]any{"one": true} }
|
||||
second.CreateMetadata = func(string) map[string]any { return map[string]any{"two": true} }
|
||||
|
||||
left, err := NewRegistry([]Descriptor{first})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
right, err := NewRegistry([]Descriptor{second})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if left.Hash() != right.Hash() {
|
||||
t.Fatal("factory function changed the capability contract hash")
|
||||
}
|
||||
|
||||
reordered, err := NewRegistry([]Descriptor{second, Descriptor{Type: "z", Version: "1", Label: "Z", DefaultWidth: 1, DefaultHeight: 1}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherOrder, err := NewRegistry([]Descriptor{{Type: "z", Version: "1", Label: "Z", DefaultWidth: 1, DefaultHeight: 1}, second})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reordered.Hash() != otherOrder.Hash() {
|
||||
t.Fatal("descriptor registration order changed the contract hash")
|
||||
}
|
||||
|
||||
projected := second
|
||||
projected.ProjectionKind = "storyboard"
|
||||
projected.ProjectionField = "storyboard"
|
||||
withProjection, err := NewRegistry([]Descriptor{projected})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if withProjection.Hash() == right.Hash() {
|
||||
t.Fatal("projection declaration did not change capability contract hash")
|
||||
}
|
||||
|
||||
described := second
|
||||
field := described.PatchFields["title"]
|
||||
field.Description = "不同的字段语义"
|
||||
described.PatchFields["title"] = field
|
||||
withDescription, err := NewRegistry([]Descriptor{described})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if withDescription.Hash() == right.Hash() {
|
||||
t.Fatal("patch field description did not change capability contract hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryRejectsInvalidContracts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
edit func(*Descriptor)
|
||||
}{
|
||||
{"invalid input kind", func(d *Descriptor) { d.InputKind = "9table" }},
|
||||
{"invalid generation mode", func(d *Descriptor) { d.GenerationMode = "table mode" }},
|
||||
{"duplicate generation mode", func(d *Descriptor) {}},
|
||||
{"negative max input", func(d *Descriptor) { d.Connection.MaxInputCount = -1 }},
|
||||
{"accepted and rejected conflict", func(d *Descriptor) { d.Connection.RejectedInputKinds = []string{"image"} }},
|
||||
{"source without kind", func(d *Descriptor) { d.InputKind = "" }},
|
||||
{"projection missing field", func(d *Descriptor) { d.ProjectionKind = "storyboard" }},
|
||||
{"projection missing kind", func(d *Descriptor) { d.ProjectionField = "storyboard" }},
|
||||
{"invalid projection kind", func(d *Descriptor) { d.ProjectionKind, d.ProjectionField = "bad kind", "storyboard" }},
|
||||
{"unsafe projection field", func(d *Descriptor) { d.ProjectionKind, d.ProjectionField = "storyboard", "metadata.__proto__" }},
|
||||
{"empty patch path", func(d *Descriptor) { d.PatchFields["title"] = PatchField{Kind: "string"} }},
|
||||
{"unsafe patch path", func(d *Descriptor) {
|
||||
d.PatchFields["title"] = PatchField{Path: "metadata.__proto__.value", Kind: "string"}
|
||||
}},
|
||||
{"unknown patch kind", func(d *Descriptor) { d.PatchFields["title"] = PatchField{Path: "title", Kind: "object"} }},
|
||||
{"negative max runes", func(d *Descriptor) { d.PatchFields["title"] = PatchField{Path: "title", Kind: "string", MaxRunes: -1} }},
|
||||
{"missing patch label", func(d *Descriptor) {
|
||||
field := d.PatchFields["title"]
|
||||
field.Label = ""
|
||||
d.PatchFields["title"] = field
|
||||
}},
|
||||
{"missing patch order", func(d *Descriptor) {
|
||||
field := d.PatchFields["title"]
|
||||
field.Order = 0
|
||||
d.PatchFields["title"] = field
|
||||
}},
|
||||
{"patch fields without update", func(d *Descriptor) { d.CanUpdate = false }},
|
||||
{"update without patch fields", func(d *Descriptor) { d.PatchFields = nil }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
descriptor := testDescriptor()
|
||||
test.edit(&descriptor)
|
||||
descriptors := []Descriptor{descriptor}
|
||||
if test.name == "duplicate generation mode" {
|
||||
other := testDescriptor()
|
||||
other.Type = "other"
|
||||
descriptors = append(descriptors, other)
|
||||
}
|
||||
if _, err := NewRegistry(descriptors); err == nil {
|
||||
t.Fatal("invalid descriptor accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := NewRegistry([]Descriptor{testDescriptor(), testDescriptor()}); err == nil {
|
||||
t.Fatal("duplicate descriptor accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescriptorConnectionAndPatchContract(t *testing.T) {
|
||||
registry, err := NewRegistry([]Descriptor{testDescriptor()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
descriptor, _ := registry.Resolve("custom")
|
||||
if !descriptor.AllowsInput("IMAGE") || !descriptor.AllowsInput("text") || descriptor.AllowsInput("audio") || descriptor.AllowsInput("video") {
|
||||
t.Fatalf("input admission contract is wrong: %#v", descriptor.Connection)
|
||||
}
|
||||
if descriptor.Connection.MaxInputCount != 2 {
|
||||
t.Fatalf("max input count = %d", descriptor.Connection.MaxInputCount)
|
||||
}
|
||||
for name, patch := range map[string]map[string]any{
|
||||
"unknown": {"missing": "value"},
|
||||
"string type": {"title": true},
|
||||
"number type": {"weight": "1"},
|
||||
"boolean type": {"enabled": 1.0},
|
||||
"rune limit": {"title": strings.Repeat("界", 21)},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := descriptor.ValidatePatch(patch); err == nil {
|
||||
t.Fatal("invalid patch accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
node := map[string]any{"metadata": map[string]any{"untouched": true}}
|
||||
patch := map[string]any{"title": "标题", "enabled": true, "weight": 1.5}
|
||||
if err := descriptor.ApplyPatch(node, patch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metadata := node["metadata"].(map[string]any)
|
||||
if node["title"] != "标题" || metadata["enabled"] != true || metadata["weight"] != 1.5 || metadata["untouched"] != true {
|
||||
t.Fatalf("patch was not applied by declared paths: %#v", node)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryGenerationModesAreStableAndResolveUniquely(t *testing.T) {
|
||||
image := testDescriptor()
|
||||
image.Type, image.GenerationMode = "image-node", "image"
|
||||
video := testDescriptor()
|
||||
video.Type, video.GenerationMode = "video-node", "video"
|
||||
registry, err := NewRegistry([]Descriptor{video, image})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Join(registry.GenerationModeNames(), ","); got != "image,video" {
|
||||
t.Fatalf("generation modes = %s", got)
|
||||
}
|
||||
if !registry.SupportsGenerationMode(" VIDEO ") || registry.SupportsGenerationMode("audio") {
|
||||
t.Fatal("generation mode support is wrong")
|
||||
}
|
||||
resolved, ok := registry.ResolveGenerationMode("image")
|
||||
if !ok || resolved.Type != "image-node" {
|
||||
t.Fatalf("resolved generation descriptor = %#v, %v", resolved, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinMediaCapabilitiesUpdateDraftWithoutOverwritingGeneratedAssets(t *testing.T) {
|
||||
registry := BuiltinRegistry()
|
||||
for _, nodeType := range []string{"image", "video", "audio"} {
|
||||
t.Run(nodeType, func(t *testing.T) {
|
||||
descriptor, ok := registry.Resolve(nodeType)
|
||||
if !ok || !descriptor.CanUpdate {
|
||||
t.Fatalf("media capability is not updateable: %#v", descriptor)
|
||||
}
|
||||
if descriptor.PatchFields["content"].Path != "metadata.composerContent" || descriptor.PatchFields["title"].Path != "title" {
|
||||
t.Fatalf("unsafe media update paths: %#v", descriptor.PatchFields)
|
||||
}
|
||||
|
||||
node := map[string]any{
|
||||
"title": "旧标题",
|
||||
"metadata": map[string]any{
|
||||
"content": "resource:generated-result",
|
||||
"prompt": "已提交提示词",
|
||||
"composerContent": "旧草稿",
|
||||
"status": "success",
|
||||
},
|
||||
}
|
||||
if err := descriptor.ApplyPatch(node, map[string]any{"title": "新标题", "content": "下一版提示词"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metadata := node["metadata"].(map[string]any)
|
||||
if node["title"] != "新标题" || metadata["composerContent"] != "下一版提示词" {
|
||||
t.Fatalf("media draft was not updated: %#v", node)
|
||||
}
|
||||
if metadata["content"] != "resource:generated-result" || metadata["prompt"] != "已提交提示词" || metadata["status"] != "success" {
|
||||
t.Fatalf("media result or submission snapshot was overwritten: %#v", metadata)
|
||||
}
|
||||
|
||||
created := descriptor.Metadata("初始草稿")
|
||||
if created["content"] != "" || created["prompt"] != "初始草稿" || created["composerContent"] != "初始草稿" || created["status"] != "idle" {
|
||||
t.Fatalf("media draft metadata is incomplete: %#v", created)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryResolveUnknownTypeFailsClosed(t *testing.T) {
|
||||
registry, err := NewRegistry([]Descriptor{testDescriptor()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := registry.Resolve("table"); ok {
|
||||
t.Fatal("unknown capability resolved")
|
||||
}
|
||||
if _, ok := registry.ResolveGenerationMode("table"); ok {
|
||||
t.Fatal("unknown generation mode resolved")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user