From 8ee7eb1f0a93a30af3fe388864f1001ae97bf088 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 23 Jun 2026 15:09:50 +0800 Subject: [PATCH] fix: keep annotation edit target in canvas agent context --- components/canvas/CanvasChatPanel.tsx | 47 +++++++++- .../canvas/chat-panel/canvas-agent-targets.ts | 59 ++++++++++++ tests/routes/canvas-chat-helpers.test.ts | 92 +++++++++++++++++++ 3 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 components/canvas/chat-panel/canvas-agent-targets.ts diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index c80563da0..a4e9e0018 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -132,6 +132,10 @@ import { shouldRetryCanvasViewOperationAfterRefresh, shouldSkipCanvasMediaPayloadForHandledItems, } from "@/components/canvas/chat-panel/canvas-agent-stream"; +import { + mergePendingCanvasAgentTargetNodeIds, + resolvePendingCanvasAgentTargetNode, +} from "@/components/canvas/chat-panel/canvas-agent-targets"; import { type DetailArchive, type DetailBriefForm, @@ -1252,6 +1256,10 @@ export function CanvasChatPanel({ const handledCanvasMediaItemIdsRef = useRef>(new Set()); const pendingCanvasViewOperationRef = useRef(null); + const pendingCanvasAgentAnnotationEditTargetRef = useRef<{ + nodeId: string; + requestedAt: number; + } | null>(null); const lastWorkflowAutofillSignatureRef = useRef(null); const lastWorkflowAutofillUrlsRef = useRef([]); const pendingLaunchRef = useRef(null); @@ -3626,6 +3634,13 @@ export function CanvasChatPanel({ annotationCount?: number; }> ).detail; + const nodeId = String(detail?.nodeId || "").trim(); + if (nodeId) { + pendingCanvasAgentAnnotationEditTargetRef.current = { + nodeId, + requestedAt: Date.now(), + }; + } const title = String(detail?.title || "").trim(); const prompt = title ? `按「${title}」这张图上的批注修改` @@ -4145,6 +4160,7 @@ export function CanvasChatPanel({ hiddenPromptRef.current = null; autoContextRef.current = null; contextPayloadRef.current = null; + pendingCanvasAgentAnnotationEditTargetRef.current = null; return { body: { id: rawId, @@ -5638,7 +5654,25 @@ export function CanvasChatPanel({ }, [nodes, selectedImageNodes]); const buildContextPayload = useCallback(() => { - const selectedNodeIdSet = new Set(selectedNodeIds); + const pendingAnnotationTargetNode = resolvePendingCanvasAgentTargetNode({ + nodes, + pendingTarget: pendingCanvasAgentAnnotationEditTargetRef.current, + isEligibleNode: (node) => + node.type === "image" && + isValidChatHttpUrl(String((node.data as any)?.src || "").trim()), + }); + if ( + pendingCanvasAgentAnnotationEditTargetRef.current && + !pendingAnnotationTargetNode + ) { + pendingCanvasAgentAnnotationEditTargetRef.current = null; + } + const effectiveSelectedNodeIds = mergePendingCanvasAgentTargetNodeIds({ + selectedNodeIds, + pendingTargetNodeId: pendingAnnotationTargetNode?.id, + maxItems: 20, + }); + const selectedNodeIdSet = new Set(effectiveSelectedNodeIds); const imageNodes = nodes.filter( (node) => node.type === "image" && @@ -5647,8 +5681,15 @@ export function CanvasChatPanel({ const effectiveComposerMode = activeComposerModeRef.current || activeComposerMode; const shouldIsolateDirectModeContext = effectiveComposerMode !== "agent"; - const selectedImageNodesForContext = selectedImageNodes.filter((node) => - isValidChatHttpUrl(String((node.data as any)?.src || "").trim()) + const selectedImageNodesForContext = Array.from( + new Map( + [ + ...selectedImageNodes.filter((node) => + isValidChatHttpUrl(String((node.data as any)?.src || "").trim()) + ), + ...(pendingAnnotationTargetNode ? [pendingAnnotationTargetNode] : []), + ].map((node) => [node.id, node]) + ).values() ); if (shouldIsolateDirectModeContext && selectedImageNodesForContext.length === 0) { diff --git a/components/canvas/chat-panel/canvas-agent-targets.ts b/components/canvas/chat-panel/canvas-agent-targets.ts new file mode 100644 index 000000000..931eac194 --- /dev/null +++ b/components/canvas/chat-panel/canvas-agent-targets.ts @@ -0,0 +1,59 @@ +import type { CanvasNode } from "@/types/canvas"; + +export type PendingCanvasAgentTarget = { + nodeId?: string | null; + requestedAt?: number | null; +}; + +const DEFAULT_PENDING_TARGET_MAX_AGE_MS = 10 * 60 * 1000; + +function normalizeNodeId(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +export function resolvePendingCanvasAgentTargetNode(params: { + nodes: CanvasNode[]; + pendingTarget?: PendingCanvasAgentTarget | null; + now?: number; + maxAgeMs?: number; + isEligibleNode?: (node: CanvasNode) => boolean; +}) { + const nodeId = normalizeNodeId(params.pendingTarget?.nodeId); + if (!nodeId) return null; + + const requestedAt = Number(params.pendingTarget?.requestedAt || 0); + const now = Number(params.now ?? Date.now()); + const maxAgeMs = Math.max( + 0, + Number(params.maxAgeMs ?? DEFAULT_PENDING_TARGET_MAX_AGE_MS) + ); + if ( + Number.isFinite(requestedAt) && + requestedAt > 0 && + Number.isFinite(now) && + now - requestedAt > maxAgeMs + ) { + return null; + } + + const node = params.nodes.find((item) => item.id === nodeId) || null; + if (!node) return null; + if (params.isEligibleNode && !params.isEligibleNode(node)) return null; + return node; +} + +export function mergePendingCanvasAgentTargetNodeIds(params: { + selectedNodeIds: string[]; + pendingTargetNodeId?: string | null; + maxItems?: number; +}) { + const maxItems = Math.max(1, Number(params.maxItems || 20)); + return Array.from( + new Set( + [ + ...params.selectedNodeIds, + normalizeNodeId(params.pendingTargetNodeId), + ].filter(Boolean) + ) + ).slice(0, maxItems); +} diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index ed8623cf3..3567dc613 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -12,6 +12,10 @@ import { shouldRetryCanvasViewOperationAfterRefresh, shouldSkipCanvasMediaPayloadForHandledItems, } from "@/components/canvas/chat-panel/canvas-agent-stream"; +import { + mergePendingCanvasAgentTargetNodeIds, + resolvePendingCanvasAgentTargetNode, +} from "@/components/canvas/chat-panel/canvas-agent-targets"; import { writeCanvasMediaChunk, writeCanvasOperationChunk, @@ -2237,6 +2241,94 @@ test.describe("canvas chat pure helpers", () => { ]); }); + test("keeps pending annotation edit targets eligible for the next canvas context", () => { + const nodes = [ + { + id: "image-annotated", + type: "image", + position: { x: 10, y: 20 }, + size: { width: 300, height: 240 }, + data: { + src: "https://cdn.example.com/annotated.png", + metadata: { + annotations: [ + { + id: "annotation-1", + type: "text-note", + text: "背景换浅灰", + }, + ], + }, + }, + }, + { + id: "text-1", + type: "text", + position: { x: 500, y: 20 }, + size: { width: 220, height: 80 }, + data: { content: "说明" }, + }, + ] as any[]; + + const pendingNode = resolvePendingCanvasAgentTargetNode({ + nodes, + pendingTarget: { + nodeId: "image-annotated", + requestedAt: 1000, + }, + now: 1500, + isEligibleNode: (node) => node.type === "image", + }); + + expect(pendingNode?.id).toBe("image-annotated"); + expect( + resolvePendingCanvasAgentTargetNode({ + nodes, + pendingTarget: { + nodeId: "image-annotated", + requestedAt: 1000, + }, + now: 1000 + 10 * 60 * 1000 + 1, + isEligibleNode: (node) => node.type === "image", + }) + ).toBeNull(); + expect( + resolvePendingCanvasAgentTargetNode({ + nodes, + pendingTarget: { + nodeId: "text-1", + requestedAt: 1000, + }, + now: 1500, + isEligibleNode: (node) => node.type === "image", + }) + ).toBeNull(); + }); + + test("merges pending annotation edit targets into selected node ids without duplicates", () => { + expect( + mergePendingCanvasAgentTargetNodeIds({ + selectedNodeIds: ["image-1", "image-2"], + pendingTargetNodeId: "image-1", + }) + ).toEqual(["image-1", "image-2"]); + + expect( + mergePendingCanvasAgentTargetNodeIds({ + selectedNodeIds: ["image-1"], + pendingTargetNodeId: "image-annotated", + }) + ).toEqual(["image-1", "image-annotated"]); + + expect( + mergePendingCanvasAgentTargetNodeIds({ + selectedNodeIds: Array.from({ length: 20 }, (_, index) => `node-${index + 1}`), + pendingTargetNodeId: "node-21", + maxItems: 20, + }) + ).toHaveLength(20); + }); + test("builds annotation-aware edit prompts only for annotation requests", () => { const context: CanvasAgentContext = { ...plannerContext,