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 }, } // 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 { return nil, BadAuthRequest("画布包含当前 Agent 不支持的节点类型") } 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 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} }