diff --git a/backend/internal/app/cloud_agent.go b/backend/internal/app/cloud_agent.go index e58984daa..3775f5a62 100644 --- a/backend/internal/app/cloud_agent.go +++ b/backend/internal/app/cloud_agent.go @@ -462,10 +462,13 @@ func cloudAgentCanvasSummary(canvas *model.CanvasProject) (string, error) { break } descriptor, known := cloudAgentNodeCapabilityForType(node.Type) - if !known { - return "", BadAuthRequest("画布包含当前 Agent 不支持的节点类型") - } item := map[string]any{"id": truncateRunes(node.ID, 100), "type": truncateRunes(node.Type, 40), "title": truncateRunes(node.Title, 300)} + if !known { + item["agentSupported"] = false + item["agentUnsupportedReason"] = "仅展示基础信息;当前 Agent 不支持操作此类型节点" + nodes = append(nodes, item) + continue + } projected, err := cloudAgentProjectNodeFields(map[string]any{"title": node.Title}, node.Metadata, descriptor, descriptor.SummaryFields, 600, false, 0) if err != nil { return "", err diff --git a/backend/internal/app/cloud_agent_canvas_state.go b/backend/internal/app/cloud_agent_canvas_state.go index 7fba94ff5..0f92e76de 100644 --- a/backend/internal/app/cloud_agent_canvas_state.go +++ b/backend/internal/app/cloud_agent_canvas_state.go @@ -86,7 +86,12 @@ func cloudAgentCanvasState(repo *repository.Repository, userID string, doc map[s } capability, known := cloudAgentNodeCapabilityForType(stringValue(node["type"])) if !known { - return nil, BadAuthRequest("画布包含当前 Agent 不支持的节点类型") + // 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 { diff --git a/backend/internal/app/cloud_agent_contract_test.go b/backend/internal/app/cloud_agent_contract_test.go index 90b5a08d0..bbe459dfb 100644 --- a/backend/internal/app/cloud_agent_contract_test.go +++ b/backend/internal/app/cloud_agent_contract_test.go @@ -1,13 +1,58 @@ package app import ( + "encoding/json" "reflect" "strings" "testing" "infinite-canvas/backend/internal/canvas/capability" + "infinite-canvas/backend/internal/model" ) +func TestCloudAgentMixedCanvasReadsUnsupportedNodesWithoutGrantingCapabilities(t *testing.T) { + nodes := []map[string]any{{"id": "text", "type": "text", "metadata": map[string]any{"content": "readable"}}} + for _, kind := range []string{"ai-art-critique", "config", "drawing", "future-plugin"} { + nodes = append(nodes, map[string]any{"id": kind, "type": kind, "title": "插件节点", "position": map[string]any{"x": 10.0, "y": 20.0}, "metadata": map[string]any{"content": "PRIVATE_SENTINEL", "apiKey": "PRIVATE_SENTINEL"}}) + if _, supported := cloudAgentNodeCapabilityForType(kind); supported { + t.Fatalf("unsupported type gained write capability: %s", kind) + } + if err := validateCreationOps([]CreationCanvasOp{{Type: "add_node", ID: "new", NodeType: kind}}); err == nil { + t.Fatalf("unsupported creation accepted: %s", kind) + } + if _, _, err := cloudAgentReferenceDescriptor(nodes[len(nodes)-1]); err == nil { + t.Fatalf("unsupported media reference accepted: %s", kind) + } + if err := validateCloudAgentConnection(nodes, kind, "text"); err == nil { + t.Fatalf("unsupported connection accepted: %s", kind) + } + } + doc := map[string]any{"nodes": nodes, "connections": []map[string]any{{"id": "edge", "fromNodeId": "drawing", "toNodeId": "text"}}} + raw, _ := json.Marshal(doc) + summary, err := cloudAgentCanvasSummary(&model.CanvasProject{PayloadJSON: string(raw)}) + if err != nil || strings.Contains(summary, "PRIVATE_SENTINEL") || !strings.Contains(summary, `"agentSupported":false`) { + t.Fatalf("mixed summary failed or leaked metadata: %v", err) + } + for _, ids := range [][]string{nil, {"drawing", "config"}} { + view, err := cloudAgentCanvasState(nil, "user", doc, 0, ids, 0) + if err != nil { + t.Fatal(err) + } + result := view.(map[string]any) + encoded, _ := json.Marshal(result) + if strings.Contains(string(encoded), "PRIVATE_SENTINEL") || result["snapshotHash"] != cloudAgentCanvasHash(doc) || len(result["connections"].([]any)) != 1 { + t.Fatal("unsafe projection or lost snapshot/connection") + } + want := len(nodes) + if ids != nil { + want = len(ids) + } + if len(result["nodes"].([]any)) != want { + t.Fatal("nodes silently omitted") + } + } +} + func TestCloudAgentToolsFollowCanvasCapabilityRegistry(t *testing.T) { req := agentTestRequest() req.PermissionMode = "auto" diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 2f9d01854..5c59f5159 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -34,6 +34,7 @@ description: 信封、错误码与 OpenAPI - `GET /agent/runs/:id` 返回运行信息;`POST /agent/runs/:id/cancel` 取消当前运行及活动子任务;`POST /agent/runs/:id/approvals/:approvalId/decision` 接受 `{"decision":"approve"|"reject","reason":"..."}`,审批决定幂等且服务端校验归属。 - `GET /agent/runs/:id/events` 返回 `agent_event` 和首次 / 状态版本变化时的紧凑 `run_snapshot` SSE;无变化时只发送 heartbeat。事件 `Seq` 在运行内递增;支持 `?after=` / `Last-Event-ID`,游标不是运行 ID。`cleanupPending: true` 时即使运行已进入终态也会继续推送,直到后台收尾完成。前端应以快照恢复状态,代理只对这一精确路径关闭缓冲。 - `generation_task_created` 携带 `canvasId`、`nodeId`、`taskId`、`title`、`mode`、`referenceNodeIds`。草稿、提交、画布工具写入,以及媒体成功 / 失败 / 取消的真实节点变更,均在画布写入与运行检查点的同一事务持久化 `canvas_updated`。节点已删除或换任务时不伪造更新;`run_failed` 本身不代表节点媒体任务失败。 +- 未注册 Agent 能力的节点不阻断整张画布读取:摘要和 `canvas_get_state` 仅返回基础信息,并标记 `agentSupported: false` 与原因;不暴露任意 metadata,不授予该类型的创建、修改、连线或媒体引用能力。 - `approval.preview` 和 `canvas_updated.actions` 是服务端根据同一解析、快照与 Capability 规划流程生成的解释信息,不是授权凭证。审批卡片明确列出新增/修改节点、节点类型、修改字段、名称变化和连线两端;执行事件保存同样的可读信息,供历史消息显示和点击定位。实际写入仍重新校验用户归属、`snapshotHash`、Capability、节点锁、预算和审批 `callHash`。 - `canvas_updated` 包含 `canvasId`、`operation`、`callId`、`actions`、可选的 `preview`/`text` 和 `canvasPatch`。`canvasPatch` 为 `{ canvasId, updatedAt, nodes: [{ before, after }], connections: [{ before, after }] }`;新增对象的 `before` 为 `null`,仅包含有变化的节点和连线。当前工具不开放删除。节点 action 保存操作、节点 ID、执行前标题、类型、修改字段和可选的修改后标题;引用 action 同时保存来源与目标节点 ID、标题和类型,不要求前端从内部 ID 猜测对象。`callId` 用于合并同一画布操作的工具完成消息。 - 前端对增量按 40ms 窗口合批,三方合并远端基线、当前 Store 和编辑器,正常完成 / 失败无需全量 GET;保留无关节点编辑、拖动与视口,同字段冲突、删除或新任务绑定不强行覆盖。只有事件缺口、断流、增量冲突或没有增量的事件源需要全量校准,同一画布协调器最多每秒发起一次,单请求在途,期间重复通知合并为一次尾随校准。已识别增量协议后,相邻 `tool_failed` / `run_failed` 不重复触发整图刷新。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 45b677754..5eea03be6 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,11 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 混合节点画布 Agent + +- 未注册 Agent 能力的节点(包括插件、配置、绘图节点)不再阻断整张画布的 Agent 摘要与分页读取;返回基础信息和 `agentSupported: false`,不读取任意 metadata,也不授予创建、修改或媒体引用能力。 +- 已补混合类型摘要、列表与指定节点读取、私有字段隔离、快照/连线保留和未支持类型创建拒绝测试。待真实账号验证混合画布能启动 Agent 并创建受支持的图片节点;不代表新增了插件节点编辑或全画布布局工具。 + ## 画布 Agent 预算与按需技能 - 已取消前后端最多 8 个技能、生成预算 8 / 120 秒和固定模型循环次数约束;生成数与视频累计秒数默认 0 表示不限,积分、权限、生成审批和供应商实际能力仍强校验。