Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_layout.go

631 lines
21 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 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 "", cloudAgentFieldError("mode", "invalid_value", "整理模式无效:只能是 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 "", cloudAgentFieldError("align", "invalid_value", "对齐模式无效:只能是 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, cloudAgentFieldError("nodeIds", "invalid_value", 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, "", cloudAgentFieldError("groups", "invalid_value", "分组里没有可整理的节点")
}
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, cloudAgentFieldError("snapshotHash", "required", "整理节点需要先读取画布并传入 snapshotHash")
}
if len(args.Groups) > cloudAgentArrangeMaxGroups {
return nil, cloudAgentFieldError("groups", "item_count", fmt.Sprintf("一次最多分成 %d 组", cloudAgentArrangeMaxGroups))
}
if args.Gap < 0 || args.Gap > cloudAgentArrangeMaxGap {
return nil, cloudAgentFieldError("gap", "invalid_value", 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, cloudAgentFieldError("nodeIds", "invalid_value", "可整理的节点少于两个;单个节点请直接用 update_node 指定坐标")
}
if len(selected) > cloudAgentArrangeMaxNodes {
return nil, cloudAgentFieldError("nodeIds", "item_count", 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 := cloudAgentNodeCapabilityForNode(node); 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
}