Files
ttstd-infinite-canvas/backend/internal/canvas/capability/descriptor.go
AetherNo2332 56598108d4 feat(canvas): 云端 Agent 画布布局 - 新增 canvas_arrange_nodes 与新增节点自动落位
云端 Agent 此前只能新建节点与连线,既不会排版也不能移动已有节点:模型只能用几十个
update_node 手算坐标,或者让用户自己拖。

- 新增 backend/internal/canvas/layout:把前端既有的画布布局算法(web/src/lib/canvas/
  canvas-layout.ts,本 PR 不改该文件)按同一套常量移植到服务端,提供 Kahn 拓扑分层 flow、
  媒体泳道 byType、row/column/grid、对齐与等距、分组分带、AABB 重叠检测与空位搜索。
- 新增写工具 canvas_arrange_nodes:snapshotHash 必需,mode∈{auto,flow,byType,row,column,
  grid},可传 nodeIds、groups(只做横向位置分带,不建容器、不改层级)、align、gap、dryRun。
  只改坐标,不改内容、不建连线、不增删节点;一次最多 50 个节点 / 12 组。锁定节点、背板等
  容器、隐藏批次子节点与已归属背板的子节点保持不动并在回执里说明;整批复用现有 mutation
  记录与事件管线,审批与撤销走同一条路径。
- add_node 省略坐标时不再落到 (0,0):优先贴合本批连线的对端(输入在左、输出在右),否则
  追加到同泳道同类节点之后,冲突则扫描空位;只给一个轴时保留该轴。agentCanvasOp.X/Y 改为
  指针以区分"没给坐标"与"坐标 0"。
- update_node 的 patch 新增 x/y(PatchField.Limit 校验 ±1e6),供模型微调单个节点。
- 系统行为策略新增「布局与整理」一节并升版到 8;docs 补工具说明与工具全集;前端同步审批
  预览联合类型、面板文案与徽标。
- 工具 schema 预算按实测从 24000 上调到 25000(21 个工具 24788 字节),并改正注释里
  "上游约 23.4 KB"的说法(上游 20 个工具实测 22777 字节)。
2026-09-22 01:32:20 +08:00

291 lines
7.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"
)
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
}
// 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
}
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]
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 "音频"
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
}