feat(canvas): 云端 Agent - 完成策略分层、节点能力注册与可解释审批改造

This commit is contained in:
ddCat
2026-09-14 17:58:21 +08:00
parent 4b0e9d8965
commit 01486872c4
65 changed files with 5347 additions and 918 deletions

View 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"}
}

View 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
}

View 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
}

View 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")
}
}