fix(agent): 画布助手 - 避免未支持节点阻断整张画布

This commit is contained in:
ddCat
2026-09-15 09:27:00 +08:00
parent 363d8d699a
commit 71a9a0e7f7
5 changed files with 63 additions and 4 deletions

View File

@@ -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

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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` 不重复触发整图刷新。

View File

@@ -5,6 +5,11 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
# 待测试
## 混合节点画布 Agent
- 未注册 Agent 能力的节点(包括插件、配置、绘图节点)不再阻断整张画布的 Agent 摘要与分页读取;返回基础信息和 `agentSupported: false`,不读取任意 metadata,也不授予创建、修改或媒体引用能力。
- 已补混合类型摘要、列表与指定节点读取、私有字段隔离、快照/连线保留和未支持类型创建拒绝测试。待真实账号验证混合画布能启动 Agent 并创建受支持的图片节点;不代表新增了插件节点编辑或全画布布局工具。
## 画布 Agent 预算与按需技能
- 已取消前后端最多 8 个技能、生成预算 8 / 120 秒和固定模型循环次数约束;生成数与视频累计秒数默认 0 表示不限,积分、权限、生成审批和供应商实际能力仍强校验。