321 lines
8.7 KiB
Go
321 lines
8.7 KiB
Go
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
|
||
}
|