云端 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 字节)。
291 lines
7.7 KiB
Go
291 lines
7.7 KiB
Go
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
|
||
}
|