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

254 lines
9.1 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 (
"encoding/json"
"reflect"
"yingce/backend/internal/repository"
)
// Persist the delta in the same transaction as the canvas and run checkpoint.
// Clients can project it without fetching the entire canvas for every task.
func cloudAgentCanvasEventRecorder(runID string, state *cloudAgentRuntime) cloudAgentMutationRecorder {
return func(repo *repository.Repository, input cloudAgentMutationInput) error {
if err := cloudAgentMutationRecorderForRun(runID)(repo, input); err != nil {
return err
}
return emitCloudAgentCanvasChange(repo, runID, state, input)
}
}
func cloudAgentObjectChanges(before, after any) []map[string]any {
previous := map[string]map[string]any{}
for _, item := range creationMaps(before) {
previous[stringValue(item["id"])] = item
}
changes := []map[string]any{}
for _, item := range creationMaps(after) {
old := previous[stringValue(item["id"])]
if !reflect.DeepEqual(old, item) {
oldFields, newFields := cloudAgentShrinkChange(old, item)
changes = append(changes, map[string]any{"before": oldFields, "after": newFields})
}
}
return changes
}
// cloudAgentGeometryFields 是"只改位置/尺寸"的字段集合。
var cloudAgentGeometryFields = []string{"position", "width", "height", "zIndex"}
// cloudAgentShrinkChange 把"只动了几何"的节点变更缩成几何增量。
//
// 画布增量在前端是**三方合并**(按字段合并,未出现的字段保持本地值),所以只发发生变化的
// 几何字段既安全又必要:一次整理 50 个节点时,若每个节点都带完整正文(分镜行、Markdown
// 正文可达十几 KB),单条 canvas_updated 事件就会超过 validateCloudAgentRuntime 的
// 单事件 128KiB 上限("Agent runtime event payload is too large"),整轮直接终止。
// 只有"除几何外完全一致"时才缩:带内容/metadata 变化的节点仍发完整 before/after,
// 以免影响前端对生成任务状态的保护逻辑。
func cloudAgentShrinkChange(before, after map[string]any) (map[string]any, map[string]any) {
if before == nil || after == nil {
return before, after
}
for key, value := range after {
if cloudAgentContainsString(cloudAgentGeometryFields, key) {
continue
}
if !reflect.DeepEqual(before[key], value) {
return before, after
}
}
for key, value := range before {
if cloudAgentContainsString(cloudAgentGeometryFields, key) {
continue
}
if _, exists := after[key]; !exists && value != nil {
return before, after
}
}
shrunkBefore := map[string]any{"id": after["id"]}
shrunkAfter := map[string]any{"id": after["id"]}
for _, key := range cloudAgentGeometryFields {
final, hasFinal := after[key]
initial, hasInitial := before[key]
if !hasFinal && !hasInitial {
continue
}
if reflect.DeepEqual(initial, final) {
continue
}
if hasInitial {
shrunkBefore[key] = initial
}
if hasFinal {
shrunkAfter[key] = final
}
}
return shrunkBefore, shrunkAfter
}
// emitCloudAgentCanvasChange 生成与画布写入同事务保存的事件。
// 增量无法完整表达变更或超过事件上限时通知客户端重新读取,不截断真实画布内容。
func emitCloudAgentCanvasChange(repo *repository.Repository, runID string, state *cloudAgentRuntime, input cloudAgentMutationInput) error {
before, err := creationDocument(input.BeforeJSON)
if err != nil {
return err
}
canvas, err := repo.CanvasProjectForUser(input.UserID, input.CanvasID)
if err != nil {
return err
}
after, err := creationDocument(canvas.PayloadJSON)
if err != nil {
return err
}
nodes := cloudAgentObjectChanges(before["nodes"], after["nodes"])
edges := cloudAgentObjectChanges(before["connections"], after["connections"])
if !cloudAgentPatchCoversDocument(before, after) {
// A partial delta must never acknowledge a revision containing omitted edits.
state.event(runID, "canvas_updated", map[string]any{"canvasId": input.CanvasID, "operation": input.Operation, "requiresRefresh": true})
return nil
}
actions := []map[string]any{}
previewByOperationAndNode := map[string]cloudAgentApprovalPreviewItem{}
previewByUniqueNode := map[string]cloudAgentApprovalPreviewItem{}
ambiguousPreviewNode := map[string]bool{}
if inputPreview := input.Preview; inputPreview != nil {
for _, item := range inputPreview.Items {
if item.NodeID == "" {
continue
}
previewByOperationAndNode[item.Operation+"\x00"+item.NodeID] = item
if _, exists := previewByUniqueNode[item.NodeID]; exists {
ambiguousPreviewNode[item.NodeID] = true
} else {
previewByUniqueNode[item.NodeID] = item
}
}
}
findPreview := func(operation, nodeID string) (cloudAgentApprovalPreviewItem, bool) {
if item, ok := previewByOperationAndNode[operation+"\x00"+nodeID]; ok {
return item, true
}
if !ambiguousPreviewNode[nodeID] {
item, ok := previewByUniqueNode[nodeID]
return item, ok
}
return cloudAgentApprovalPreviewItem{}, false
}
for _, change := range nodes {
node := change["after"].(map[string]any)
action := "updated"
if old, _ := change["before"].(map[string]any); old == nil {
action = "created"
}
entry := map[string]any{"action": action, "nodeId": node["id"], "title": node["title"], "nodeType": cloudAgentEventNodeType(node)}
previewOperation := "update_node"
if action == "created" {
previewOperation = "add_node"
}
if preview, ok := findPreview(previewOperation, stringValue(node["id"])); ok {
if preview.NodeTitle != "" {
entry["title"] = preview.NodeTitle
}
if len(preview.Fields) > 0 {
entry["fields"] = preview.Fields
}
if preview.ResultTitle != "" {
entry["resultTitle"] = preview.ResultTitle
}
if preview.Summary != "" {
entry["summary"] = preview.Summary
}
}
actions = append(actions, entry)
}
byID, err := creationObjects(after["nodes"])
if err != nil {
return err
}
for _, change := range edges {
edge := change["after"].(map[string]any)
if node := byID[stringValue(edge["fromNodeId"])]; node != nil {
entry := map[string]any{"action": "referenced", "nodeId": node["id"], "title": node["title"], "nodeType": cloudAgentEventNodeType(node), "targetNodeId": edge["toNodeId"]}
if target := byID[stringValue(edge["toNodeId"])]; target != nil {
entry["targetTitle"], entry["targetNodeType"] = target["title"], cloudAgentEventNodeType(target)
}
if preview, ok := findPreview("connect_nodes", stringValue(edge["fromNodeId"])); ok && preview.TargetNodeTitle != "" {
entry["targetTitle"], entry["targetNodeType"] = preview.TargetNodeTitle, preview.TargetNodeType
if preview.Summary != "" {
entry["summary"] = preview.Summary
}
}
actions = append(actions, entry)
}
}
payload := map[string]any{
"canvasId": input.CanvasID, "operation": input.Operation, "actions": actions,
"canvasPatch": map[string]any{"canvasId": input.CanvasID, "baseRevision": canvas.Revision - 1, "revision": canvas.Revision, "updatedAt": after["updatedAt"], "nodes": nodes, "connections": edges},
}
if input.Preview != nil {
payload["preview"] = input.Preview
payload["text"] = input.Preview.Description
}
if state.CallIndex >= 0 && state.CallIndex < len(state.Calls) {
payload["callId"] = state.Calls[state.CallIndex].ID
}
raw, err := json.Marshal(payload)
if err != nil {
return err
}
if len(raw) > cloudAgentEventPayloadLimit {
// 修改标题或关联素材也可能携带整份分镜正文的 before/after,导致审批事务回滚。
// 大增量沿用已有的快照刷新路径;画布、撤销记录和工具回执仍由原事务完整保存。
delete(payload, "canvasPatch")
payload["requiresRefresh"] = true
raw, err = json.Marshal(payload)
if err != nil {
return err
}
if len(raw) > cloudAgentEventPayloadLimit {
// 摘要本身也超限时不截断字段,退回既有的完整刷新通知。
payload = map[string]any{"canvasId": input.CanvasID, "operation": input.Operation, "requiresRefresh": true}
}
}
state.event(runID, "canvas_updated", payload)
return nil
}
// cloudAgentEventNodeType 给回执用的节点类型:角色卡等变体底层是 text,
// 必须按 workflowKind 解析成能力类型,否则前端会把角色卡报成"文本节点"。
func cloudAgentEventNodeType(node map[string]any) any {
if descriptor, ok := cloudAgentNodeCapabilityForNode(node); ok && descriptor.Type != "" {
return descriptor.Type
}
return node["type"]
}
func cloudAgentPatchCoversDocument(before, after map[string]any) bool {
for _, key := range []string{"nodes", "connections"} {
old, next := creationMaps(before[key]), creationMaps(after[key])
if len(next) < len(old) {
return false
}
// The delta merger updates existing items in place and appends new items.
for i, item := range old {
if item["id"] != next[i]["id"] {
return false
}
}
}
content := func(doc map[string]any) map[string]any {
result := map[string]any{}
for key, value := range doc {
if key != "nodes" && key != "connections" && key != "id" && key != "revision" && key != "createdAt" && key != "updatedAt" && key != "viewport" {
if (key == "title" || key == "projectId") && value == "" {
continue
}
result[key] = value
}
}
return result
}
return reflect.DeepEqual(content(before), content(after))
}