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

441 lines
14 KiB
Go

package app
import (
"fmt"
"strings"
"infinite-canvas/backend/internal/canvas/capability"
"infinite-canvas/backend/internal/repository"
)
type cloudAgentStructuredProjector func(value any, offset int, precise bool) (any, error)
var cloudAgentStructuredProjectors = map[string]cloudAgentStructuredProjector{
"storyboard": func(value any, offset int, precise bool) (any, error) {
storyboard, ok := value.(map[string]any)
if !ok {
return nil, nil
}
return cloudAgentStoryboardState(storyboard, offset, precise), nil
},
"batch_table": func(value any, offset int, precise bool) (any, error) {
table, ok := value.(map[string]any)
if !ok {
return nil, nil
}
return cloudAgentBatchTableState(table, offset, precise), nil
},
}
// Viewport autosaves must not invalidate approved content; node edits still do.
func cloudAgentCanvasHash(doc map[string]any) string {
content := make(map[string]any, len(doc))
for key, value := range doc {
if key != "viewport" && key != "updatedAt" {
content[key] = value
}
}
return creationHash(content)
}
func cloudAgentCanvasState(repo *repository.Repository, userID string, doc map[string]any, offset int, ids []string, storyboardOffset int) (any, error) {
if offset < 0 || storyboardOffset < 0 || len(ids) > 8 {
return nil, BadAuthRequest("画布读取分页参数无效")
}
all := creationMaps(doc["nodes"])
wanted := map[string]bool{}
for _, id := range ids {
wanted[id] = true
}
limit := 2000
if len(ids) > 0 {
limit = 16000
}
nodes := []any{}
included := map[string]bool{}
next := 0
for index, node := range all {
id := stringValue(node["id"])
if len(ids) > 0 {
if !wanted[id] {
continue
}
} else {
if index < offset {
continue
}
if len(nodes) == 40 {
next = index
break
}
}
meta, _ := node["metadata"].(map[string]any)
item := map[string]any{"id": id, "type": stringValue(node["type"])}
if title, ok := node["title"].(string); ok {
item["title"] = truncateRunes(title, 300)
}
if position, ok := node["position"].(map[string]any); ok {
safePosition := map[string]any{}
for _, axis := range []string{"x", "y"} {
if value, ok := cloudAgentSafeNumber(position[axis]); ok {
safePosition[axis] = value
}
}
item["position"] = safePosition
}
for _, dimension := range []string{"width", "height"} {
if value, ok := cloudAgentSafeNumber(node[dimension]); ok {
item[dimension] = value
}
}
if status, ok := meta["status"].(string); ok {
item["status"] = truncateRunes(status, 40)
}
capability, known := cloudAgentNodeCapabilityForType(stringValue(node["type"]))
if !known {
// Read visibility is not permission to mutate or use a node as a media reference.
item["agentSupported"] = false
item["agentUnsupportedReason"] = "仅展示基础信息;当前 Agent 不支持操作此类型节点"
nodes = append(nodes, item)
included[id] = true
continue
}
fields := capability.SummaryFields
if len(ids) > 0 {
fields = capability.DetailFields
}
projected, err := cloudAgentProjectNodeFields(node, meta, capability, fields, limit, len(ids) > 0, storyboardOffset)
if err != nil {
return nil, err
}
for key, value := range projected {
item[key] = value
}
if draftRunID := stringValue(meta["agentDraftRunId"]); draftRunID != "" && stringValue(meta["taskId"]) == "" {
draft := map[string]any{"submitted": false, "requiresApproval": true, "ownerStatus": "unknown"}
owner, err := repo.CloudAgent(userID, draftRunID)
if err == nil && owner.CanvasID != "" {
draft["ownerStatus"] = owner.Status
draft["cleanupPending"] = owner.CleanupPending
}
item["generationDraft"] = draft
}
if capability.Connection.CanReference {
ref, _, err := cloudAgentReference(repo, userID, node)
item["referenceReady"] = err == nil
if err != nil {
item["referenceIssue"] = err.Error()
} else {
// Provider references contain a storage key for task submission.
// The model only needs the verified public characteristics; never
// forward the provider payload or storage locator into the read tool.
item["asset"] = map[string]any{
"mimeType": ref["mimeType"], "bytes": ref["bytes"],
"width": ref["width"], "height": ref["height"],
"durationMs": ref["durationMs"], "inputKind": ref["inputKind"],
}
}
}
nodes = append(nodes, item)
included[id] = true
}
if len(ids) > 0 {
for _, id := range ids {
if !included[id] {
return nil, BadAuthRequest("指定节点不在当前画布")
}
}
}
edges := []any{}
for _, edge := range creationMaps(doc["connections"]) {
if included[stringValue(edge["fromNodeId"])] || included[stringValue(edge["toNodeId"])] {
edges = append(edges, map[string]any{"id": edge["id"], "fromNodeId": edge["fromNodeId"], "toNodeId": edge["toNodeId"]})
}
}
return map[string]any{"snapshotHash": cloudAgentCanvasHash(doc), "nodes": nodes, "connections": edges, "totalNodes": len(all), "nextOffset": next, "hasMore": next > 0}, nil
}
func cloudAgentSafeNumber(value any) (any, bool) {
switch number := value.(type) {
case float64, float32, int, int64:
return number, true
default:
return nil, false
}
}
// cloudAgentProjectNodeFields is the single projection path for both the
// initial run summary and canvas_get_state. Capability descriptors decide which
// fields exist; this function decides how those fields are safely represented.
// It deliberately never returns arbitrary metadata, URLs, storage keys or
// media payloads.
func cloudAgentProjectNodeFields(node, meta map[string]any, descriptor capability.Descriptor, fields []string, textLimit int, precise bool, structuredOffset int) (map[string]any, error) {
projected := map[string]any{}
for _, key := range fields {
if descriptor.ProjectionKind != "" && key == descriptor.ProjectionField {
projector, registered := cloudAgentStructuredProjectors[descriptor.ProjectionKind]
if !registered {
return nil, BadAuthRequest(fmt.Sprintf("节点 %s 的结构化读取能力未注册", descriptor.Label))
}
value, ok := cloudAgentProjectionValue(node, meta, descriptor.ProjectionField)
if !ok {
continue
}
structured, err := projector(value, structuredOffset, precise)
if err != nil {
return nil, BadAuthRequest(fmt.Sprintf("节点 %s 的结构化数据无法读取", descriptor.Label))
}
if structured != nil {
projected[key] = structured
}
continue
}
value, ok := node[key]
if !ok {
value, ok = meta[key]
}
if !ok || (key == "content" && descriptor.GenerationMode != "") {
continue
}
if safe, truncated := cloudAgentSafeProjection(value, textLimit); safe != nil {
projected[key] = safe
if truncated {
projected[key+"Truncated"] = true
}
}
}
return projected, nil
}
func cloudAgentProjectionValue(node, meta map[string]any, path string) (any, bool) {
parts := strings.Split(path, ".")
for _, root := range []map[string]any{node, meta} {
var current any = root
found := true
for _, part := range parts {
object, ok := current.(map[string]any)
if !ok {
found = false
break
}
current, ok = object[part]
if !ok {
found = false
break
}
}
if found {
return current, true
}
}
return nil, false
}
func cloudAgentSafeProjection(value any, textLimit int) (any, bool) {
switch typed := value.(type) {
case string:
text := truncateRunes(typed, textLimit)
return text, len([]rune(typed)) > textLimit
case float64, float32, int, int64, bool:
return typed, false
case []string:
items := make([]string, 0, len(typed))
for _, item := range typed {
items = append(items, truncateRunes(item, min(textLimit, 200)))
}
return items, false
case []any:
items := make([]any, 0, min(len(typed), 32))
for _, item := range typed[:min(len(typed), 32)] {
safe, _ := cloudAgentSafeProjection(item, min(textLimit, 200))
if safe != nil {
items = append(items, safe)
}
}
return items, len(typed) > len(items)
default:
return nil, false
}
}
// Summaries locate a shot; a precise node read returns one full row at a time.
// Only narrative fields and canvas IDs are exposed, never arbitrary metadata.
func cloudAgentStoryboardState(storyboard map[string]any, offset int, precise bool) map[string]any {
all := creationMaps(storyboard["rows"])
count, textLimit := 20, 200
if precise {
count, textLimit = 1, 16000
}
rows := []any{}
next := 0
for i, row := range all {
if i < offset {
continue
}
if len(rows) == count {
next = i
break
}
item := map[string]any{}
fields := []string{"id", "shotNumber", "durationSeconds", "plotDescription", "imageNodeId", "videoNodeId"}
if precise {
fields = append(fields, "videoMotionPrompt", "imageGenerationPrompt", "dialogue", "narrativeIntent", "viewerPOV", "performanceBlocking", "shotSize", "emotion", "lightingAndAtmosphere", "audioEffects", "camera", "motion", "timeBeats", "mustHave", "optionalDetails", "continuityOut", "negativePrompt")
}
budget := 24000
for _, key := range fields {
switch value := row[key].(type) {
case string:
text := truncateRunes(value, min(textLimit, budget))
budget -= len([]rune(text))
item[key] = text
if text != value {
item[key+"Truncated"] = true
}
case float64:
item[key] = value
}
}
for collection, keys := range map[string][]string{
"assetBindings": {"nodeId", "role", "priority"},
"characters": {"characterName", "characterAssetId", "characterVersionId", "characterImageNodeId"},
} {
values := []any{}
entries := creationMaps(row[collection])
for _, entry := range entries[:min(len(entries), 16)] {
value := map[string]any{}
for _, key := range keys {
if text, ok := entry[key].(string); ok {
value[key] = truncateRunes(text, 200)
} else if number, ok := entry[key].(float64); ok {
value[key] = number
}
}
values = append(values, value)
}
item[collection] = values
item[collection+"Truncated"] = len(entries) > 16
}
rows = append(rows, item)
}
return map[string]any{"rows": rows, "totalRows": len(all), "nextOffset": next, "hasMore": next > 0}
}
// Batch-table projection exposes only the fields rendered by the component.
// Result URLs, task IDs, storage keys and arbitrary metadata remain private.
func cloudAgentBatchTableState(table map[string]any, offset int, precise bool) map[string]any {
operation := stringValue(table["operation"])
if operation != "creative" {
operation = "try_on"
}
concurrency := 10
if value, ok := cloudAgentInteger(table["concurrency"]); ok && (value == 1 || value == 5 || value == 10) {
concurrency = value
}
columns := []any{}
for index, column := range creationMaps(table["referenceColumns"])[:min(len(creationMaps(table["referenceColumns"])), 6)] {
id, label := truncateRunes(stringValue(column["id"]), 120), truncateRunes(stringValue(column["label"]), 120)
if id != "" && label != "" {
columns = append(columns, map[string]any{"id": id, "label": label, "mentionToken": fmt.Sprintf("@参考图%d", index+1)})
}
}
if len(columns) == 0 {
columns = defaultCloudAgentBatchReferenceColumns()
}
all := creationMaps(table["rows"])
count, textLimit := 20, 240
if precise {
count, textLimit = 20, 16000
}
rows := []any{}
next := 0
ready, enabled, missingPrompt, missingReferences, outputLinked := 0, 0, 0, 0, 0
for _, row := range all {
rowEnabled, _ := row["enabled"].(bool)
prompt := strings.TrimSpace(stringValue(row["prompt"]))
inputs := cloudAgentBatchInputIDs(row["inputNodeIds"], len(columns))
if rowEnabled {
enabled++
if prompt == "" {
missingPrompt++
}
minimumInputs := 1
if operation == "try_on" {
minimumInputs = 2
}
if len(inputs) < minimumInputs {
missingReferences++
}
if prompt != "" && len(inputs) >= minimumInputs {
ready++
}
}
if stringValue(row["outputNodeId"]) != "" {
outputLinked++
}
}
for index, row := range all {
if index < offset {
continue
}
if len(rows) == count {
next = index
break
}
item := map[string]any{
"id": truncateRunes(stringValue(row["id"]), 120),
"enabled": row["enabled"] == true,
"inputNodeIds": cloudAgentBatchInputIDs(row["inputNodeIds"], len(columns)),
}
prompt := stringValue(row["prompt"])
item["prompt"] = truncateRunes(prompt, textLimit)
if len([]rune(prompt)) > textLimit {
item["promptTruncated"] = true
}
if outputNodeID := truncateRunes(stringValue(row["outputNodeId"]), 120); outputNodeID != "" {
item["outputNodeId"] = outputNodeID
}
rows = append(rows, item)
}
return map[string]any{
"operation": operation, "concurrency": concurrency, "referenceColumns": columns,
"rows": rows, "totalRows": len(all), "nextOffset": next, "hasMore": next > 0,
"generationPreview": map[string]any{
"enabledRows": enabled, "readyRows": ready, "missingPromptRows": missingPrompt,
"missingReferenceRows": missingReferences, "outputLinkedRows": outputLinked,
},
}
}
func cloudAgentBatchInputIDs(value any, limit int) []any {
if limit <= 0 || limit > 6 {
limit = 6
}
items, _ := value.([]any)
out := make([]any, 0, min(len(items), limit))
seen := map[string]bool{}
for _, item := range items {
id := truncateRunes(stringValue(item), 120)
if id == "" || seen[id] || len(out) == limit {
continue
}
seen[id] = true
out = append(out, id)
}
return out
}
func cloudAgentInteger(value any) (int, bool) {
switch number := value.(type) {
case int:
return number, true
case int64:
return int(number), int64(int(number)) == number
case float64:
integer := int(number)
return integer, float64(integer) == number
default:
return 0, false
}
}