724 lines
24 KiB
Go
724 lines
24 KiB
Go
package app
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"yingce/backend/internal/canvas/capability"
|
||
"yingce/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
|
||
},
|
||
}
|
||
|
||
// cloudAgentNodeHash 是单个节点内容的版本号。分镜/批量表的读写只作用在一个节点上,
|
||
// 用它做并发校验:用户删改其它节点不会让 Agent 对这个节点的修改失效;
|
||
// 这个节点本身被改过(或被删掉)时才拒绝写入。
|
||
func cloudAgentNodeHash(doc map[string]any, nodeID string) string {
|
||
for _, node := range creationMaps(doc["nodes"]) {
|
||
if stringValue(node["id"]) == nodeID {
|
||
return creationHash(node)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// cloudAgentNodeSnapshotMatches 接受两种版本号:旧的整画布哈希(画布完全未变)或
|
||
// 目标节点哈希(只有目标节点未变)。前者兼容已经发出、带整画布哈希的调用。
|
||
func cloudAgentNodeSnapshotMatches(doc map[string]any, nodeID, snapshotHash string) bool {
|
||
if snapshotHash == "" {
|
||
return false
|
||
}
|
||
if snapshotHash == cloudAgentCanvasHash(doc) {
|
||
return true
|
||
}
|
||
node := cloudAgentNodeHash(doc, nodeID)
|
||
return node != "" && node == snapshotHash
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// Generation uses the graph, not canvas presentation or autosave bookkeeping.
|
||
// Keep all node business fields (including unknown metadata) fail-closed, and
|
||
// keep the full canvas hash for mutations/undo and the database CAS.
|
||
func cloudAgentMediaContentHash(doc map[string]any) string {
|
||
content := map[string]any{"connections": doc["connections"]}
|
||
nodes := creationMaps(doc["nodes"])
|
||
projected := make([]map[string]any, 0, len(nodes))
|
||
for _, node := range nodes {
|
||
item := make(map[string]any, len(node))
|
||
for key, value := range node {
|
||
switch key {
|
||
case "position", "width", "height", "createdAt", "updatedAt":
|
||
default:
|
||
item[key] = value
|
||
}
|
||
}
|
||
projected = append(projected, item)
|
||
}
|
||
content["nodes"] = projected
|
||
return cloudAgentCanvasHash(content)
|
||
}
|
||
|
||
const cloudAgentReadPageBytes = 64 << 10
|
||
|
||
// A full related-component read is useful for answering questions such as
|
||
// "what feeds this shot and what does it feed?", but it must remain bounded so
|
||
// a densely connected canvas cannot turn one tool call into an unbounded
|
||
// context dump.
|
||
const cloudAgentRelatedNodeLimit = 256
|
||
|
||
func cloudAgentCanvasState(repo *repository.Repository, userID, canvasID string, doc map[string]any, offset int, ids []string, storyboardOffset int, connectionOffsets ...int) (any, error) {
|
||
return cloudAgentCanvasStateSelected(repo, userID, canvasID, doc, offset, ids, nil, 0, false, storyboardOffset, connectionOffsets...)
|
||
}
|
||
|
||
// cloudAgentCanvasStateWithFocus returns a bounded graph neighborhood around
|
||
// the requested nodes. It is deliberately separate from the legacy paginated
|
||
// reader so existing callers keep their exact pagination semantics.
|
||
func cloudAgentCanvasStateWithFocus(repo *repository.Repository, userID, canvasID string, doc map[string]any, offset int, focusNodeIDs []string, depth, storyboardOffset int, connectionOffsets ...int) (any, error) {
|
||
return cloudAgentCanvasStateSelected(repo, userID, canvasID, doc, offset, nil, focusNodeIDs, depth, false, storyboardOffset, connectionOffsets...)
|
||
}
|
||
|
||
func cloudAgentCanvasStateWithRelated(repo *repository.Repository, userID, canvasID string, doc map[string]any, offset int, focusNodeIDs []string, storyboardOffset int, connectionOffsets ...int) (any, error) {
|
||
return cloudAgentCanvasStateSelected(repo, userID, canvasID, doc, offset, nil, focusNodeIDs, 0, true, storyboardOffset, connectionOffsets...)
|
||
}
|
||
|
||
func cloudAgentCanvasStateSelected(repo *repository.Repository, userID, canvasID string, doc map[string]any, offset int, ids, focusNodeIDs []string, depth int, includeRelated bool, storyboardOffset int, connectionOffsets ...int) (any, error) {
|
||
if offset < 0 || storyboardOffset < 0 || len(ids) > 8 || len(focusNodeIDs) > 8 || (len(ids) > 0 && len(focusNodeIDs) > 0) {
|
||
return nil, BadAuthRequest("画布读取分页参数无效")
|
||
}
|
||
if len(focusNodeIDs) > 0 && !includeRelated && (depth < 0 || depth > 3) {
|
||
return nil, BadAuthRequest("关联子图 depth 必须在 0 到 3 之间")
|
||
}
|
||
if includeRelated && len(focusNodeIDs) == 0 {
|
||
return nil, BadAuthRequest("includeRelated 只能与 focusNodeIds 一起使用")
|
||
}
|
||
all := creationMaps(doc["nodes"])
|
||
connectionOffset := 0
|
||
if len(connectionOffsets) > 0 {
|
||
connectionOffset = connectionOffsets[0]
|
||
}
|
||
if connectionOffset < 0 {
|
||
return nil, BadAuthRequest("连线分页参数无效")
|
||
}
|
||
wanted := map[string]bool{}
|
||
for _, id := range ids {
|
||
wanted[id] = true
|
||
}
|
||
focus := map[string]bool{}
|
||
for _, id := range focusNodeIDs {
|
||
if id == "" || focus[id] {
|
||
return nil, BadAuthRequest("关联子图 focusNodeIds 不能包含空值或重复节点")
|
||
}
|
||
focus[id] = true
|
||
}
|
||
nodeByID := make(map[string]map[string]any, len(all))
|
||
for _, node := range all {
|
||
if id := stringValue(node["id"]); id != "" {
|
||
nodeByID[id] = node
|
||
}
|
||
}
|
||
for id := range wanted {
|
||
if nodeByID[id] == nil {
|
||
return nil, BadAuthRequest("指定节点不在当前画布")
|
||
}
|
||
}
|
||
for id := range focus {
|
||
if nodeByID[id] == nil {
|
||
return nil, BadAuthRequest("关联子图 focusNodeIds 中存在不在当前画布的节点")
|
||
}
|
||
}
|
||
selected := map[string]bool{}
|
||
selectionTruncated := false
|
||
if len(focus) > 0 {
|
||
adjacency := make(map[string][]string, len(all))
|
||
for _, edge := range creationMaps(doc["connections"]) {
|
||
from, to := stringValue(edge["fromNodeId"]), stringValue(edge["toNodeId"])
|
||
if nodeByID[from] == nil || nodeByID[to] == nil {
|
||
continue
|
||
}
|
||
adjacency[from] = append(adjacency[from], to)
|
||
adjacency[to] = append(adjacency[to], from)
|
||
}
|
||
frontier := make([]string, 0, len(focus))
|
||
// Preserve the caller's focus order. This makes bounded results stable
|
||
// and keeps the selected node itself ahead of its neighbours.
|
||
for _, id := range focusNodeIDs {
|
||
selected[id] = true
|
||
frontier = append(frontier, id)
|
||
}
|
||
levels := depth
|
||
if includeRelated {
|
||
levels = len(all) + 1
|
||
}
|
||
for level := 0; level < levels && len(frontier) > 0; level++ {
|
||
next := make([]string, 0)
|
||
for _, id := range frontier {
|
||
for _, neighbor := range adjacency[id] {
|
||
if selected[neighbor] {
|
||
continue
|
||
}
|
||
if includeRelated && len(selected) >= cloudAgentRelatedNodeLimit {
|
||
selectionTruncated = true
|
||
continue
|
||
}
|
||
selected[neighbor] = true
|
||
next = append(next, neighbor)
|
||
}
|
||
}
|
||
frontier = next
|
||
}
|
||
}
|
||
selectedMode := len(focus) > 0
|
||
candidateNodes := make([]map[string]any, 0, len(all))
|
||
for _, node := range all {
|
||
id := stringValue(node["id"])
|
||
switch {
|
||
case selectedMode && selected[id]:
|
||
candidateNodes = append(candidateNodes, node)
|
||
case !selectedMode && len(ids) > 0 && wanted[id]:
|
||
candidateNodes = append(candidateNodes, node)
|
||
case !selectedMode && len(ids) == 0:
|
||
candidateNodes = append(candidateNodes, node)
|
||
}
|
||
}
|
||
limit := 320
|
||
if len(ids) > 0 || len(focus) > 0 {
|
||
limit = 16000
|
||
}
|
||
nodes := []any{}
|
||
included := map[string]bool{}
|
||
next := 0
|
||
pageBytes := 1024
|
||
for candidateIndex, node := range candidateNodes {
|
||
if candidateIndex < offset {
|
||
continue
|
||
}
|
||
id := stringValue(node["id"])
|
||
if len(ids) == 0 && len(focus) == 0 && len(nodes) == 40 {
|
||
next = candidateIndex
|
||
break
|
||
}
|
||
precise := len(ids) > 0 || focus[id]
|
||
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 := cloudAgentNodeCapabilityForNode(node)
|
||
if !known {
|
||
// Read visibility is not permission to mutate or use a node as a media reference.
|
||
item["agentSupported"] = false
|
||
item["agentUnsupportedReason"] = "仅展示基础信息;当前 Agent 不支持操作此类型节点"
|
||
body, _ := json.Marshal(item)
|
||
if pageBytes+len(body) > cloudAgentReadPageBytes-(8<<10) {
|
||
next = candidateIndex
|
||
break
|
||
}
|
||
pageBytes += len(body)
|
||
nodes = append(nodes, item)
|
||
included[id] = true
|
||
continue
|
||
}
|
||
if capability.Variant != nil {
|
||
// 变体节点(如角色卡)底层 type 仍是 text,用 kind 标明真实能力,避免当普通文本处理。
|
||
item["kind"] = capability.Type
|
||
}
|
||
fields := capability.SummaryFields
|
||
if precise {
|
||
fields = capability.DetailFields
|
||
}
|
||
projected, err := cloudAgentProjectNodeFields(node, meta, capability, fields, limit, precise, storyboardOffset)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for key, value := range projected {
|
||
item[key] = value
|
||
}
|
||
if cloudAgentCharacterNode(node) {
|
||
if repo == nil {
|
||
item["character"] = map[string]any{"available": false, "issue": "角色资产读取服务不可用"}
|
||
} else {
|
||
canvas, canvasErr := repo.CanvasProjectForUser(userID, canvasID)
|
||
if canvasErr != nil {
|
||
return nil, canvasErr
|
||
}
|
||
character, characterErr := cloudAgentResolveCharacter(repo, userID, canvas.ProjectID, node)
|
||
if characterErr != nil {
|
||
item["character"] = map[string]any{"available": false, "issue": cloudAgentSafeToolError(characterErr)}
|
||
} else {
|
||
characterView := character.read(precise)
|
||
if precise {
|
||
_, referenceErr := cloudAgentCharacterImageReference(repo, userID, id, character)
|
||
characterView["imageReference"] = map[string]any{"ready": referenceErr == nil}
|
||
if referenceErr != nil {
|
||
characterView["imageReference"].(map[string]any)["issue"] = cloudAgentSafeToolError(referenceErr)
|
||
}
|
||
_, audioErr := cloudAgentCharacterAudioReference(repo, userID, id, character)
|
||
characterView["audioReference"] = map[string]any{"ready": audioErr == nil}
|
||
if audioErr != nil {
|
||
characterView["audioReference"].(map[string]any)["issue"] = cloudAgentSafeToolError(audioErr)
|
||
}
|
||
}
|
||
item["character"] = characterView
|
||
}
|
||
}
|
||
}
|
||
if capability.GenerationMode != "" {
|
||
generation := map[string]any{"taskStatus": "not_submitted"}
|
||
if reason, issue := cloudAgentMediaTargetIssue(node, capability.Type); reason != "" {
|
||
generation["submitBlockedReason"], generation["submitBlockedIssue"] = reason, issue
|
||
}
|
||
taskID := stringValue(meta["taskId"])
|
||
if taskID == "" {
|
||
taskID = stringValue(meta["generationTaskId"])
|
||
}
|
||
if taskID != "" {
|
||
// Do not infer success/failure from stale canvas metadata.
|
||
generation["taskStatus"] = "unavailable"
|
||
task, err := repo.TaskForUser(userID, taskID)
|
||
if err == nil && task.ProjectID == canvasID {
|
||
for key, value := range cloudAgentTaskDiagnostic(repo, task) {
|
||
generation[key] = value
|
||
}
|
||
}
|
||
}
|
||
item["generation"] = generation
|
||
}
|
||
if draftRunID := stringValue(meta["agentDraftRunId"]); draftRunID != "" && stringValue(meta["taskId"]) == "" && stringValue(meta["generationTaskId"]) == "" {
|
||
draft := map[string]any{"submitted": false, "requiresApproval": true, "ownerStatus": "unknown"}
|
||
owner, err := repo.CloudAgent(userID, draftRunID)
|
||
if err == nil && owner.CanvasID == canvasID {
|
||
draft["ownerStatus"] = owner.Status
|
||
draft["cleanupPending"] = owner.CleanupPending
|
||
}
|
||
item["generationDraft"] = draft
|
||
}
|
||
// 角色卡的引用可用性在 character.imageReference/audioReference 中给出。
|
||
if capability.Connection.CanReference && !cloudAgentCharacterNode(node) {
|
||
ref, _, err := cloudAgentReference(repo, userID, node)
|
||
outputReference := map[string]any{"ready": err == nil}
|
||
item["outputReference"] = outputReference
|
||
if err != nil {
|
||
outputReference["issue"] = cloudAgentSafeToolError(err)
|
||
} 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"],
|
||
}
|
||
}
|
||
}
|
||
body, err := json.Marshal(item)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if pageBytes+len(body) > cloudAgentReadPageBytes-(8<<10) {
|
||
if len(nodes) == 0 {
|
||
return nil, BadAuthRequest("节点详情超过单页读取预算,请使用节点对应的结构化分页工具")
|
||
}
|
||
next = candidateIndex
|
||
break
|
||
}
|
||
pageBytes += len(body)
|
||
nodes = append(nodes, item)
|
||
included[id] = true
|
||
}
|
||
edges := []any{}
|
||
nextConnection := 0
|
||
for index, edge := range creationMaps(doc["connections"]) {
|
||
if index < connectionOffset {
|
||
continue
|
||
}
|
||
fromIncluded := included[stringValue(edge["fromNodeId"])]
|
||
toIncluded := included[stringValue(edge["toNodeId"])]
|
||
if (selectedMode && fromIncluded && toIncluded) || (!selectedMode && (fromIncluded || toIncluded)) {
|
||
item := map[string]any{"id": edge["id"], "fromNodeId": edge["fromNodeId"], "toNodeId": edge["toNodeId"]}
|
||
body, _ := json.Marshal(item)
|
||
if pageBytes+len(body) > cloudAgentReadPageBytes {
|
||
nextConnection = index
|
||
break
|
||
}
|
||
pageBytes += len(body)
|
||
edges = append(edges, item)
|
||
}
|
||
}
|
||
result := map[string]any{"snapshotHash": cloudAgentCanvasHash(doc), "mediaSnapshotHash": cloudAgentMediaContentHash(doc), "nodes": nodes, "connections": edges, "totalNodes": len(all), "nextOffset": next, "hasMore": next > 0, "nextConnectionOffset": nextConnection, "hasMoreConnections": nextConnection > 0, "pageByteBudget": cloudAgentReadPageBytes}
|
||
if selectedMode {
|
||
result["totalSelectedNodes"] = len(candidateNodes)
|
||
selection := map[string]any{"mode": "subgraph", "focusNodeIds": focusNodeIDs, "selectedNodes": len(candidateNodes)}
|
||
if includeRelated {
|
||
selection["relationMode"] = "all"
|
||
selection["truncated"] = selectionTruncated
|
||
selection["nodeLimit"] = cloudAgentRelatedNodeLimit
|
||
} else {
|
||
selection["depth"] = depth
|
||
}
|
||
result["selection"] = selection
|
||
}
|
||
return result, 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
|
||
}
|
||
var value any
|
||
var ok bool
|
||
if key == "prompt" && descriptor.GenerationMode != "" {
|
||
value, ok = cloudAgentMediaPrompt(meta)
|
||
} else {
|
||
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 cloudAgentMediaPrompt(meta map[string]any) (string, bool) {
|
||
if generationSpec, ok := meta["generationSpec"].(map[string]any); ok {
|
||
if prompt, ok := generationSpec["prompt"].(string); ok {
|
||
return prompt, true
|
||
}
|
||
}
|
||
if prompt, ok := meta["composerContent"].(string); ok {
|
||
return prompt, true
|
||
}
|
||
if prompt, ok := meta["prompt"].(string); ok {
|
||
return prompt, true
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
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()
|
||
}
|
||
|
||
globalPrompt := strings.TrimSpace(stringValue(table["globalPrompt"]))
|
||
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"]))
|
||
effectivePrompt := globalPrompt
|
||
if effectivePrompt == "" {
|
||
effectivePrompt = prompt
|
||
}
|
||
inputs := cloudAgentBatchInputIDs(row["inputNodeIds"], len(columns))
|
||
if rowEnabled {
|
||
enabled++
|
||
if effectivePrompt == "" {
|
||
missingPrompt++
|
||
}
|
||
minimumInputs := 1
|
||
if operation == "try_on" {
|
||
minimumInputs = 2
|
||
}
|
||
if len(inputs) < minimumInputs {
|
||
missingReferences++
|
||
}
|
||
if effectivePrompt != "" && 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)
|
||
}
|
||
projected := 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,
|
||
},
|
||
}
|
||
if globalPrompt != "" {
|
||
projected["globalPrompt"] = truncateRunes(globalPrompt, textLimit)
|
||
if len([]rune(globalPrompt)) > textLimit {
|
||
projected["globalPromptTruncated"] = true
|
||
}
|
||
}
|
||
return projected
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|