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 字节)。
This commit is contained in:
AetherNo2332
2026-09-22 01:32:20 +08:00
parent 6f45240599
commit 56598108d4
16 changed files with 1894 additions and 33 deletions

View File

@@ -10,6 +10,7 @@ import (
"unicode/utf8"
"infinite-canvas/backend/internal/canvas/capability"
"infinite-canvas/backend/internal/canvas/layout"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
)
@@ -113,7 +114,33 @@ func applyCloudAgentCanvasPlan(doc map[string]any, ops []agentCanvasOp) ([]cloud
if !ok {
return nil, BadAuthRequest("不支持的节点类型")
}
node := creationAddedNode(CreationCanvasOp{Type: op.Type, ID: op.ID, NodeType: op.NodeType, Title: title, X: &op.X, Y: &op.Y, Metadata: capability.Metadata(content)})
x, y := op.X, op.Y
if x == nil || y == nil {
// 没有(完整)坐标时不落到原点:按泳道与依赖关系算一个空位,
// 保证同一批新增的多个节点也不会互相重叠。模型只给了一个轴时保留它。
pending := cloudAgentLayoutNodes(map[string]any{"nodes": nodes})
var hint *layout.Position
if x != nil || y != nil {
hint = &layout.Position{}
if x != nil {
hint.X = *x
}
if y != nil {
hint.Y = *y
}
}
if slot, ok := cloudAgentArrangeAddNodePosition(doc, pending, op, ops, hint); ok {
if x == nil {
value := slot.X
x = &value
}
if y == nil {
value := slot.Y
y = &value
}
}
}
node := creationAddedNode(CreationCanvasOp{Type: op.Type, ID: op.ID, NodeType: op.NodeType, Title: title, X: x, Y: y, Metadata: capability.Metadata(content)})
nodes = append(nodes, node)
nodeTitle := cloudAgentApprovalNodeTitle(node, capability.Label)
items = append(items, cloudAgentApprovalPreviewItem{

View File

@@ -0,0 +1,630 @@
package app
import (
"fmt"
"math"
"strings"
"infinite-canvas/backend/internal/canvas/layout"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
)
// 云端 Agent 的节点整理:模型只决定"整理哪些节点 / 怎么归类",几何一律由服务端算。
// 一次整理的上限刻意保守:坐标批量改写比内容改写更容易让用户迷失,且审批卡要能一眼看完。
const (
cloudAgentArrangeMaxNodes = 50
cloudAgentArrangeMaxGroups = 12
cloudAgentArrangeMaxGap = 400
cloudAgentArrangeCoordCap = 1e6
)
type cloudAgentArrangeGroup struct {
Label string `json:"label,omitempty"`
NodeIDs []string `json:"nodeIds"`
Mode string `json:"mode,omitempty"`
}
type cloudAgentArrangeArgs struct {
SnapshotHash string `json:"snapshotHash"`
NodeIDs []string `json:"nodeIds,omitempty"`
Mode string `json:"mode,omitempty"`
Groups []cloudAgentArrangeGroup `json:"groups,omitempty"`
Align string `json:"align,omitempty"`
Gap float64 `json:"gap,omitempty"`
DryRun bool `json:"dryRun,omitempty"`
}
type cloudAgentArrangeMove struct {
ID string
Title string
From layout.Position
To layout.Position
}
type cloudAgentArrangePlan struct {
Args cloudAgentArrangeArgs
Canvas *model.CanvasProject
Document map[string]any
Positions map[string]layout.Position
Moves []cloudAgentArrangeMove
Skipped []string
BandLabels []string
Preview cloudAgentApprovalPreview
BeforeHash string
Mode layout.Mode
}
// cloudAgentLayoutLane 把节点类型映射到整理泳道(与前端 canvasLayoutLane 的语义一致:
// 视频 / 音频 / 图片 / 文本,其余类型归文本泳道)。第二个返回值表示该类型是否参与整理。
func cloudAgentLayoutLane(nodeType string) (string, bool) {
switch nodeType {
case "video":
return "video", true
case "audio":
return "audio", true
case "image":
return "image", true
case "text", "markdown", "script", "batch-table":
return "text", true
default:
// 背板 / 文件夹是容器,插件节点也不参与几何整理。
return "", false
}
}
// cloudAgentLayoutNodes 把画布文档投影成排布输入。parentID 用于判断顶层节点。
func cloudAgentLayoutNodes(doc map[string]any) []layout.Node {
raw := creationMaps(doc["nodes"])
nodes := make([]layout.Node, 0, len(raw))
for _, node := range raw {
id := stringValue(node["id"])
if id == "" {
continue
}
lane, supported := cloudAgentLayoutLane(stringValue(node["type"]))
item := layout.Node{ID: id, Type: stringValue(node["type"]), Lane: lane, NoMove: !supported}
if position, ok := node["position"].(map[string]any); ok {
if value, ok := cloudAgentSafeNumber(position["x"]); ok {
item.X, _ = value.(float64)
}
if value, ok := cloudAgentSafeNumber(position["y"]); ok {
item.Y, _ = value.(float64)
}
}
if value, ok := cloudAgentSafeNumber(node["width"]); ok {
item.Width, _ = value.(float64)
}
if value, ok := cloudAgentSafeNumber(node["height"]); ok {
item.Height, _ = value.(float64)
}
if meta, ok := node["metadata"].(map[string]any); ok {
item.Locked = meta["locked"] == true
}
nodes = append(nodes, item)
}
return nodes
}
// cloudAgentLayoutEdges 只取两端都在画布上的引用连线,用于依赖方向分层。
func cloudAgentLayoutEdges(doc map[string]any) []layout.Edge {
edges := make([]layout.Edge, 0)
for _, edge := range creationMaps(doc["connections"]) {
from, to := stringValue(edge["fromNodeId"]), stringValue(edge["toNodeId"])
if from == "" || to == "" {
continue
}
edges = append(edges, layout.Edge{From: from, To: to})
}
return edges
}
// cloudAgentLayoutMovable 判断节点能否被 Agent 移动:锁定、容器节点、隐藏批次子节点、
// 以及已经归属某个背板的子节点都不动——用户手工摆过的位置和容器层级不是 Agent 的编排对象。
func cloudAgentLayoutMovable(node map[string]any) bool {
meta, _ := node["metadata"].(map[string]any)
if meta["locked"] == true {
return false
}
if _, supported := cloudAgentLayoutLane(stringValue(node["type"])); !supported {
return false
}
if strings.TrimSpace(stringValue(node["parentId"])) != "" {
return false
}
if rootID := strings.TrimSpace(stringValue(meta["batchRootId"])); rootID != "" {
return false
}
return true
}
func cloudAgentLayoutNodesByID(doc map[string]any) map[string]map[string]any {
index := map[string]map[string]any{}
for _, node := range creationMaps(doc["nodes"]) {
if id := stringValue(node["id"]); id != "" {
index[id] = node
}
}
return index
}
func cloudAgentArrangeModeLabel(mode layout.Mode) string {
switch mode {
case layout.ModeFlow:
return "按依赖分层"
case layout.ModeByLane:
return "按媒体类型分区"
case layout.ModeRow:
return "排成一行"
case layout.ModeColumn:
return "排成一列"
case layout.ModeGrid:
return "排成网格"
default:
return "自动整理"
}
}
func cloudAgentArrangeAlignLabel(align layout.AlignMode) string {
switch align {
case layout.AlignLeft:
return "左对齐"
case layout.AlignCenterX:
return "水平居中"
case layout.AlignRight:
return "右对齐"
case layout.AlignTop:
return "顶对齐"
case layout.AlignCenterY:
return "垂直居中"
case layout.AlignBottom:
return "底对齐"
case layout.AlignDistributeX:
return "水平等距"
case layout.AlignDistributeY:
return "垂直等距"
default:
return ""
}
}
func cloudAgentArrangeMode(value string) (layout.Mode, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "auto":
return layout.ModeAuto, nil
case "flow":
return layout.ModeFlow, nil
case "bytype", "by_type", "lane", "lanes":
return layout.ModeByLane, nil
case "row":
return layout.ModeRow, nil
case "column":
return layout.ModeColumn, nil
case "grid":
return layout.ModeGrid, nil
default:
return "", BadAuthRequest("整理模式无效:只能是 auto、flow、byType、row、column 或 grid")
}
}
func cloudAgentArrangeAlign(value string) (layout.AlignMode, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "":
return "", nil
case "left", "centerx", "right", "top", "centery", "bottom", "distributex", "distributey":
return layout.AlignMode(strings.ToLower(strings.TrimSpace(value))), nil
default:
return "", BadAuthRequest("对齐模式无效:只能是 left、centerX、right、top、centerY、bottom、distributeX 或 distributeY")
}
}
// cloudAgentArrangeNodes 按 args 选出的节点集合。显式 nodeIds 会逐个校验:
// 不存在、被锁定或属于容器/隐藏批次的节点会被跳过并在回执里说明,而不是让整轮失败。
func cloudAgentArrangeScope(doc map[string]any, args cloudAgentArrangeArgs) ([]layout.Node, []string, error) {
index := cloudAgentLayoutNodesByID(doc)
all := cloudAgentLayoutNodes(doc)
byID := map[string]layout.Node{}
for _, node := range all {
byID[node.ID] = node
}
requested := args.NodeIDs
if len(args.Groups) > 0 {
requested = nil
seen := map[string]bool{}
for _, group := range args.Groups {
for _, id := range group.NodeIDs {
id = strings.TrimSpace(id)
if id == "" || seen[id] {
continue
}
seen[id] = true
requested = append(requested, id)
}
}
}
selected := make([]layout.Node, 0, len(requested))
skipped := make([]string, 0)
if len(requested) == 0 {
for _, node := range all {
if node.NoMove {
continue
}
if !cloudAgentLayoutMovable(index[node.ID]) {
continue
}
selected = append(selected, node)
}
return selected, skipped, nil
}
for _, id := range requested {
node, ok := byID[id]
if !ok {
return nil, nil, BadAuthRequest(fmt.Sprintf("要整理的节点 %s 不存在,请重新读取画布", id))
}
if node.NoMove || !cloudAgentLayoutMovable(index[id]) {
skipped = append(skipped, id)
continue
}
selected = append(selected, node)
}
return selected, skipped, nil
}
func cloudAgentArrangePositions(doc map[string]any, args cloudAgentArrangeArgs, selected []layout.Node) (map[string]layout.Position, []string, layout.Mode, error) {
index := map[string]layout.Node{}
for _, node := range selected {
index[node.ID] = node
}
mode, err := cloudAgentArrangeMode(args.Mode)
if err != nil {
return nil, nil, "", err
}
align, err := cloudAgentArrangeAlign(args.Align)
if err != nil {
return nil, nil, "", err
}
edges := cloudAgentLayoutEdges(doc)
positions := map[string]layout.Position{}
labels := make([]string, 0, len(args.Groups))
if len(args.Groups) > 0 {
bands := make([]layout.Band, 0, len(args.Groups))
for _, group := range args.Groups {
members := make([]layout.Node, 0, len(group.NodeIDs))
for _, id := range group.NodeIDs {
if node, ok := index[strings.TrimSpace(id)]; ok {
members = append(members, node)
}
}
if len(members) == 0 {
continue
}
groupMode, err := cloudAgentArrangeMode(group.Mode)
if err != nil {
return nil, nil, "", err
}
if groupMode == layout.ModeAuto {
groupMode = layout.ModeByLane
}
label := strings.TrimSpace(group.Label)
if label == "" {
label = fmt.Sprintf("第 %d 组", len(bands)+1)
}
labels = append(labels, label)
if align != "" {
// 分组 + 对齐:先在带内对齐,再把整带交给 Bands 排位置。
members = applyLayout(members, layout.Align(members, align))
}
bands = append(bands, layout.Band{Label: label, Nodes: members, Mode: groupMode})
}
if len(bands) == 0 {
return nil, nil, "", BadAuthRequest("分组里没有可整理的节点")
}
positions = layout.Bands(bands, args.Gap)
} else {
switch mode {
case layout.ModeAuto:
positions = layout.Auto(selected, edges)
case layout.ModeFlow:
positions = layout.Flow(selected, edges)
case layout.ModeByLane:
positions = layout.ByLane(selected)
default:
positions = layout.Linear(selected, mode)
}
if align != "" {
positions = mergeLayoutPositions(positions, layout.Align(applyLayout(selected, positions), align))
}
}
// 坐标收敛:四舍五入到两位小数并夹到安全范围,避免浮点噪声进入画布文档。
for id, position := range positions {
positions[id] = layout.Position{X: clampCoord(position.X), Y: clampCoord(position.Y)}
}
return positions, labels, mode, nil
}
func clampCoord(value float64) float64 {
if math.IsNaN(value) || math.IsInf(value, 0) {
return 0
}
value = math.Round(value*100) / 100
if value > cloudAgentArrangeCoordCap {
return cloudAgentArrangeCoordCap
}
if value < -cloudAgentArrangeCoordCap {
return -cloudAgentArrangeCoordCap
}
return value
}
func applyLayout(nodes []layout.Node, positions map[string]layout.Position) []layout.Node {
applied := make([]layout.Node, 0, len(nodes))
for _, node := range nodes {
if position, ok := positions[node.ID]; ok {
node.X, node.Y = position.X, position.Y
}
applied = append(applied, node)
}
return applied
}
func mergeLayoutPositions(base, override map[string]layout.Position) map[string]layout.Position {
merged := make(map[string]layout.Position, len(base))
for id, position := range base {
merged[id] = position
}
for id, position := range override {
merged[id] = position
}
return merged
}
// prepareCloudAgentArrangeNodes 复刻 canvas_apply_ops 的准备流程:校验快照、算出新坐标、生成审批预览。
// 它不写库;真正落库在 applyCloudAgentArrangeNodes(或在审批通过后的同一条执行路径里)。
func prepareCloudAgentArrangeNodes(repo *repository.Repository, userID, canvasID string, call cloudAgentCall) (*cloudAgentArrangePlan, error) {
var args cloudAgentArrangeArgs
if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil {
return nil, cloudAgentJSONArgumentError(err)
}
if strings.TrimSpace(args.SnapshotHash) == "" {
return nil, BadAuthRequest("整理节点需要先读取画布并传入 snapshotHash")
}
if len(args.Groups) > cloudAgentArrangeMaxGroups {
return nil, BadAuthRequest(fmt.Sprintf("一次最多分成 %d 组", cloudAgentArrangeMaxGroups))
}
if args.Gap < 0 || args.Gap > cloudAgentArrangeMaxGap {
return nil, BadAuthRequest(fmt.Sprintf("组间距必须在 0 到 %d 之间", cloudAgentArrangeMaxGap))
}
canvas, err := repo.CanvasProjectForUser(userID, canvasID)
if err != nil {
return nil, err
}
doc, err := creationDocument(canvas.PayloadJSON)
if err != nil {
return nil, err
}
beforeHash := cloudAgentCanvasHash(doc)
if beforeHash != strings.TrimSpace(args.SnapshotHash) {
// 与 canvas_apply_ops 同一写法:过期快照是"可纠正的参数错误",运行期把它当工具结果
// 交回模型重新读取,而不是走准入失败分支终止整轮。
return nil, &cloudAgentFieldArgumentError{
error: &cloudAgentArgumentError{creationConflict("画布已变化,本次未整理;请重新读取并重新申请审批")},
Field: "snapshotHash", Issue: "stale_snapshot",
}
}
selected, skipped, err := cloudAgentArrangeScope(doc, args)
if err != nil {
return nil, err
}
if len(selected) < 2 {
return nil, BadAuthRequest("可整理的节点少于两个;单个节点请直接用 update_node 指定坐标")
}
if len(selected) > cloudAgentArrangeMaxNodes {
return nil, BadAuthRequest(fmt.Sprintf("一次最多整理 %d 个节点,请分批或用 nodeIds 指定范围", cloudAgentArrangeMaxNodes))
}
positions, labels, mode, err := cloudAgentArrangePositions(doc, args, selected)
if err != nil {
return nil, err
}
moves := make([]cloudAgentArrangeMove, 0, len(positions))
for _, node := range selected {
position, ok := positions[node.ID]
if !ok {
continue
}
if math.Abs(position.X-node.X) < 0.01 && math.Abs(position.Y-node.Y) < 0.01 {
continue
}
moves = append(moves, cloudAgentArrangeMove{ID: node.ID, From: layout.Position{X: node.X, Y: node.Y}, To: position})
}
plan := &cloudAgentArrangePlan{
Args: args, Canvas: canvas, Document: doc, Positions: positions,
Skipped: skipped, BandLabels: labels, BeforeHash: beforeHash, Mode: mode,
}
if len(moves) == 0 {
return plan, nil
}
titles := cloudAgentLayoutTitles(doc)
for _, move := range moves {
move.Title = titles[move.ID]
plan.Moves = append(plan.Moves, move)
}
plan.Preview = cloudAgentArrangePreview(doc, plan)
return plan, nil
}
func cloudAgentLayoutTitles(doc map[string]any) map[string]string {
titles := map[string]string{}
for _, node := range creationMaps(doc["nodes"]) {
id := stringValue(node["id"])
if id == "" {
continue
}
title := strings.TrimSpace(stringValue(node["title"]))
if title == "" {
if capability, ok := cloudAgentNodeCapabilityForType(stringValue(node["type"])); ok {
title = capability.Label
}
}
titles[id] = truncateRunes(title, 60)
}
return titles
}
func cloudAgentArrangePreview(doc map[string]any, plan *cloudAgentArrangePlan) cloudAgentApprovalPreview {
modeLabel := cloudAgentArrangeModeLabel(plan.Mode)
headline := fmt.Sprintf("整理 %d 个节点的位置(%s)", len(plan.Moves), modeLabel)
if len(plan.BandLabels) > 0 {
headline = fmt.Sprintf("把 %d 个节点归入 %d 组并整理位置", len(plan.Moves), len(plan.BandLabels))
}
if alignLabel := cloudAgentArrangeAlignLabel(layout.AlignMode(strings.ToLower(strings.TrimSpace(plan.Args.Align)))); alignLabel != "" {
headline += "," + alignLabel
}
details := make([]string, 0, 6)
if len(plan.BandLabels) > 0 {
details = append(details, "分组:"+strings.Join(plan.BandLabels, " / "))
}
details = append(details, fmt.Sprintf("移动 %d 个节点(上限 %d)", len(plan.Moves), cloudAgentArrangeMaxNodes))
for index, move := range plan.Moves {
if index == 6 {
details = append(details, fmt.Sprintf("等共 %d 个节点", len(plan.Moves)))
break
}
details = append(details, fmt.Sprintf("%s → (%g, %g)", move.Title, move.To.X, move.To.Y))
}
if len(plan.Skipped) > 0 {
details = append(details, fmt.Sprintf("%d 个锁定/容器/隐藏节点保持不动", len(plan.Skipped)))
}
return cloudAgentApprovalPreview{
Kind: "arrange_nodes",
Title: "整理节点位置",
Description: headline + ";只调整坐标,不改内容、不建连线、不改层级。",
Items: []cloudAgentApprovalPreviewItem{{
Operation: "arrange_nodes",
Summary: headline,
Details: details,
}},
}
}
// applyCloudAgentArrangeNodes 落库并记录一次可撤销的 mutation。
func applyCloudAgentArrangeNodes(repo *repository.Repository, userID, canvasID string, call cloudAgentCall, policy RuntimePolicySetting, recorder ...cloudAgentMutationRecorder) (any, error) {
plan, err := prepareCloudAgentArrangeNodes(repo, userID, canvasID, call)
if err != nil {
return nil, err
}
if len(plan.Moves) == 0 {
return map[string]any{
"canvasId": canvasID, "snapshotHash": plan.BeforeHash, "moved": 0,
"summary": "节点位置已经是整理后的结果,本次没有改动",
}, nil
}
if plan.Args.DryRun {
ids := make([]string, 0, len(plan.Moves))
for _, move := range plan.Moves {
ids = append(ids, move.ID)
}
return map[string]any{
"canvasId": canvasID, "snapshotHash": plan.BeforeHash, "moved": 0, "wouldMove": len(ids),
"nodeIds": ids, "summary": fmt.Sprintf("预演:将移动 %d 个节点,未写入画布", len(ids)),
}, nil
}
before := plan.Canvas.PayloadJSON
if err := cloudAgentApplyArrangePositions(plan); err != nil {
return nil, err
}
if err := saveCloudAgentDocument(repo, plan.Canvas, plan.Document, policy); err != nil {
return nil, err
}
if len(recorder) > 0 && recorder[0] != nil {
if err := recorder[0](repo, cloudAgentMutationInput{
UserID: userID, CanvasID: canvasID, StepID: call.ID, Operation: "canvas_arrange_nodes",
BeforeSnapshotHash: plan.BeforeHash, AfterSnapshotHash: cloudAgentCanvasHash(plan.Document),
BeforeJSON: before, Preview: &plan.Preview,
}); err != nil {
return nil, err
}
}
ids := make([]string, 0, len(plan.Moves))
for _, move := range plan.Moves {
ids = append(ids, move.ID)
}
return map[string]any{
"canvasId": canvasID, "snapshotHash": cloudAgentCanvasHash(plan.Document),
"beforeSnapshotHash": plan.BeforeHash, "moved": len(plan.Moves), "nodeIds": ids,
"groups": plan.BandLabels, "skipped": plan.Skipped,
"summary": fmt.Sprintf("已整理 %d 个节点的位置(%s)", len(plan.Moves), cloudAgentArrangeModeLabel(plan.Mode)),
}, nil
}
func cloudAgentApplyArrangePositions(plan *cloudAgentArrangePlan) error {
nodes := creationMaps(plan.Document["nodes"])
for index, node := range nodes {
position, ok := plan.Positions[stringValue(node["id"])]
if !ok {
continue
}
current, _ := node["position"].(map[string]any)
if current == nil {
current = map[string]any{}
}
current["x"] = position.X
current["y"] = position.Y
node["position"] = current
nodes[index] = node
}
plan.Document["nodes"] = nodes
return nil
}
// cloudAgentArrangeAddNodePosition 给"没给坐标"的新节点找一个有序的落位:
// 优先贴着本批建立连线的对端(生成输入在左、输出在右),否则按泳道追加到同类节点之后。
func cloudAgentArrangeAddNodePosition(doc map[string]any, pending []layout.Node, op agentCanvasOp, ops []agentCanvasOp, hint *layout.Position) (layout.Position, bool) {
lane, supported := cloudAgentLayoutLane(op.NodeType)
if !supported {
return layout.Position{}, false
}
width, height := cloudAgentAddedNodeSize(op.NodeType)
occupied := append([]layout.Node{}, cloudAgentLayoutNodes(doc)...)
occupied = append(occupied, pending...)
preferred := hint
for _, candidate := range ops {
if candidate.Type != "connect_nodes" {
continue
}
if preferred != nil {
break
}
switch {
case candidate.ToNodeID == op.ID:
if source, ok := findLayoutNode(occupied, candidate.FromNodeID); ok {
preferred = &layout.Position{X: source.X + source.Width + layout.PlacementGap, Y: source.Y}
}
case candidate.FromNodeID == op.ID:
if target, ok := findLayoutNode(occupied, candidate.ToNodeID); ok {
preferred = &layout.Position{X: target.X - width - layout.PlacementGap, Y: target.Y}
}
}
}
return layout.FreeSlot(width, height, lane, occupied, preferred, layout.PlacementGap), true
}
func findLayoutNode(nodes []layout.Node, id string) (layout.Node, bool) {
for _, node := range nodes {
if node.ID == id {
return node, true
}
}
return layout.Node{}, false
}
func cloudAgentAddedNodeSize(nodeType string) (float64, float64) {
if capability, ok := cloudAgentNodeCapabilityForType(nodeType); ok {
return capability.DefaultWidth, capability.DefaultHeight
}
return 340, 240
}

View File

@@ -0,0 +1,338 @@
package app
import (
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"infinite-canvas/backend/internal/canvas/layout"
"infinite-canvas/backend/internal/model"
)
// layoutFixture 造一张用于整理测试的画布:文本 / 图片 / 视频三类节点挤在同一个角落。
func layoutFixture(t *testing.T, nodes []map[string]any) (*Service, map[string]any) {
t.Helper()
s, db, _, _ := creationTestService(t)
doc := map[string]any{"nodes": nodes, "connections": []any{}}
raw, err := json.Marshal(doc)
if err != nil {
t.Fatal(err)
}
if err := db.Create(&model.CanvasProject{ID: "layout-canvas", UserID: "user", PayloadJSON: string(raw)}).Error; err != nil {
t.Fatal(err)
}
return s, doc
}
func layoutNode(id, nodeType string, x, y float64, metadata ...map[string]any) map[string]any {
meta := map[string]any{"content": "内容-" + id}
if len(metadata) > 0 {
for key, value := range metadata[0] {
meta[key] = value
}
}
return map[string]any{
"id": id, "type": nodeType, "title": "节点-" + id,
"position": map[string]any{"x": x, "y": y},
"width": 300.0, "height": 200.0,
"metadata": meta,
}
}
func arrangeCall(t *testing.T, args map[string]any) cloudAgentCall {
t.Helper()
raw, err := json.Marshal(args)
if err != nil {
t.Fatal(err)
}
call := cloudAgentCall{ID: "arrange-1"}
call.Function.Name = "canvas_arrange_nodes"
call.Function.Arguments = string(raw)
return call
}
func canvasPositions(t *testing.T, s *Service) map[string]layout.Position {
t.Helper()
canvas, err := s.repo.CanvasProjectForUser("user", "layout-canvas")
if err != nil {
t.Fatal(err)
}
doc, err := creationDocument(canvas.PayloadJSON)
if err != nil {
t.Fatal(err)
}
positions := map[string]layout.Position{}
for _, node := range creationMaps(doc["nodes"]) {
x, _ := cloudAgentSafeNumber(node["position"].(map[string]any)["x"])
y, _ := cloudAgentSafeNumber(node["position"].(map[string]any)["y"])
xf, _ := x.(float64)
yf, _ := y.(float64)
positions[stringValue(node["id"])] = layout.Position{X: xf, Y: yf}
}
return positions
}
func TestCloudAgentArrangeNodesLaysOutByLane(t *testing.T) {
nodes := []map[string]any{
layoutNode("text-1", "text", 0, 0),
layoutNode("text-2", "text", 10, 10),
layoutNode("image-1", "image", 20, 20),
layoutNode("video-1", "video", 30, 30),
}
s, doc := layoutFixture(t, nodes)
policy := mustRuntimePolicy(t, s)
result, err := applyCloudAgentArrangeNodes(s.repo, "user", "layout-canvas", arrangeCall(t, map[string]any{
"snapshotHash": creationHash(doc),
"mode": "byType",
}), policy)
if err != nil {
t.Fatal(err)
}
receipt, _ := result.(map[string]any)
// 布局以当前左上角为锚点:已经在锚点上的 text-1 不需要移动,moved 只统计真正变动的节点。
if moved, _ := receipt["moved"].(int); moved < 3 {
t.Fatalf("整理节点数 = %v,期望至少 3:%+v", receipt["moved"], receipt)
}
positions := canvasPositions(t, s)
if positions["text-1"].Y >= positions["image-1"].Y || positions["image-1"].Y >= positions["video-1"].Y {
t.Fatalf("未按媒体泳道分区:%+v", positions)
}
if positions["text-1"].X != positions["image-1"].X {
t.Fatalf("各泳道应左对齐:%+v", positions)
}
applied := []layout.Node{
{ID: "text-1", X: positions["text-1"].X, Y: positions["text-1"].Y, Width: 300, Height: 200},
{ID: "text-2", X: positions["text-2"].X, Y: positions["text-2"].Y, Width: 300, Height: 200},
{ID: "image-1", X: positions["image-1"].X, Y: positions["image-1"].Y, Width: 300, Height: 200},
{ID: "video-1", X: positions["video-1"].X, Y: positions["video-1"].Y, Width: 300, Height: 200},
}
if pairs := layout.Overlaps(applied, 0); len(pairs) != 0 {
t.Fatalf("整理后仍有重叠:%+v", pairs)
}
if hash, _ := receipt["snapshotHash"].(string); hash == "" || hash == receipt["beforeSnapshotHash"] {
t.Fatalf("整理后快照应变化:%+v", receipt)
}
}
func TestCloudAgentArrangeNodesKeepsLockedAndContainers(t *testing.T) {
nodes := []map[string]any{
layoutNode("text-1", "text", 0, 0),
layoutNode("text-2", "text", 5, 5),
layoutNode("locked-1", "text", 400, 400, map[string]any{"locked": true}),
layoutNode("frame-1", "frame", 800, 800),
}
s, doc := layoutFixture(t, nodes)
policy := mustRuntimePolicy(t, s)
if _, err := applyCloudAgentArrangeNodes(s.repo, "user", "layout-canvas", arrangeCall(t, map[string]any{
"snapshotHash": creationHash(doc),
"mode": "byType",
}), policy); err != nil {
t.Fatal(err)
}
positions := canvasPositions(t, s)
if positions["locked-1"] != (layout.Position{X: 400, Y: 400}) {
t.Fatalf("锁定节点被移动:%+v", positions["locked-1"])
}
if positions["frame-1"] != (layout.Position{X: 800, Y: 800}) {
t.Fatalf("背板被移动:%+v", positions["frame-1"])
}
if positions["text-2"].Y == 5 && positions["text-2"].X == 5 {
t.Fatalf("可整理节点没有移动:%+v", positions)
}
}
func TestCloudAgentArrangeNodesGroupsMakeBands(t *testing.T) {
nodes := []map[string]any{
layoutNode("a-1", "image", 0, 0),
layoutNode("a-2", "image", 10, 10),
layoutNode("b-1", "video", 20, 20),
layoutNode("b-2", "video", 30, 30),
layoutNode("left-out", "text", 900, 900),
}
s, doc := layoutFixture(t, nodes)
policy := mustRuntimePolicy(t, s)
result, err := applyCloudAgentArrangeNodes(s.repo, "user", "layout-canvas", arrangeCall(t, map[string]any{
"snapshotHash": creationHash(doc),
"groups": []any{
map[string]any{"label": "A 场景", "nodeIds": []any{"a-1", "a-2"}, "mode": "grid"},
map[string]any{"label": "B 场景", "nodeIds": []any{"b-1", "b-2"}, "mode": "grid"},
},
"gap": 120,
}), policy)
if err != nil {
t.Fatal(err)
}
receipt, _ := result.(map[string]any)
groups, _ := receipt["groups"].([]string)
if len(groups) != 2 || groups[0] != "A 场景" {
t.Fatalf("分组信息缺失:%+v", receipt["groups"])
}
positions := canvasPositions(t, s)
if positions["b-1"].Y <= positions["a-1"].Y {
t.Fatalf("两组未分带:a=%+v b=%+v", positions["a-1"], positions["b-1"])
}
if positions["left-out"] != (layout.Position{X: 900, Y: 900}) {
t.Fatalf("未参与分组的节点被移动:%+v", positions["left-out"])
}
}
func TestCloudAgentArrangeNodesDryRunDoesNotWrite(t *testing.T) {
nodes := []map[string]any{layoutNode("text-1", "text", 0, 0), layoutNode("text-2", "text", 5, 5)}
s, doc := layoutFixture(t, nodes)
policy := mustRuntimePolicy(t, s)
result, err := applyCloudAgentArrangeNodes(s.repo, "user", "layout-canvas", arrangeCall(t, map[string]any{
"snapshotHash": creationHash(doc),
"mode": "row",
"dryRun": true,
}), policy)
if err != nil {
t.Fatal(err)
}
receipt, _ := result.(map[string]any)
if wouldMove, _ := receipt["wouldMove"].(int); wouldMove < 1 {
t.Fatalf("预演结果 = %+v", receipt)
}
if moved, _ := receipt["moved"].(int); moved != 0 {
t.Fatalf("预演不应写入:%+v", receipt)
}
positions := canvasPositions(t, s)
if positions["text-2"] != (layout.Position{X: 5, Y: 5}) {
t.Fatalf("预演不应写入画布:%+v", positions)
}
}
func TestCloudAgentArrangeNodesRejectsStaleSnapshotAndTooManyNodes(t *testing.T) {
nodes := []map[string]any{layoutNode("text-1", "text", 0, 0), layoutNode("text-2", "text", 5, 5)}
s, _ := layoutFixture(t, nodes)
policy := mustRuntimePolicy(t, s)
// 过期快照按上游的字段错误口径归类(issue=stale_snapshot),运行期据此把它当可纠正的
// 工具结果交回模型重新读取,而不是终止整轮。
var staleErr *cloudAgentFieldArgumentError
if _, err := applyCloudAgentArrangeNodes(s.repo, "user", "layout-canvas", arrangeCall(t, map[string]any{
"snapshotHash": "stale-hash",
"mode": "row",
}), policy); !errors.As(err, &staleErr) || staleErr.Issue != "stale_snapshot" || staleErr.Field != "snapshotHash" {
t.Fatalf("陈旧快照应被归类为过期快照:%v", err)
}
many := make([]map[string]any, 0, cloudAgentArrangeMaxNodes+1)
for index := 0; index <= cloudAgentArrangeMaxNodes; index++ {
many = append(many, layoutNode(fmt.Sprintf("text-%d", index), "text", float64(index), 0))
}
s2, doc2 := layoutFixture(t, many)
if _, err := applyCloudAgentArrangeNodes(s2.repo, "user", "layout-canvas", arrangeCall(t, map[string]any{
"snapshotHash": creationHash(doc2),
"mode": "row",
}), mustRuntimePolicy(t, s2)); err == nil || !strings.Contains(err.Error(), "最多整理") {
t.Fatalf("超限应被拒绝:%v", err)
}
}
// add_node 不带坐标时由服务端按泳道自动落位(作为生成输入时排到目标左侧):
// agentCanvasOp.X/Y 的指针语义保证"不传坐标"与"坐标 0"是两件事。
func TestCloudAgentAddedNodeWithoutCoordinatesLandsOrdered(t *testing.T) {
nodes := []map[string]any{
layoutNode("text-1", "text", 0, 0),
layoutNode("image-1", "image", 0, 400),
}
_, doc := layoutFixture(t, nodes)
// 不带坐标:服务端按泳道落位,不能落在 (0,0) 与已有节点重叠。
ops := []agentCanvasOp{
{Type: "add_node", ID: "text-new", NodeType: "text", Title: stringPtr("新增文本")},
}
if _, err := applyCloudAgentCanvasPlan(doc, ops); err != nil {
t.Fatal(err)
}
added := nodeByID(t, doc, "text-new")
position := positionOf(t, added)
if position.X == 0 && position.Y == 0 {
t.Fatalf("新增节点落在原点:%+v", position)
}
if position.X == 0 && position.Y == 400 {
t.Fatalf("新增节点与已有图片重叠:%+v", position)
}
// 带连线:新节点作为生成输入时放在目标左侧。
ops = []agentCanvasOp{
{Type: "add_node", ID: "text-input", NodeType: "text", Title: stringPtr("输入")},
{Type: "connect_nodes", ID: "edge-new", FromNodeID: "text-input", ToNodeID: "image-1"},
}
if _, err := applyCloudAgentCanvasPlan(doc, ops); err != nil {
t.Fatal(err)
}
input := positionOf(t, nodeByID(t, doc, "text-input"))
if input.X >= 0 {
t.Fatalf("作为生成输入的新节点应排到目标左侧:%+v", input)
}
if input.Y != 400 {
t.Fatalf("新节点应与目标同高:%+v", input)
}
// 显式坐标优先。
x, y := 1234.0, 5678.0
ops = []agentCanvasOp{{Type: "add_node", ID: "text-fixed", NodeType: "text", X: &x, Y: &y}}
if _, err := applyCloudAgentCanvasPlan(doc, ops); err != nil {
t.Fatal(err)
}
if got := positionOf(t, nodeByID(t, doc, "text-fixed")); got != (layout.Position{X: 1234, Y: 5678}) {
t.Fatalf("显式坐标未被采用:%+v", got)
}
}
func TestCloudAgentUpdateNodePositionPatch(t *testing.T) {
nodes := []map[string]any{layoutNode("text-1", "text", 0, 0)}
_, doc := layoutFixture(t, nodes)
ops := []agentCanvasOp{{Type: "update_node", ID: "text-1", Patch: map[string]any{"x": 480.0, "y": 260.0}}}
if _, err := applyCloudAgentCanvasPlan(doc, ops); err != nil {
t.Fatal(err)
}
if got := positionOf(t, nodeByID(t, doc, "text-1")); got != (layout.Position{X: 480, Y: 260}) {
t.Fatalf("坐标未写入:%+v", got)
}
ops = []agentCanvasOp{{Type: "update_node", ID: "text-1", Patch: map[string]any{"x": 1e9}}}
if _, err := applyCloudAgentCanvasPlan(doc, ops); err == nil {
t.Fatal("越界坐标应被拒绝")
}
}
func nodeByID(t *testing.T, doc map[string]any, id string) map[string]any {
t.Helper()
for _, node := range creationMaps(doc["nodes"]) {
if stringValue(node["id"]) == id {
return node
}
}
t.Fatalf("节点 %s 不存在", id)
return nil
}
func positionOf(t *testing.T, node map[string]any) layout.Position {
t.Helper()
position, _ := node["position"].(map[string]any)
x, _ := cloudAgentSafeNumber(position["x"])
y, _ := cloudAgentSafeNumber(position["y"])
xf, _ := x.(float64)
yf, _ := y.(float64)
return layout.Position{X: xf, Y: yf}
}
// mustRuntimePolicy 读取运行时策略,供整理工具落库时使用。
func mustRuntimePolicy(t *testing.T, s *Service) RuntimePolicySetting {
t.Helper()
policy, err := s.RuntimePolicy()
if err != nil {
t.Fatal(err)
}
return policy
}

View File

@@ -1100,6 +1100,12 @@ func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *c
if err == nil {
preview = batchPlan.Preview
}
case "canvas_arrange_nodes":
arrangePlan, err := prepareCloudAgentArrangeNodes(repo, run.UserID, state.Request.CanvasID, call)
mutationErr = err
if err == nil {
preview = arrangePlan.Preview
}
default:
canvasPlan, err := prepareCloudAgentCanvasMutation(repo, run.UserID, state.Request.CanvasID, call)
mutationErr = err
@@ -1176,6 +1182,8 @@ func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *c
toolErr = BadAuthRequest("工具未获本轮权限授权")
case call.Function.Name == "canvas_apply_ops":
result, toolErr = applyCloudAgentCanvas(repo, run.UserID, state.Request.CanvasID, call, policy, cloudAgentCanvasEventRecorder(run.ID, state))
case call.Function.Name == "canvas_arrange_nodes":
result, toolErr = applyCloudAgentArrangeNodes(repo, run.UserID, state.Request.CanvasID, call, policy, cloudAgentCanvasEventRecorder(run.ID, state))
case call.Function.Name == "canvas_create_storyboard", call.Function.Name == "canvas_edit_storyboard":
result, toolErr = applyCloudAgentStoryboardMutation(repo, run.UserID, state.Request.CanvasID, call, policy, cloudAgentCanvasEventRecorder(run.ID, state))
case call.Function.Name == "canvas_edit_batch_table":

View File

@@ -52,9 +52,11 @@ func TestCloudAgentToolSchemaStaysCompact(t *testing.T) {
t.Fatal("canvas_apply_ops 未暴露")
}
// 体积预算:当前 20 个工具约 22.4 KB,这里留 ~7% 余量;新增工具或字段时请重新测量并
// 有意识地调整这个数字,而不是让 schema 悄悄膨胀(它每一步都要发、还在前缀最前面)。
if len(raw) > 24000 {
t.Fatalf("平台工具 schema 体积 %d 字节超出预算 24000:请压缩描述或显式调整预算", len(raw))
// 体积预算:上游 20 个工具实测 22,777 字节(预算 24000);本 PR 新增 canvas_arrange_nodes
// 并把描述、patch 字段补齐后实测 21 个工具 24,788 字节,因此把预算显式上调到 25000
// (约 0.9% 余量)。新增工具或字段时请重新测量并有意识地调整这个数字,而不是让 schema
// 悄悄膨胀(它每一步都要发、还在前缀最前面)。
if len(raw) > 25000 {
t.Fatalf("平台工具 schema 体积 %d 字节超出预算 25000:请压缩描述或显式调整预算", len(raw))
}
}

View File

@@ -257,7 +257,22 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma
{"properties": map[string]any{"type": map[string]any{"const": "connect_nodes"}}, "required": []string{"fromNodeId", "toNodeId"}},
},
}
add("canvas_apply_ops", "创建空白节点、修改提示词或建立引用连线,不提交生成任务、不产生生成费用;先读取画布并传 snapshotHash。提交媒体生成使用 generate_media。每次最多20项,禁止删除、任意 metadata 和媒体 URL。每项都需要 type 和 id:add_node 还需要 nodeType,update_node 还需要按节点能力清单填写 patch,connect_nodes 还需要 fromNodeId 与 toNodeId。连线是生成输入关系,不会改变已提交任务的输入;来源须 canSource,目标须 canTarget 且接受来源 inputKind,能力以注册表为准。", map[string]any{"snapshotHash": str("canvas_get_state返回的snapshotHash"), "ops": map[string]any{"type": "array", "maxItems": 20, "items": opItem}}, "snapshotHash", "ops")
add("canvas_apply_ops", "创建空白节点、修改提示词或建立引用连线,不提交生成任务、不产生生成费用;先读取画布并传 snapshotHash。提交媒体生成使用 generate_media。每次最多20项,禁止删除、任意 metadata 和媒体 URL。每项都需要 type 和 id:add_node 还需要 nodeType(可给 x/y 指定位置;省略坐标时服务端按画布内容自动落位,不会叠在原点),update_node 还需要按节点能力清单填写 patch(可含 x/y 移动节点),connect_nodes 还需要 fromNodeId 与 toNodeId。连线是生成输入关系,不会改变已提交任务的输入;来源须 canSource,目标须 canTarget 且接受来源 inputKind,能力以注册表为准。批量整理位置用 canvas_arrange_nodes,不要用几十项 update_node 手工算坐标。", map[string]any{"snapshotHash": str("canvas_get_state返回的snapshotHash"), "ops": map[string]any{"type": "array", "maxItems": 20, "items": opItem}}, "snapshotHash", "ops")
add("canvas_arrange_nodes", "整理画布节点位置:只改坐标,不改内容、不建连线、不增删节点,先读画布并传 snapshotHash。mode 省略即 auto(有连线按依赖分层,否则按媒体类型分区)。groups 为横向分带(label 展示名,可覆盖整组 mode)。nodeIds 省略则整理全部可整理节点(跳过锁定节点、容器、批次子节点与已归属背板者)。align 对齐/等距,dryRun 只预演;一次最多 50 个节点,只挪单个节点用 update_node 的 x/y。", map[string]any{
"snapshotHash": str("最近一次画布读取的 snapshotHash"),
"nodeIds": map[string]any{"type": "array", "maxItems": cloudAgentArrangeMaxNodes, "items": str("节点ID;省略=全部可整理")},
"mode": map[string]any{"type": "string", "enum": []any{"auto", "flow", "byType", "row", "column", "grid"}, "description": "auto=有连线按依赖否则按类型;flow=按依赖分层;byType=按类型分区;row/column/grid=线性或网格"},
"groups": map[string]any{"type": "array", "maxItems": cloudAgentArrangeMaxGroups, "items": map[string]any{
"type": "object", "properties": map[string]any{
"label": str("分组展示名"),
"nodeIds": map[string]any{"type": "array", "maxItems": cloudAgentArrangeMaxNodes, "items": str("节点ID")},
"mode": map[string]any{"type": "string", "enum": []any{"byType", "flow", "row", "column", "grid"}},
}, "required": []string{"nodeIds"}, "additionalProperties": false,
}},
"align": map[string]any{"type": "string", "enum": []any{"left", "centerX", "right", "top", "centerY", "bottom", "distributeX", "distributeY"}},
"gap": map[string]any{"type": "number", "minimum": 0, "maximum": cloudAgentArrangeMaxGap, "description": "分带间距(像素)"},
"dryRun": map[string]any{"type": "boolean", "description": "true 只预演不写入"},
}, "snapshotHash")
}
if req.PermissionMode != "read_only" && len(req.ContextScope) > 0 {
add("generate_media", "提交媒体生成:准备草稿和引用连线,独立审批通过后提交收费任务,auto也需要审批。仅创建节点、编辑提示词或连线使用 canvas_apply_ops。生成前读取画布和按实际参考素材筛选的模型目录,参数需符合返回的时长、画幅和音频能力。可续用空闲且无任务、无产物的草稿;其他运行的草稿需原运行已结束且清理完成。已绑定任务或已有产物的节点不能覆盖,原任务状态和错误可从 generation 或 task_get 读取。sourceNodeId 是文本输入;referenceNodeIds 是媒体输入;referenceTransientIds 只接受标注工具返回的临时引用,不接受任意URL。准入错误按返回的 reason 修正;已提交任务失败应告知用户,重新生成需用户明确要求并重新审批。", map[string]any{
@@ -310,7 +325,7 @@ func cloudAgentToolAllowed(req CloudAgentRequest, name string) bool {
return false
}
func cloudAgentWrite(name string) bool {
return name == "canvas_apply_ops" || name == "generate_media" || name == "image_layer_split" || name == "canvas_create_storyboard" || name == "canvas_edit_storyboard" || name == "canvas_edit_batch_table"
return name == "canvas_apply_ops" || name == "canvas_arrange_nodes" || name == "generate_media" || name == "image_layer_split" || name == "canvas_create_storyboard" || name == "canvas_edit_storyboard" || name == "canvas_edit_batch_table"
}
func cloudAgentReadTool(repo *repository.Repository, userID string, state *cloudAgentRuntime, call cloudAgentCall, services ...*Service) (any, error) {
@@ -683,16 +698,18 @@ type agentCanvasArgs struct {
}
type agentCanvasOp struct {
Type string `json:"type"`
ID string `json:"id"`
NodeType string `json:"nodeType"`
Title *string `json:"title"`
Content *string `json:"content"`
Patch map[string]any `json:"patch"`
X float64 `json:"x"`
Y float64 `json:"y"`
FromNodeID string `json:"fromNodeId"`
ToNodeID string `json:"toNodeId"`
Type string `json:"type"`
ID string `json:"id"`
NodeType string `json:"nodeType"`
Title *string `json:"title"`
Content *string `json:"content"`
Patch map[string]any `json:"patch"`
// X/Y 为指针:nil 表示模型没有指定坐标,服务端按画布内容自动落位(不再落到原点重叠)。
// 指针语义与 canvas/capability/builtin.go 的 positionPatchFields 一致(坐标是可选的数字)。
X *float64 `json:"x"`
Y *float64 `json:"y"`
FromNodeID string `json:"fromNodeId"`
ToNodeID string `json:"toNodeId"`
}
// Explicit node creation and edges only; no generic metadata, media URL or deletion.

View File

@@ -3,6 +3,8 @@ package capability
const (
maxAgentNodeTitleRunes = 240
maxAgentNodeContentRunes = 16000
// 坐标绝对值上限:与 app 侧整理工具收敛几何值的范围一致。
maxAgentNodeCoordLimit = 1e6
)
func BuiltinRegistry() *Registry {
@@ -60,9 +62,15 @@ func BuiltinRegistry() *Registry {
InputKind: "text",
Connection: ConnectionPolicy{CanSource: true, CanTarget: true, AcceptedInputKinds: []string{"image"}},
CanUpdate: true, SummaryFields: []string{"batchTable"}, DetailFields: []string{"batchTable"}, ProjectionKind: "batch_table", ProjectionField: "batchTable",
PatchFields: map[string]PatchField{
"title": {Path: "title", Kind: patchKindString, Label: "节点名称", Order: 10, Description: "批量创作表标题", MaxRunes: maxAgentNodeTitleRunes},
},
PatchFields: func() map[string]PatchField {
fields := map[string]PatchField{
"title": {Path: "title", Kind: patchKindString, Label: "节点名称", Order: 10, Description: "批量创作表标题", MaxRunes: maxAgentNodeTitleRunes},
}
for key, field := range positionPatchFields() {
fields[key] = field
}
return fields
}(),
CreateMetadata: func(string) map[string]any {
return map[string]any{
"status": "idle",
@@ -151,7 +159,7 @@ func generatedMediaSemantics(nodeType string) generatedMediaCapabilitySemantics
}
func editableNodeFields(contentPath, contentLabel, contentDescription string) map[string]PatchField {
return map[string]PatchField{
fields := map[string]PatchField{
"title": {
Path: "title", Kind: patchKindString, Label: "节点名称", Order: 10, Description: "节点标题", MaxRunes: maxAgentNodeTitleRunes,
},
@@ -159,6 +167,19 @@ func editableNodeFields(contentPath, contentLabel, contentDescription string) ma
Path: contentPath, Kind: patchKindString, Label: contentLabel, Order: 20, Description: contentDescription, MaxRunes: maxAgentNodeContentRunes,
},
}
for key, field := range positionPatchFields() {
fields[key] = field
}
return fields
}
// positionPatchFields 是坐标字段:模型可以直接指定节点位置(微调),批量整理走 canvas_arrange_nodes。
// 坐标上限与整理侧的收敛范围一致,避免写进离谱的几何值。
func positionPatchFields() map[string]PatchField {
return map[string]PatchField{
"x": {Path: "position.x", Kind: patchKindNumber, Label: "横坐标", Order: 30, Limit: maxAgentNodeCoordLimit, Description: "画布横坐标(像素);与 y 一起移动节点,通常用 canvas_arrange_nodes 批量整理"},
"y": {Path: "position.y", Kind: patchKindNumber, Label: "纵坐标", Order: 31, Limit: maxAgentNodeCoordLimit, Description: "画布纵坐标(像素);与 x 一起移动节点,通常用 canvas_arrange_nodes 批量整理"},
}
}
func generatedMetadata(prompt string) map[string]any {

View File

@@ -2,6 +2,7 @@ package capability
import (
"fmt"
"math"
"sort"
"strings"
"unicode"
@@ -23,10 +24,12 @@ type ConnectionPolicy struct {
}
type PatchField struct {
Path string
Kind string
Label string
Order int
Path string
Kind string
Label string
Order int
// Limit > 0 时表示数值字段的绝对值上限(坐标用),避免模型写入离谱的几何值。
Limit float64
Description string
MaxRunes int
}
@@ -107,9 +110,16 @@ func (d Descriptor) ValidatePatch(patch map[string]any) error {
return fmt.Errorf("%s 字段 %s 超出长度限制", d.Label, key)
}
case "number":
if _, ok := value.(float64); !ok {
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)

View File

@@ -0,0 +1,627 @@
// Package layout 是画布节点排布的确定性几何实现,供云端 Agent 的"整理 / 归类 / 新增落位"使用。
//
// 这里刻意与前端 web/src/lib/canvas/canvas-layout.ts 保持同一套常量与算法语义:
// 前端"自动整理节点"按钮与 Agent 的整理工具必须给出同样的结果,否则同一个画布在两条
// 路径下会得到不同的布局。修改任一侧常量或算法时,另一侧要同步(两侧各自有黄金用例)。
//
// 本包只做几何,不读取画布文档、不判断权限、不写库。
package layout
import "sort"
const (
// 与 canvas-layout.ts 的常量一一对应。
FlowColumnGap = 120
LaneGap = 96
LaneNodeGap = 36
GridNodeGap = 32
// 新增节点与已有节点之间的最小空隙(落位避让用)。
PlacementGap = 40
)
// LaneOrder 是泳道的稳定顺序,决定从上到下的分区次序。
var LaneOrder = []string{"text", "image", "video", "audio"}
// Node 是排布所需的节点快照。Width/Height 为 0 时按默认尺寸补齐。
type Node struct {
ID string
Type string
Lane string
X, Y float64
Width float64
Height float64
Locked bool
NoMove bool // 容器节点(背板/文件夹)本身不参与整理
Movable bool
}
// Position 是排布结果。
type Position struct {
X float64
Y float64
}
// Edge 是一条有向引用连线(来源 → 目标),用于依赖方向分层。
type Edge struct {
From string
To string
}
// Mode 是整理模式。
type Mode string
const (
ModeAuto Mode = "auto"
ModeFlow Mode = "flow"
ModeByLane Mode = "byType"
ModeRow Mode = "row"
ModeColumn Mode = "column"
ModeGrid Mode = "grid"
)
// AlignMode 是对齐 / 等距分布模式。
type AlignMode string
const (
AlignLeft AlignMode = "left"
AlignCenterX AlignMode = "centerX"
AlignRight AlignMode = "right"
AlignTop AlignMode = "top"
AlignCenterY AlignMode = "centerY"
AlignBottom AlignMode = "bottom"
AlignDistributeX AlignMode = "distributeX"
AlignDistributeY AlignMode = "distributeY"
)
func (n Node) width() float64 {
if n.Width > 0 {
return n.Width
}
return 340
}
func (n Node) height() float64 {
if n.Height > 0 {
return n.Height
}
return 240
}
func minX(nodes []Node) float64 {
value := nodes[0].X
for _, node := range nodes[1:] {
if node.X < value {
value = node.X
}
}
return value
}
func minY(nodes []Node) float64 {
value := nodes[0].Y
for _, node := range nodes[1:] {
if node.Y < value {
value = node.Y
}
}
return value
}
func maxWidth(nodes []Node) float64 {
value := 0.0
for _, node := range nodes {
if node.width() > value {
value = node.width()
}
}
return value
}
func maxHeight(nodes []Node) float64 {
value := 0.0
for _, node := range nodes {
if node.height() > value {
value = node.height()
}
}
return value
}
// byPosition 复刻前端的稳定次序:先按 y,再按 x。
func byPosition(nodes []Node) []Node {
sorted := append([]Node(nil), nodes...)
sort.SliceStable(sorted, func(i, j int) bool {
if sorted[i].Y != sorted[j].Y {
return sorted[i].Y < sorted[j].Y
}
return sorted[i].X < sorted[j].X
})
return sorted
}
// Auto 复刻 layoutCanvasAuto:节点之间有连线时按依赖分层,否则按媒体类型泳道。
func Auto(nodes []Node, edges []Edge) map[string]Position {
if len(nodes) < 2 {
return map[string]Position{}
}
if connected(nodes, edges) {
return Flow(nodes, edges)
}
return ByLane(nodes)
}
func connected(nodes []Node, edges []Edge) bool {
ids := make(map[string]bool, len(nodes))
for _, node := range nodes {
ids[node.ID] = true
}
for _, edge := range edges {
if ids[edge.From] && ids[edge.To] {
return true
}
}
return false
}
// Flow 复刻 layoutCanvasFlow:Kahn 拓扑分层从左到右,层内按泳道竖排。
// 环上的节点留在第 0 层,避免布局死循环。
func Flow(nodes []Node, edges []Edge) map[string]Position {
result := map[string]Position{}
if len(nodes) == 0 {
return result
}
index := make(map[string]Node, len(nodes))
for _, node := range nodes {
index[node.ID] = node
}
inbound := make(map[string]int, len(nodes))
outbound := make(map[string][]string, len(nodes))
for _, node := range nodes {
inbound[node.ID] = 0
}
for _, edge := range edges {
if _, ok := index[edge.From]; !ok {
continue
}
if _, ok := index[edge.To]; !ok {
continue
}
inbound[edge.To]++
outbound[edge.From] = append(outbound[edge.From], edge.To)
}
queue := make([]string, 0, len(nodes))
for _, node := range nodes {
if inbound[node.ID] == 0 {
queue = append(queue, node.ID)
}
}
layer := map[string]int{}
for _, id := range queue {
layer[id] = 0
}
for len(queue) > 0 {
id := queue[0]
queue = queue[1:]
for _, target := range outbound[id] {
if layer[target] < layer[id]+1 {
layer[target] = layer[id] + 1
}
inbound[target]--
if inbound[target] == 0 {
queue = append(queue, target)
}
}
}
for _, node := range nodes {
if _, ok := layer[node.ID]; !ok {
layer[node.ID] = 0
}
}
left, top := minX(nodes), minY(nodes)
layerIDs := make([]int, 0, len(nodes))
seen := map[int]bool{}
for _, node := range nodes {
if !seen[layer[node.ID]] {
seen[layer[node.ID]] = true
layerIDs = append(layerIDs, layer[node.ID])
}
}
sort.Ints(layerIDs)
layerX := map[int]float64{}
x := left
for _, value := range layerIDs {
members := nodesInLayer(nodes, layer, value)
layerX[value] = x
x += maxWidth(members) + FlowColumnGap
}
laneTop := top
for _, lane := range LaneOrder {
lanes := laneNodes(nodes, lane)
if len(lanes) == 0 {
continue
}
stacks := map[int][]Node{}
stackOrder := make([]int, 0, len(lanes))
for _, node := range lanes {
value := layer[node.ID]
if _, ok := stacks[value]; !ok {
stackOrder = append(stackOrder, value)
}
stacks[value] = append(stacks[value], node)
}
laneHeight := 0.0
for _, stack := range stacks {
height := 0.0
for _, node := range stack {
height += node.height()
}
height += float64(max(0, len(stack)-1)) * LaneNodeGap
if height > laneHeight {
laneHeight = height
}
}
sort.Ints(stackOrder)
for _, value := range stackOrder {
stack := byPosition(stacks[value])
y := laneTop
for _, node := range stack {
result[node.ID] = Position{X: layerX[value], Y: y}
y += node.height() + LaneNodeGap
}
}
laneTop += laneHeight + LaneGap
}
return result
}
func nodesInLayer(nodes []Node, layer map[string]int, value int) []Node {
subset := make([]Node, 0, len(nodes))
for _, node := range nodes {
if layer[node.ID] == value {
subset = append(subset, node)
}
}
return subset
}
func laneNodes(nodes []Node, lane string) []Node {
subset := make([]Node, 0, len(nodes))
for _, node := range nodes {
if node.Lane == lane {
subset = append(subset, node)
}
}
return subset
}
// ByLane 复刻 layoutCanvasNodesByMediaType:每个泳道内部按网格排布(最多 4 列)。
func ByLane(nodes []Node) map[string]Position {
result := map[string]Position{}
if len(nodes) == 0 {
return result
}
left, top := minX(nodes), minY(nodes)
laneTop := top
for _, lane := range LaneOrder {
lanes := byPosition(laneNodes(nodes, lane))
if len(lanes) == 0 {
continue
}
columns := min(4, max(1, ceilSqrt(len(lanes))))
cellWidth := maxWidth(lanes) + GridNodeGap
cellHeight := maxHeight(lanes) + GridNodeGap
for index, node := range lanes {
result[node.ID] = Position{
X: left + float64(index%columns)*cellWidth,
Y: laneTop + float64(index/columns)*cellHeight,
}
}
rows := float64(ceilDiv(len(lanes), columns))
laneTop += rows*cellHeight - GridNodeGap + LaneGap
}
return result
}
// Linear 复刻 layoutCanvasNodes:单行 / 单列 / 方阵。
func Linear(nodes []Node, mode Mode) map[string]Position {
result := map[string]Position{}
if len(nodes) == 0 {
return result
}
sorted := byPosition(nodes)
left, top := minX(nodes), minY(nodes)
switch mode {
case ModeRow:
x := left
for _, node := range sorted {
result[node.ID] = Position{X: x, Y: top}
x += node.width() + GridNodeGap
}
case ModeColumn:
y := top
for _, node := range sorted {
result[node.ID] = Position{X: left, Y: y}
y += node.height() + GridNodeGap
}
default:
columns := max(1, int(ceilSqrt(len(sorted))))
cellWidth := maxWidth(sorted) + GridNodeGap
cellHeight := maxHeight(sorted) + GridNodeGap
for index, node := range sorted {
result[node.ID] = Position{
X: left + float64(index%columns)*cellWidth,
Y: top + float64(index/columns)*cellHeight,
}
}
}
return result
}
// Align 复刻 alignCanvasNodes:对齐与等距分布。
func Align(nodes []Node, mode AlignMode) map[string]Position {
result := map[string]Position{}
if len(nodes) < 2 {
return result
}
left, top := minX(nodes), minY(nodes)
right, bottom := 0.0, 0.0
for index, node := range nodes {
if index == 0 || node.X+node.width() > right {
right = node.X + node.width()
}
if index == 0 || node.Y+node.height() > bottom {
bottom = node.Y + node.height()
}
}
centerX, centerY := (left+right)/2, (top+bottom)/2
switch mode {
case AlignDistributeX:
sorted := append([]Node(nil), nodes...)
sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].X < sorted[j].X })
total := 0.0
for _, node := range sorted {
total += node.width()
}
gap := (right - left - total) / float64(max(1, len(sorted)-1))
x := left
for _, node := range sorted {
result[node.ID] = Position{X: x, Y: node.Y}
x += node.width() + gap
}
return result
case AlignDistributeY:
sorted := append([]Node(nil), nodes...)
sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].Y < sorted[j].Y })
total := 0.0
for _, node := range sorted {
total += node.height()
}
gap := (bottom - top - total) / float64(max(1, len(sorted)-1))
y := top
for _, node := range sorted {
result[node.ID] = Position{X: node.X, Y: y}
y += node.height() + gap
}
return result
}
for _, node := range nodes {
x, y := node.X, node.Y
switch mode {
case AlignLeft:
x = left
case AlignCenterX:
x = centerX - node.width()/2
case AlignRight:
x = right - node.width()
case AlignTop:
y = top
case AlignCenterY:
y = centerY - node.height()/2
case AlignBottom:
y = bottom - node.height()
}
result[node.ID] = Position{X: x, Y: y}
}
return result
}
// Band 是一组节点("归类"后的一个分组)。Bands 按给定顺序从上到下排成带状。
type Band struct {
Label string
Nodes []Node
Mode Mode
}
// Bands 把分组排成横向带:每组占一条带,带内按各自模式排布,带间留 gap(默认 LaneGap)。
// 这是"只做位置分带"的实现:不改变节点层级,也不创建容器节点。
func Bands(bands []Band, gap float64) map[string]Position {
result := map[string]Position{}
if len(bands) == 0 {
return result
}
if gap <= 0 {
gap = LaneGap
}
all := make([]Node, 0)
for _, band := range bands {
all = append(all, band.Nodes...)
}
if len(all) == 0 {
return result
}
left, top := minX(all), minY(all)
bandTop := top
for _, band := range bands {
if len(band.Nodes) == 0 {
continue
}
mode := band.Mode
if mode == "" || mode == ModeAuto {
mode = ModeByLane
}
var positions map[string]Position
switch mode {
case ModeFlow:
positions = Flow(band.Nodes, nil)
case ModeByLane:
positions = ByLane(band.Nodes)
default:
positions = Linear(band.Nodes, mode)
}
// 把这一带整体移到带起点:纵向从 bandTop 开始,横向统一从 left 开始。
bandLeft, bandTopValue := 0.0, 0.0
first := true
for _, position := range positions {
if first {
bandLeft, bandTopValue, first = position.X, position.Y, false
continue
}
if position.X < bandLeft {
bandLeft = position.X
}
if position.Y < bandTopValue {
bandTopValue = position.Y
}
}
height := 0.0
for id, position := range positions {
node := findNode(band.Nodes, id)
result[id] = Position{X: left + (position.X - bandLeft), Y: bandTop + (position.Y - bandTopValue)}
if bottom := result[id].Y + node.height() - bandTop; bottom > height {
height = bottom
}
}
bandTop += height + gap
}
return result
}
func findNode(nodes []Node, id string) Node {
for _, node := range nodes {
if node.ID == id {
return node
}
}
return Node{}
}
// Bounds 计算一组节点的包围盒(含尺寸)。
func Bounds(nodes []Node) (left, top, right, bottom float64, ok bool) {
if len(nodes) == 0 {
return 0, 0, 0, 0, false
}
left, top = minX(nodes), minY(nodes)
for index, node := range nodes {
if index == 0 || node.X+node.width() > right {
right = node.X + node.width()
}
if index == 0 || node.Y+node.height() > bottom {
bottom = node.Y + node.height()
}
}
return left, top, right, bottom, true
}
// overlaps 判断两个节点(含额外空隙)是否相交。
func overlaps(first, second Node, gap float64) bool {
return first.X < second.X+second.width()+gap &&
second.X < first.X+first.width()+gap &&
first.Y < second.Y+second.height()+gap &&
second.Y < first.Y+first.height()+gap
}
// Overlaps 返回相互重叠(含 gap 余量)的节点对,用于回执里的自查提示。
func Overlaps(nodes []Node, gap float64) [][2]string {
pairs := make([][2]string, 0)
for index := 0; index < len(nodes); index++ {
for next := index + 1; next < len(nodes); next++ {
if overlaps(nodes[index], nodes[next], gap) {
pairs = append(pairs, [2]string{nodes[index].ID, nodes[next].ID})
}
}
}
return pairs
}
// FreeSlot 为新增节点找一块不与已有节点重叠的空位。
//
// 策略:先按泳道在同类节点右侧找空位;冲突则沿该泳道向右、再向下扫描(步长为节点尺寸 + 空隙)。
// occupied 是画布现有节点(含本批已落位的新节点);preferred 非空时优先尝试该坐标(依赖落位)。
func FreeSlot(width, height float64, lane string, occupied []Node, preferred *Position, gap float64) Position {
if width <= 0 {
width = 340
}
if height <= 0 {
height = 240
}
if gap <= 0 {
gap = PlacementGap
}
candidate := Node{Width: width, Height: height}
fits := func(x, y float64) bool {
candidate.X, candidate.Y = x, y
for _, node := range occupied {
if overlaps(candidate, node, gap) {
return false
}
}
return true
}
if preferred != nil && fits(preferred.X, preferred.Y) {
return *preferred
}
// 起点:该泳道已有节点的最右下方;没有同类节点时退回画布左下角。
sameLane := laneNodes(occupied, lane)
startX, startY := 0.0, 0.0
if len(sameLane) > 0 {
startY = minY(sameLane)
startX = 0
for _, node := range sameLane {
if right := node.X + node.width() + gap; right > startX {
startX = right
}
}
} else if len(occupied) > 0 {
_, _, right, _, _ := Bounds(occupied)
startX = right + gap
startY = minY(occupied)
}
if preferred != nil {
startX, startY = preferred.X, preferred.Y
}
stepX, stepY := width+gap, height+gap
for column := 0; column < 64; column++ {
for row := 0; row < 64; row++ {
x := startX + float64(column)*stepX
y := startY + float64(row)*stepY
if fits(x, y) {
return Position{X: x, Y: y}
}
}
}
return Position{X: startX, Y: startY}
}
func ceilSqrt(value int) int {
if value <= 0 {
return 0
}
root := 1
for root*root < value {
root++
}
return root
}
func ceilDiv(value, divisor int) int {
if divisor <= 0 {
return 0
}
return (value + divisor - 1) / divisor
}

View File

@@ -0,0 +1,170 @@
package layout
import (
"math"
"testing"
)
func node(id, lane string, x, y, w, h float64) Node {
return Node{ID: id, Lane: lane, X: x, Y: y, Width: w, Height: h}
}
func assertPosition(t *testing.T, positions map[string]Position, id string, x, y float64) {
t.Helper()
position, ok := positions[id]
if !ok {
t.Fatalf("缺少节点 %s 的位置:%+v", id, positions)
}
if math.Abs(position.X-x) > 0.001 || math.Abs(position.Y-y) > 0.001 {
t.Fatalf("节点 %s 位置 = (%v,%v),期望 (%v,%v)", id, position.X, position.Y, x, y)
}
}
// 与前端 layoutCanvasAuto 的媒体泳道分支对齐:text / image / video / audio 从上到下分区。
func TestByLaneKeepsLaneOrderAndAvoidsOverlap(t *testing.T) {
nodes := []Node{
node("text-1", "text", 0, 0, 340, 240),
node("image-1", "image", 900, 500, 720, 405),
node("video-1", "video", 900, 1200, 720, 405),
node("audio-1", "audio", 900, 1800, 340, 120),
}
positions := ByLane(nodes)
assertPosition(t, positions, "text-1", 0, 0)
// text 泳道一行高 240,之后是本泳道与下一泳道之间的 LaneGap
assertPosition(t, positions, "image-1", 0, 240+LaneGap)
// image 泳道一行高 405,其后同样是 LaneGap
imageBottom := 240.0 + LaneGap + 405
assertPosition(t, positions, "video-1", 0, imageBottom+LaneGap)
assertPosition(t, positions, "audio-1", 0, imageBottom+LaneGap+405+LaneGap)
if pairs := Overlaps(withPositions(nodes, positions), 0); len(pairs) != 0 {
t.Fatalf("泳道排布出现重叠:%+v", pairs)
}
}
// 与前端 layoutCanvasFlow 对齐:依赖方向从左到右,层内同泳道竖向堆叠。
func TestFlowLaysOutDependenciesLeftToRight(t *testing.T) {
nodes := []Node{
node("text-1", "text", 0, 0, 340, 240),
node("image-1", "image", 0, 400, 720, 405),
node("video-1", "video", 0, 900, 720, 405),
}
edges := []Edge{{From: "text-1", To: "video-1"}, {From: "image-1", To: "video-1"}}
positions := Flow(nodes, edges)
textX := positions["text-1"].X
imageX := positions["image-1"].X
videoX := positions["video-1"].X
if textX != imageX {
t.Fatalf("同层起点应一致:text=%v image=%v", textX, imageX)
}
wantVideoX := textX + 720 + FlowColumnGap
if math.Abs(videoX-wantVideoX) > 0.001 {
t.Fatalf("下一层 x = %v,期望 %v", videoX, wantVideoX)
}
// 没有入边的自环也要落在第一层,不能死循环
cyclic := Flow(nodes, append(edges, Edge{From: "video-1", To: "text-1"}))
if _, ok := cyclic["text-1"]; !ok {
t.Fatal("含环时第一层节点丢失")
}
}
func TestAutoFallsBackToLanesWithoutConnections(t *testing.T) {
nodes := []Node{node("image-1", "image", 0, 0, 720, 405), node("image-2", "image", 800, 0, 720, 405)}
edges := []Edge{{From: "image-1", To: "missing"}} // 集合外的连线不算
positions := Auto(nodes, edges)
if positions["image-1"].Y != positions["image-2"].Y {
t.Fatalf("无有效连线时应按泳道横排:%+v", positions)
}
connected := Auto(nodes, []Edge{{From: "image-1", To: "image-2"}})
if connected["image-1"].X == connected["image-2"].X {
t.Fatalf("有连线时应分层左右排开:%+v", connected)
}
}
// Bands 是"只做位置分带"的归类:每组一条带,带间留 gap,组内不重叠。
func TestBandsSeparatesGroups(t *testing.T) {
bands := []Band{
{Label: "A", Mode: ModeByLane, Nodes: []Node{node("a1", "image", 0, 0, 720, 405), node("a2", "video", 800, 0, 720, 405)}},
{Label: "B", Mode: ModeByLane, Nodes: []Node{node("b1", "image", 0, 3000, 720, 405)}},
}
positions := Bands(bands, 120)
// 带内按泳道排:image 泳道在上,video 泳道在其下方
if positions["a1"].Y != 0 {
t.Fatalf("第一条带应从顶部开始:%+v", positions["a1"])
}
if positions["a2"].Y <= positions["a1"].Y {
t.Fatalf("带内不同泳道应上下分开:a1=%v a2=%v", positions["a1"], positions["a2"])
}
// 第二整条带排在第一条带之下,且所有带左对齐
if positions["b1"].Y <= positions["a2"].Y+405 {
t.Fatalf("第二带应排在第一带下方:a2=%v b1=%v", positions["a2"], positions["b1"])
}
if positions["b1"].X != positions["a1"].X {
t.Fatalf("各带应左对齐:a1=%v b1=%v", positions["a1"], positions["b1"])
}
applied := append([]Node{}, bands[0].Nodes...)
applied = append(applied, bands[1].Nodes...)
if pairs := Overlaps(withPositions(applied, positions), 0); len(pairs) != 0 {
t.Fatalf("分带后出现重叠:%+v", pairs)
}
}
func TestAlignDistributesAndCenters(t *testing.T) {
nodes := []Node{node("a", "text", 0, 0, 100, 100), node("b", "text", 300, 400, 100, 100), node("c", "text", 900, 900, 100, 100)}
left := Align(nodes, AlignLeft)
for _, id := range []string{"a", "b", "c"} {
if left[id].X != 0 {
t.Fatalf("%s 未左对齐:%+v", id, left[id])
}
}
distributed := Align(nodes, AlignDistributeY)
gaps := []float64{
distributed["b"].Y - (distributed["a"].Y + 100),
distributed["c"].Y - (distributed["b"].Y + 100),
}
if math.Abs(gaps[0]-gaps[1]) > 0.001 {
t.Fatalf("纵向等距分布不均匀:%v", gaps)
}
}
func TestFreeSlotAvoidsExistingNodes(t *testing.T) {
occupied := []Node{node("image-1", "image", 0, 0, 720, 405)}
// 优选坐标被占用时让位
preferred := Position{X: 0, Y: 0}
slot := FreeSlot(720, 405, "image", occupied, &preferred, 40)
if slot.X == 0 && slot.Y == 0 {
t.Fatalf("优选坐标被占用时未让位:%+v", slot)
}
candidate := Node{ID: "new", Lane: "image", X: slot.X, Y: slot.Y, Width: 720, Height: 405}
if pairs := Overlaps(append(occupied, candidate), 0); len(pairs) != 0 {
t.Fatalf("落位后仍重叠:%+v slot=%+v", pairs, slot)
}
// 空闲的优选坐标直接采用
free := Position{X: 2000, Y: 0}
if got := FreeSlot(720, 405, "image", occupied, &free, 40); got != free {
t.Fatalf("空闲优选坐标未被采用:%+v", got)
}
// 没有任何节点时落在原点附近
empty := FreeSlot(340, 240, "text", nil, nil, 40)
if empty.X != 0 || empty.Y != 0 {
t.Fatalf("空画布落位 = %+v", empty)
}
}
func withPositions(nodes []Node, positions map[string]Position) []Node {
applied := make([]Node, 0, len(nodes))
for _, item := range nodes {
if position, ok := positions[item.ID]; ok {
item.X, item.Y = position.X, position.Y
}
applied = append(applied, item)
}
return applied
}

View File

@@ -1,6 +1,6 @@
---
id: cloud-agent-system
version: 7
version: 8
---
# 影策 Cloud Agent 系统行为策略
@@ -47,6 +47,16 @@ version: 7
上一轮收束摘要只说明运行结果、失败原因和已提交任务;它不要求重做历史任务。已提交收费任务不得因续聊或恢复提示自动重发。
## 布局与整理
节点位置属于用户手工编辑的空间,只在两种情况下整理:用户明确要求整理、归类或排版;或你新增的节点会与现有节点重叠(服务端在未给坐标时会自动落位,通常不需要你处理)。
- 整理用 `canvas_arrange_nodes`:先读取画布拿 `snapshotHash`,只改坐标,不改内容、不建连线、不改层级,也不把节点收进背板。
- 归类用 `groups`:你决定"哪些节点属于同一场景/同一镜头组"和组的顺序,位置由服务端算;分组只形成横向位置带。
- 新增节点时不要自己算坐标:省略 `x`/`y` 让服务端按画布内容落位(靠近依赖的输入在左、输出在右,或追加到同类节点之后)。只有用户明确要求某个位置,或需要微调单个节点时,才用 `update_node` 的 `x`/`y`。
- 锁定节点、背板等容器节点、隐藏批次子节点和已归属背板的子节点不会被动;整理要跳过它们,不要为了统一外观去动用户的锁定布局。
- 整理会改变画布快照:整理后要重新读取画布再继续写入,不要复用旧的 `snapshotHash`。
## 能力使用
以工具返回的能力清单为准,不猜测节点类型、字段、连接关系或模型选择。节点选择由你结合用户目标、镜头数量、连续性和后续维护需求判断:单个画面、一次性提示词或快速试验通常使用文本/Markdown与媒体节点更轻量;多镜头、逐镜审查、逐镜生成、资产关联或需要交接维护时,分镜脚本通常更合适。不要为了形式创建复杂节点,也不要用普通文本伪装成结构化分镜;如果选择轻量节点处理多镜头草稿,不要声称它具备镜头级维护能力。结构化数据节点应通过其专用字段和分页读取,不把整份数据塞进上下文。`sourceNodeId` 只用于能力清单声明为文本输入来源的节点;媒体资产只通过 `referenceNodeIds` 建立真实引用,文字中的编号不能替代引用。模型选择必须复制模型目录返回的完整 `selection`,不要把任意 ID 猜成 `logicalModelId`。

View File

@@ -10,7 +10,7 @@ func TestLoadAgentPoliciesUsesDocumentMetadata(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if system.ID != "cloud-agent-system" || system.Version != 7 || media.ID != "cloud-agent-media" || media.Version != 3 {
if system.ID != "cloud-agent-system" || system.Version != 8 || media.ID != "cloud-agent-media" || media.Version != 3 {
t.Fatalf("unexpected policy metadata: system=%+v media=%+v", system, media)
}
if strings.Contains(system.Text, "id: cloud-agent-system") || !strings.HasPrefix(system.Text, "# 影策 Cloud Agent") {

View File

@@ -151,12 +151,13 @@ Token 报价的 `estimated` 为 `true`,`quantity` 是预留 Token 数。参考
- 系统行为策略与媒体默认策略是版本化、编译进后端发布物的资源,修改它们仍需发布后端,不是后台热更新;工具、校验、扣费和审批在代码侧强制执行。画布节点能力由服务端 registry 定义,工具 schema、创建、连线与画布摘要读取共同消费;新生成模式还必须接入媒体任务适配器,不能仅凭 md 或注册节点启用收费操作。
- 已安装且属于当前用户的技能在创建时只保存版本、哈希和文本文件路径,不预存全部正文,也不按所有选中技能正文合计 128KB 拒绝创建。`skill_read_file` 按需读取 `SKILL.md` 或必要引用,支持字符偏移 `offset`(默认 0),每页最多 12000 字符,返回 `hasMore` / `nextOffset`。读取时重新验证用户权限、启用状态和版本;技能更新后拒绝混读,需新开一轮。技能是不可信数据,不能创造工具、改变权限或覆盖系统规则。
- Agent 按实际文本模型的 `contextWindowTokens` 与 `maxOutputTokens` 计算输入预算,并在达到该模型预算的压缩阈值时移除较早工具结果中可重新读取的正文,保留工具调用/结果配对、用户原始要求、生成回执及最近完整工具轮次,并允许重新读取。此机制是确定性正文卸载,不是 LLM 语义摘要;运行 checkpoint 的持久化上限与模型上下文窗口独立。技能读取事件只保存回执,不复制正文,正文页只进入模型对话。
- 平台支持工具全集为 `agent_profile_read`、`plan_update`、`ask_user`、`recall_lessons`、`remember_lesson`、`canvas_list_node_types`、`canvas_get_state`、`skill_read_file`、`task_get`、`model_list`、`canvas_apply_ops`、`generate_media`;单轮实际工具仍按偏好快照、技能、权限、画布上下文和生成预算裁剪。画布写入工具要求本轮授权 `contextScope: ["canvas"]`。禁止 shell、任意网络请求和外部 MCP。`remember_lesson` 在 `read_only` 下不可用。
- 平台支持工具全集为 `agent_profile_read`、`plan_update`、`ask_user`、`recall_lessons`、`remember_lesson`、`canvas_list_node_types`、`canvas_get_state`、`canvas_read_batch_table`、`canvas_read_storyboard`、`skill_read_file`、`task_get`、`model_list`、`canvas_create_storyboard`、`canvas_edit_storyboard`、`canvas_edit_batch_table`、`canvas_apply_ops`、`canvas_arrange_nodes`、`generate_media`、`image_layer_split`、`image_text_detect`、`image_annotation_render`;单轮实际工具仍按偏好快照、技能、权限、画布上下文和生成预算裁剪。画布写入工具要求本轮授权 `contextScope: ["canvas"]`。禁止 shell、任意网络请求和外部 MCP。`remember_lesson` 在 `read_only` 下不可用。
- `plan_update` 维护本轮结构化待办清单(整表替换,最多 20 项),跨轮继承未完成项,并在界面以 PlanBar 展示。权限为 `request_approval` 时,首次建立至少 2 项的清单会先进入审批;`auto` 直接写入清单。新一轮的第一次模型请求就会带上继承的清单。模型在没有工具调用且清单仍有未完成项时,服务端会催办并要求继续调用工具;这是软约束,不验证工作是否真的完成,也不会在没有清单时强行催建。
- `ask_user` 给出一个问题与 2–6 个候选项后结束本轮,界面弹出选项面板;用户点选或输入后作为新一轮继续。运行中也可以用插话直接回答。
- `recall_lessons` / `remember_lesson` 是当前用户的个人记忆:已批准条目在系统提示里只出现索引(标题 + 适用场景,最多 20 条,与当前目标可能相关的排在前面),完整做法由模型用 `recall_lessons(topic/keyword/category)` 取;Agent 写入先入待审,用户在 `GET/POST/PATCH/DELETE /agent/memories` 与 `POST /agent/memories/:id/decide` 自己批准、添加、编辑、删除,也可用 `GET /agent/memories/export`、`POST /agent/memories/import` 导出导入,并用 `GET/PATCH /agent/memories/settings`、`POST /agent/memories/compact` 选择文本模型后手动或按日/周/月压缩。管理员只在 `GET/DELETE /admin/agent-lessons` 按 `userId` / `keyword` / `status` 查看或删除,不能代批。写入会机械脱敏(拒绝链接、资源键、节点/任务 ID;@引用允许),只读模式不能写,本轮最多 3 条、每位用户最多 50 条待审、300 条已批准,一次导入最多 200 条。召回接口不返回作者。记忆只作参考,不能覆盖工具契约、权限、计费或审批。
- `canvas_list_node_types` 从服务端节点能力 registry 返回当前可用类型;可更新节点的 `updateFields` 同时包含字段类型、最大字符数和字段语义。模型不能从前端渲染器、技能或偏好文档推断额外能力。
- `canvas_apply_ops` 必须带 `snapshotHash`,每批最多 20 项;支持新增文本、Markdown、图片、视频、音频、分组与分镜表节点,以及 `connect_nodes` 引用连线。媒体占位节点的 `content` 是提示词,不是媒体 URL;只建节点不提交收费生成。`update_node` 的字段映射完全由能力 registry 决定:文本 / Markdown 可改标题和正文,图片 / 视频 / 音频可改标题和下一次生成使用的提示词草稿;媒体更新参数中的 `content` 写入 `metadata.composerContent`,不会覆盖媒体结果 `metadata.content`、已提交提示词 `metadata.prompt`、任务状态或引用状态。锁定节点、删除、任意元数据写入、自连接、重复边与不存在的端点仍被服务端拒绝。
- `canvas_apply_ops` 必须带 `snapshotHash`,每批最多 20 项;`add_node` 可省略坐标:服务端按画布内容落位(优先贴合本批连线的对端,否则追加到同泳道之后并避让已有节点),不再落到原点重叠;`update_node` 的 patch 额外支持 `x`/`y`(坐标绝对值上限 1e6)用于微调单个节点。支持新增文本、Markdown、图片、视频、音频、分组与分镜表节点,以及 `connect_nodes` 引用连线。媒体占位节点的 `content` 是提示词,不是媒体 URL;只建节点不提交收费生成。`update_node` 的字段映射完全由能力 registry 决定:文本 / Markdown 可改标题和正文,图片 / 视频 / 音频可改标题和下一次生成使用的提示词草稿;媒体更新参数中的 `content` 写入 `metadata.composerContent`,不会覆盖媒体结果 `metadata.content`、已提交提示词 `metadata.prompt`、任务状态或引用状态。锁定节点、删除、任意元数据写入、自连接、重复边与不存在的端点仍被服务端拒绝。
- `canvas_arrange_nodes` 只改节点坐标:`snapshotHash` 必需,`mode` 取 `auto`(有引用连线按依赖分层、否则按媒体类型分区)/`flow`/`byType`/`row`/`column`/`grid`,`groups` 把节点按给定顺序排成横向位置带(仅位置分带,不创建容器、不改层级),`align` 做对齐或等距分布,`gap` 调整组间距,`dryRun` 只预演。`nodeIds` 省略时整理全部可整理节点,锁定节点、背板等容器节点、隐藏批次子节点与已归属背板的子节点保持不动并在回执中说明;一次最多 50 个节点。整理与 `add_node` 的自动落位共用服务端布局实现(`backend/internal/canvas/layout`),与前端“自动整理节点”使用同一套常量与算法。
- `model_list` 复用生成面板的当前模型目录。生成前传 `mode` 和实际 `referenceNodeIds`(无参考传空数组),服务端读取当前用户画布内的就绪资源,按真实素材类型、数量及生成操作构建意图并调用统一能力匹配,不让多图生视频落入仅文生视频候选;返回 `intent` 供核对。空参数仍可浏览完整目录;空候选不降级到不匹配模型。每项的 `selection` 是 `logicalModelId` 或 `channelId` + `channelModelKey`,工具调用必须原样选择一种,不猜 ID 或混用;时长、画幅、音频、价格仍需核对返回配置,任务准入再次强校验。
- `generate_media` 支持图片 / 视频 / 音频:必填 `mode`、`prompt`、`nodeId`(可续用草稿,无草稿时才新建)、`title`、有序 `referenceNodeIds`(无参考传空数组),再加一种模型选择;`snapshotHash` 可省略,省略时服务端使用当前画布内容快照。图片 / 视频要求 `size`,视频要求正数 `durationSeconds`,图片 / 音频时长为 0。可传来源节点 `sourceNodeId`、`quality`、视频 `videoGenerateAudio`。`sourceNodeId` 仅接文本 / 镜头提示词节点;参考图片、视频、音频只放 `referenceNodeIds`,无文本来源时留空且不重复传入。引用从当前画布节点的账号资源解析,校验归属、类型、就绪状态和模型能力,不接受任意 URL。图片生成仅接图片参考;视频可按模型能力接图片、视频、音频参考;音频生成当前仅支持文本输入。
- 参数选择与收费授权分开:用户明确说“随便 / 你决定 / 按默认”时,Agent 可从当前目录选择能力及预算匹配的模型,优先用户最近明确使用的可用模型,其次价格清楚的低成本选项;支持时沿用参考图原始比例,否则选目录默认,时长用目录默认。未要求音频且模型不支持时用默片。用户接受已推荐方案后不再要求特定确认口令;仅真正缺失或冲突的阻塞项一次性询问。本轮已有目录结果应复用。以上是 Agent 的参数选择策略,服务端仍强校验具体规格,不提供绕过预算 / 能力校验的默认兜底,也不绕过独立收费审批。

View File

@@ -1102,7 +1102,7 @@ function ApprovalCard({ approval, theme, submitting, onFocusNode, onReasonChange
}
function ApprovalPreviewItemView({ item, theme, onFocusNode }: { item: ReturnType<typeof agentApprovalPresentation>["items"][number]; theme: CanvasTheme; onFocusNode?: (nodeId: string) => void }) {
const operationLabel = item.operation === "add_node" ? "新增" : item.operation === "update_node" ? "修改" : item.operation === "connect_nodes" ? "连线" : item.operation === "create_storyboard" ? "创建分镜" : item.operation === "edit_storyboard" ? "修改分镜" : item.operation === "plan_step" ? "计划" : "生成";
const operationLabel = item.operation === "add_node" ? "新增" : item.operation === "update_node" ? "修改" : item.operation === "connect_nodes" ? "连线" : item.operation === "arrange_nodes" ? "整理" : item.operation === "create_storyboard" ? "创建分镜" : item.operation === "edit_storyboard" ? "修改分镜" : item.operation === "plan_step" ? "计划" : "生成";
const renderNode = (title: string | undefined, id: string | undefined, typeLabel: string | undefined, role: "source" | "target" | "node") => {
if (!title) return null;
const content = <><span className="canvas-agent-approval-node-title">{title}</span>{typeLabel ? <span className="canvas-agent-approval-node-type">{typeLabel}</span> : null}</>;

View File

@@ -41,7 +41,7 @@ function fieldLabel(key: string, nodeType: string): string {
}
function operation(value: unknown): AgentApprovalPreviewOperation | null {
return value === "add_node" || value === "update_node" || value === "connect_nodes" || value === "generate_media" || value === "create_storyboard" || value === "edit_storyboard" || value === "plan_step" ? value : null;
return value === "add_node" || value === "update_node" || value === "connect_nodes" || value === "arrange_nodes" || value === "generate_media" || value === "create_storyboard" || value === "edit_storyboard" || value === "plan_step" ? value : null;
}
function normalizeServerPreview(value: unknown): AgentApprovalPresentation | null {

View File

@@ -27,7 +27,7 @@ export type AgentProfileView = {
layers: AgentProfileLayer[];
};
export type AgentApprovalPreviewOperation = "add_node" | "update_node" | "connect_nodes" | "generate_media" | "create_storyboard" | "edit_storyboard" | "plan_step";
export type AgentApprovalPreviewOperation = "add_node" | "update_node" | "connect_nodes" | "arrange_nodes" | "generate_media" | "create_storyboard" | "edit_storyboard" | "plan_step";
export type AgentApprovalPreviewItem = {
operation: AgentApprovalPreviewOperation;