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

321 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package capability
import (
"fmt"
"math"
"sort"
"strings"
"unicode"
"yingce/backend/internal/canvas/contract"
)
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
// Limit > 0 时表示数值字段的绝对值上限(坐标用),避免模型写入离谱的几何值。
Limit float64
Description string
MaxRunes int
}
// NodeVariant 表示同一底层节点类型按 metadata.workflowKind 标记出的独立能力,
// 例如 text + workflowKind=character 的角色卡。变体可被发现、读取、引用和连线,
// 但不是可直接创建的节点类型:add_node 的 nodeType 枚举和 Resolve 都不会返回变体。
type NodeVariant struct {
BaseType string
WorkflowKind string
}
// 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
Purpose string
GoodFor []string
NotIdealFor []string
Tradeoffs []string
Actions []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
Variant *NodeVariant
}
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":
number, ok := value.(float64)
if !ok {
return fmt.Errorf("%s 字段 %s 必须是数字", d.Label, key)
}
if math.IsNaN(number) || math.IsInf(number, 0) {
return fmt.Errorf("%s 字段 %s 不是有效数字", d.Label, key)
}
if field.Limit > 0 && math.Abs(number) > field.Limit {
return fmt.Errorf("%s 字段 %s 超出允许范围(±%g)", d.Label, key, field.Limit)
}
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]
if field.Path == "metadata.generationSpec.prompt" {
metadata, ok := node["metadata"].(map[string]any)
if !ok {
metadata = map[string]any{}
node["metadata"] = metadata
}
if _, ok := metadata["generationSpec"].(map[string]any); !ok {
metadata["generationSpec"] = map[string]any{
"version": float64(contract.GenerationVersion),
"mode": d.GenerationMode,
"prompt": "",
"options": map[string]any{},
"referenceBindings": []any{},
"textInputMode": "append-sources",
}
}
}
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 {
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 "音频"
case "character":
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
}