461 lines
15 KiB
Go
461 lines
15 KiB
Go
package canvas
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
var canvasOperationFields = []string{"title", "projectId", "chatSessions", "activeChatId", "starterMode", "appearance", "backgroundMode", "showImageInfo", "previsScenes", "timeline"}
|
|
var canvasOptionalOperationFields = map[string]struct{}{"projectId": {}, "starterMode": {}, "appearance": {}, "timeline": {}}
|
|
|
|
// CanvasOperationsRequest is the durable write envelope shared by the editor,
|
|
// Agent and media callbacks. MutationID is the retry identity: a lost response
|
|
// must be replayed with the same ID and payload.
|
|
type CanvasOperationsRequest struct {
|
|
MutationID string `json:"mutationId"`
|
|
BaseRevision *int64 `json:"baseRevision,omitempty"`
|
|
Operations []CanvasOperation `json:"operations"`
|
|
TrustedServerMutation bool `json:"-"`
|
|
}
|
|
|
|
var (
|
|
ErrCanvasOperationConflict = errors.New("画布操作冲突")
|
|
ErrCanvasObjectMissing = errors.New("画布对象不存在")
|
|
)
|
|
|
|
type CanvasOperation struct {
|
|
Type string `json:"type"`
|
|
ID string `json:"id,omitempty"`
|
|
Node json.RawMessage `json:"node,omitempty"`
|
|
Connection json.RawMessage `json:"connection,omitempty"`
|
|
Field string `json:"field,omitempty"`
|
|
Value json.RawMessage `json:"value,omitempty"`
|
|
Patch map[string]json.RawMessage `json:"patch,omitempty"`
|
|
Before map[string]json.RawMessage `json:"before,omitempty"`
|
|
BeforeValue json.RawMessage `json:"beforeValue,omitempty"`
|
|
Expected json.RawMessage `json:"expected,omitempty"`
|
|
}
|
|
|
|
func CanvasOperationsDigest(req CanvasOperationsRequest) string {
|
|
raw, _ := json.Marshal(req)
|
|
sum := sha256.Sum256(raw)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// DiffCanvasDocuments turns a full document into object-scoped operations.
|
|
// Existing nodes are patched by leaf path so concurrent writers can merge
|
|
// unrelated fields without replacing an entire node.
|
|
func DiffCanvasDocuments(before, after map[string]any) []CanvasOperation {
|
|
operations := make([]CanvasOperation, 0)
|
|
beforeNodes := indexedCanvasObjects(before["nodes"])
|
|
afterNodes := indexedCanvasObjects(after["nodes"])
|
|
for _, node := range orderedCanvasObjects(after["nodes"]) {
|
|
id := canvasString(node["id"])
|
|
previous, exists := beforeNodes[id]
|
|
if !exists {
|
|
operations = append(operations, CanvasOperation{Type: "create_node", Node: jsonRawValue(node)})
|
|
continue
|
|
}
|
|
patch, previousValues := diffCanvasObject(previous, node, "")
|
|
if len(patch) > 0 {
|
|
operations = append(operations, CanvasOperation{Type: "patch_node", ID: id, Patch: patch, Before: previousValues})
|
|
}
|
|
}
|
|
for _, node := range orderedCanvasObjects(before["nodes"]) {
|
|
id := canvasString(node["id"])
|
|
if _, exists := afterNodes[id]; !exists {
|
|
operations = append(operations, CanvasOperation{Type: "remove_node", ID: id, Expected: jsonRawValue(node)})
|
|
}
|
|
}
|
|
beforeEdges := indexedCanvasObjects(before["connections"])
|
|
afterEdges := indexedCanvasObjects(after["connections"])
|
|
for _, edge := range orderedCanvasObjects(after["connections"]) {
|
|
id := canvasString(edge["id"])
|
|
if previous, exists := beforeEdges[id]; !exists {
|
|
operations = append(operations, CanvasOperation{Type: "create_connection", Connection: jsonRawValue(edge)})
|
|
} else if !jsonEqual(previous, edge) {
|
|
operations = append(operations, CanvasOperation{Type: "update_connection", ID: id, Connection: jsonRawValue(edge), Expected: jsonRawValue(previous)})
|
|
}
|
|
}
|
|
for _, edge := range orderedCanvasObjects(before["connections"]) {
|
|
id := canvasString(edge["id"])
|
|
if _, exists := afterEdges[id]; !exists {
|
|
operations = append(operations, CanvasOperation{Type: "remove_connection", ID: id, Expected: jsonRawValue(edge)})
|
|
}
|
|
}
|
|
for _, field := range canvasOperationFields {
|
|
if !jsonEqual(before[field], after[field]) {
|
|
operations = append(operations, CanvasOperation{Type: "set_field", Field: field, Value: jsonRawValue(after[field]), BeforeValue: jsonRawValue(before[field])})
|
|
}
|
|
}
|
|
return operations
|
|
}
|
|
|
|
func orderedCanvasObjects(value any) []map[string]any {
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var values []map[string]any
|
|
if json.Unmarshal(raw, &values) != nil {
|
|
return nil
|
|
}
|
|
return values
|
|
}
|
|
|
|
func indexedCanvasObjects(value any) map[string]map[string]any {
|
|
result := map[string]map[string]any{}
|
|
for _, item := range orderedCanvasObjects(value) {
|
|
if id := canvasString(item["id"]); id != "" {
|
|
result[id] = item
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func canvasString(value any) string {
|
|
text, _ := value.(string)
|
|
return text
|
|
}
|
|
|
|
func diffCanvasObject(before, after map[string]any, prefix string) (map[string]json.RawMessage, map[string]json.RawMessage) {
|
|
patch, previous := map[string]json.RawMessage{}, map[string]json.RawMessage{}
|
|
keys := map[string]bool{}
|
|
for key := range before {
|
|
keys[key] = true
|
|
}
|
|
for key := range after {
|
|
keys[key] = true
|
|
}
|
|
ordered := make([]string, 0, len(keys))
|
|
for key := range keys {
|
|
ordered = append(ordered, key)
|
|
}
|
|
sort.Strings(ordered)
|
|
for _, key := range ordered {
|
|
path := key
|
|
if prefix != "" {
|
|
path = prefix + "." + key
|
|
}
|
|
oldValue, oldExists := before[key]
|
|
newValue, newExists := after[key]
|
|
if oldExists && newExists {
|
|
oldObject, oldOK := oldValue.(map[string]any)
|
|
newObject, newOK := newValue.(map[string]any)
|
|
if oldOK && newOK {
|
|
nested, nestedBefore := diffCanvasObject(oldObject, newObject, path)
|
|
for k, v := range nested {
|
|
patch[k] = v
|
|
}
|
|
for k, v := range nestedBefore {
|
|
previous[k] = v
|
|
}
|
|
continue
|
|
}
|
|
if jsonEqual(oldValue, newValue) {
|
|
continue
|
|
}
|
|
}
|
|
if oldExists {
|
|
previous[path] = jsonRawValue(oldValue)
|
|
} else {
|
|
previous[path] = json.RawMessage("null")
|
|
}
|
|
if newExists {
|
|
patch[path] = jsonRawValue(newValue)
|
|
} else {
|
|
patch[path] = json.RawMessage("null")
|
|
}
|
|
}
|
|
return patch, previous
|
|
}
|
|
|
|
func setCanvasPath(target map[string]any, path string, raw json.RawMessage) error {
|
|
parts := strings.Split(path, ".")
|
|
if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" {
|
|
return errors.New("节点字段路径无效")
|
|
}
|
|
current := target
|
|
for _, part := range parts[:len(parts)-1] {
|
|
next, ok := current[part].(map[string]any)
|
|
if !ok {
|
|
next = map[string]any{}
|
|
current[part] = next
|
|
}
|
|
current = next
|
|
}
|
|
key := parts[len(parts)-1]
|
|
if string(raw) == "null" {
|
|
delete(current, key)
|
|
return nil
|
|
}
|
|
var value any
|
|
if err := json.Unmarshal(raw, &value); err != nil {
|
|
return err
|
|
}
|
|
current[key] = value
|
|
return nil
|
|
}
|
|
|
|
func canvasOperationConflict(format string, args ...any) error {
|
|
return fmt.Errorf("%w: %s", ErrCanvasOperationConflict, fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
func canvasObjectMissing(id string) error {
|
|
return fmt.Errorf("%w: 节点 %q 已删除,迟到更新未应用", ErrCanvasObjectMissing, id)
|
|
}
|
|
|
|
func canvasPathValue(target map[string]any, path string) (any, bool) {
|
|
parts := strings.Split(path, ".")
|
|
if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" {
|
|
return nil, false
|
|
}
|
|
var current any = target
|
|
for _, part := range parts {
|
|
object, ok := current.(map[string]any)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
value, exists := object[part]
|
|
if !exists {
|
|
return nil, false
|
|
}
|
|
current = value
|
|
}
|
|
return current, true
|
|
}
|
|
|
|
func canvasExpectedValueMatches(actual any, exists bool, expected json.RawMessage) bool {
|
|
if len(expected) == 0 {
|
|
return true
|
|
}
|
|
if string(expected) == "null" {
|
|
return !exists || actual == nil
|
|
}
|
|
if !exists {
|
|
return false
|
|
}
|
|
var expectedValue any
|
|
if json.Unmarshal(expected, &expectedValue) != nil {
|
|
return false
|
|
}
|
|
return jsonEqual(actual, expectedValue)
|
|
}
|
|
|
|
func canvasObjectExpectedMatches(actual map[string]any, expected json.RawMessage) bool {
|
|
if len(expected) == 0 {
|
|
return true
|
|
}
|
|
var expectedValue map[string]any
|
|
if json.Unmarshal(expected, &expectedValue) != nil {
|
|
return false
|
|
}
|
|
return jsonEqual(actual, expectedValue)
|
|
}
|
|
|
|
func applyCanvasOperations(payload map[string]json.RawMessage, operations []CanvasOperation) error {
|
|
nodes := orderedCanvasObjects(rawJSON(payload["nodes"]))
|
|
connections := orderedCanvasObjects(rawJSON(payload["connections"]))
|
|
for _, op := range operations {
|
|
switch op.Type {
|
|
case "create_node":
|
|
var node map[string]any
|
|
if err := json.Unmarshal(op.Node, &node); err != nil || canvasString(node["id"]) == "" {
|
|
return fmt.Errorf("节点操作缺少有效节点")
|
|
}
|
|
id := canvasString(node["id"])
|
|
if indexOfCanvasObject(nodes, id) >= 0 {
|
|
return canvasOperationConflict("节点 %q 已存在,显式新增不能覆盖已有节点", id)
|
|
}
|
|
nodes = append(nodes, node)
|
|
case "patch_node":
|
|
if strings.TrimSpace(op.ID) == "" {
|
|
return fmt.Errorf("节点补丁缺少 ID")
|
|
}
|
|
if len(op.Patch) == 0 || len(op.Before) == 0 {
|
|
return fmt.Errorf("节点补丁缺少字段基线")
|
|
}
|
|
index := indexOfCanvasObject(nodes, op.ID)
|
|
if index < 0 {
|
|
return canvasObjectMissing(op.ID)
|
|
}
|
|
for path, expected := range op.Before {
|
|
if path == "id" {
|
|
return fmt.Errorf("节点 ID 不可修改")
|
|
}
|
|
actual, exists := canvasPathValue(nodes[index], path)
|
|
if !canvasExpectedValueMatches(actual, exists, expected) {
|
|
return canvasOperationConflict("节点 %q 的字段 %q 已被其他写入修改", op.ID, path)
|
|
}
|
|
}
|
|
keys := make([]string, 0, len(op.Patch))
|
|
for key := range op.Patch {
|
|
if key == "id" {
|
|
return fmt.Errorf("节点 ID 不可修改")
|
|
}
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, path := range keys {
|
|
if err := setCanvasPath(nodes[index], path, op.Patch[path]); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
case "remove_node":
|
|
if strings.TrimSpace(op.ID) == "" {
|
|
return fmt.Errorf("删除节点缺少 ID")
|
|
}
|
|
if len(op.Expected) == 0 {
|
|
return fmt.Errorf("删除节点缺少对象基线")
|
|
}
|
|
index := indexOfCanvasObject(nodes, op.ID)
|
|
if index < 0 {
|
|
continue
|
|
}
|
|
if !canvasObjectExpectedMatches(nodes[index], op.Expected) {
|
|
return canvasOperationConflict("节点 %q 已被其他写入修改,拒绝删除", op.ID)
|
|
}
|
|
nodes = append(nodes[:index], nodes[index+1:]...)
|
|
next := connections[:0]
|
|
for _, edge := range connections {
|
|
if canvasString(edge["fromNodeId"]) != op.ID && canvasString(edge["toNodeId"]) != op.ID {
|
|
next = append(next, edge)
|
|
}
|
|
}
|
|
connections = next
|
|
case "create_connection":
|
|
var edge map[string]any
|
|
if err := json.Unmarshal(op.Connection, &edge); err != nil || canvasString(edge["id"]) == "" {
|
|
return fmt.Errorf("连线操作缺少有效连线")
|
|
}
|
|
fromID := canvasString(edge["fromNodeId"])
|
|
toID := canvasString(edge["toNodeId"])
|
|
if fromID == "" || toID == "" || indexOfCanvasObject(nodes, fromID) < 0 || indexOfCanvasObject(nodes, toID) < 0 {
|
|
return fmt.Errorf("连线端点节点不存在")
|
|
}
|
|
id := canvasString(edge["id"])
|
|
if indexOfCanvasObject(connections, id) >= 0 {
|
|
return canvasOperationConflict("连线 %q 已存在,显式新增不能覆盖已有连线", id)
|
|
}
|
|
connections = append(connections, edge)
|
|
case "update_connection":
|
|
var edge map[string]any
|
|
if err := json.Unmarshal(op.Connection, &edge); err != nil || canvasString(edge["id"]) == "" {
|
|
return fmt.Errorf("连线更新操作缺少有效连线")
|
|
}
|
|
if strings.TrimSpace(op.ID) == "" || op.ID != canvasString(edge["id"]) {
|
|
return fmt.Errorf("连线操作 ID 与对象 ID 不一致")
|
|
}
|
|
if len(op.Expected) == 0 {
|
|
return fmt.Errorf("连线更新缺少对象基线")
|
|
}
|
|
fromID := canvasString(edge["fromNodeId"])
|
|
toID := canvasString(edge["toNodeId"])
|
|
if fromID == "" || toID == "" || indexOfCanvasObject(nodes, fromID) < 0 || indexOfCanvasObject(nodes, toID) < 0 {
|
|
return fmt.Errorf("连线端点节点不存在")
|
|
}
|
|
index := indexOfCanvasObject(connections, op.ID)
|
|
if index < 0 {
|
|
return canvasObjectMissing("连线 " + op.ID)
|
|
}
|
|
if !canvasObjectExpectedMatches(connections[index], op.Expected) {
|
|
return canvasOperationConflict("连线 %q 已被其他写入修改,拒绝覆盖", op.ID)
|
|
}
|
|
connections[index] = edge
|
|
case "remove_connection":
|
|
if strings.TrimSpace(op.ID) == "" {
|
|
return fmt.Errorf("删除连线缺少 ID")
|
|
}
|
|
if len(op.Expected) == 0 {
|
|
return fmt.Errorf("删除连线缺少对象基线")
|
|
}
|
|
index := indexOfCanvasObject(connections, op.ID)
|
|
if index < 0 {
|
|
continue
|
|
}
|
|
if !canvasObjectExpectedMatches(connections[index], op.Expected) {
|
|
return canvasOperationConflict("连线 %q 已被其他写入修改,拒绝删除", op.ID)
|
|
}
|
|
connections = append(connections[:index], connections[index+1:]...)
|
|
case "set_field":
|
|
if !allowedCanvasOperationField(op.Field) {
|
|
return fmt.Errorf("不支持的画布字段操作: 字段 %q", op.Field)
|
|
}
|
|
// Older clients serialized an undefined optional field by omitting the
|
|
// property entirely. Treat that wire form as JSON null so the original
|
|
// mutation ID and payload can be replayed safely after an upgrade. The
|
|
// expected-value check below still rejects the write when the server
|
|
// field is present, so this compatibility path cannot overwrite a value.
|
|
value := op.Value
|
|
if len(value) == 0 {
|
|
if _, optional := canvasOptionalOperationFields[op.Field]; !optional {
|
|
return fmt.Errorf("画布字段操作缺少 value: 字段 %q", op.Field)
|
|
}
|
|
value = json.RawMessage("null")
|
|
}
|
|
beforeValue := op.BeforeValue
|
|
if len(beforeValue) == 0 {
|
|
if _, optional := canvasOptionalOperationFields[op.Field]; !optional {
|
|
return fmt.Errorf("画布字段操作缺少 beforeValue: 字段 %q", op.Field)
|
|
}
|
|
beforeValue = json.RawMessage("null")
|
|
}
|
|
if !canvasExpectedValueMatches(rawJSON(payload[op.Field]), payload[op.Field] != nil, beforeValue) {
|
|
return canvasOperationConflict("画布字段 %q 已被其他写入修改", op.Field)
|
|
}
|
|
if string(value) == "null" {
|
|
delete(payload, op.Field)
|
|
} else {
|
|
payload[op.Field] = append(json.RawMessage(nil), value...)
|
|
}
|
|
default:
|
|
return fmt.Errorf("不支持的画布操作")
|
|
}
|
|
}
|
|
payload["nodes"], _ = json.Marshal(nodes)
|
|
payload["connections"], _ = json.Marshal(connections)
|
|
return nil
|
|
}
|
|
|
|
func rawJSON(raw json.RawMessage) any {
|
|
if len(raw) == 0 {
|
|
return []any{}
|
|
}
|
|
var value any
|
|
if json.Unmarshal(raw, &value) != nil {
|
|
return []any{}
|
|
}
|
|
return value
|
|
}
|
|
|
|
func jsonRawValue(value any) json.RawMessage {
|
|
raw, _ := json.Marshal(value)
|
|
return raw
|
|
}
|
|
|
|
func jsonEqual(left, right any) bool { return reflect.DeepEqual(left, right) }
|
|
|
|
func indexOfCanvasObject(items []map[string]any, id string) int {
|
|
for i, item := range items {
|
|
if canvasString(item["id"]) == id {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func allowedCanvasOperationField(field string) bool {
|
|
for _, allowed := range canvasOperationFields {
|
|
if field == allowed {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|