From f5a1ae7961fb2f9b4e6ab7171723457fee8f62c4 Mon Sep 17 00:00:00 2001 From: liangxu Date: Fri, 17 Jul 2026 09:07:28 +0800 Subject: [PATCH] feat(canvas): send the last referenced canvas node as a generation anchor Resolve the most recent canvas-node reference from the prompt's reference images (by node id or matching asset URL) and append it as a hidden "reference anchor node:" settings directive on agent submissions, so the server can place generated images beside the source. Keep the persisted user-visible copy unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../canvas/nodeReferenceImages.ts | 27 +++++++++++++++++- .../src/ui/pages/CanvasWorkspace/index.tsx | 9 +++--- frontend/tests/nodeReferenceImages.test.mjs | 28 ++++++++++++++++++- 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/frontend/src/ui/pages/CanvasWorkspace/canvas/nodeReferenceImages.ts b/frontend/src/ui/pages/CanvasWorkspace/canvas/nodeReferenceImages.ts index a84011e..9c006bb 100644 --- a/frontend/src/ui/pages/CanvasWorkspace/canvas/nodeReferenceImages.ts +++ b/frontend/src/ui/pages/CanvasWorkspace/canvas/nodeReferenceImages.ts @@ -1,4 +1,4 @@ -import type { CanvasNode } from "@/domain/design"; +import type { AgentContent, CanvasNode } from "@/domain/design"; import type { UploadedReferenceImage } from "@/ui/components/PromptComposer"; const canvasNodeReferencePrefix = "canvas-node:"; @@ -21,6 +21,31 @@ export function isCanvasNodeReferenceImage(reference: UploadedReferenceImage) { return reference.id.startsWith(canvasNodeReferencePrefix); } +export function lastCanvasNodeIdForReferences(references: Array>, nodes: Array>) { + for (let index = references.length - 1; index >= 0; index -= 1) { + const reference = references[index]; + const directNodeId = reference.id.startsWith(canvasNodeReferencePrefix) ? reference.id.slice(canvasNodeReferencePrefix.length) : ""; + if (directNodeId && nodes.some((node) => node.id === directNodeId)) return directNodeId; + const referenceKeys = assetReferenceKeys(reference.publicUrl); + const matchedNode = [...nodes].reverse().find((node) => Array.from(assetReferenceKeys(node.content)).some((key) => referenceKeys.has(key))); + if (matchedNode) return matchedNode.id; + } + return ""; +} + +export function withLastCanvasReferenceAnchor(contents: AgentContent[], references: Array>, nodes: Array>): AgentContent[] { + const anchorNodeId = lastCanvasNodeIdForReferences(references, nodes); + if (!anchorNodeId) return contents; + return [ + ...contents, + { + type: "text", + text: `Reference anchor node: ${anchorNodeId}`, + textSource: "settings" + } + ]; +} + export function isAssetReferencedByCanvas(publicUrl: string, nodes: CanvasNode[]) { const targetKeys = assetReferenceKeys(publicUrl); if (targetKeys.size === 0) return false; diff --git a/frontend/src/ui/pages/CanvasWorkspace/index.tsx b/frontend/src/ui/pages/CanvasWorkspace/index.tsx index d545d3b..82a8846 100644 --- a/frontend/src/ui/pages/CanvasWorkspace/index.tsx +++ b/frontend/src/ui/pages/CanvasWorkspace/index.tsx @@ -64,7 +64,7 @@ import { useCanvasPersistence } from "./canvas/hooks/useCanvasPersistence"; import { useCanvasSelection } from "./canvas/hooks/useCanvasSelection"; import { useGenerationStream, type GenerationChannel } from "./canvas/hooks/useGenerationStream"; import { imageGeneratorPlaceholderSize, imageGeneratorTargetSizeFromNode, imageGeneratorTargetSizeFromSettings, serializeImageGeneratorTargetSize, type ImageGeneratorTargetSize } from "./canvas/imageGeneratorSizing"; -import { canvasNodeToReferenceImage, isAssetReferencedByCanvas, isCanvasNodeReferenceImage } from "./canvas/nodeReferenceImages"; +import { canvasNodeToReferenceImage, isAssetReferencedByCanvas, isCanvasNodeReferenceImage, withLastCanvasReferenceAnchor } from "./canvas/nodeReferenceImages"; import { canvasNodeSvg, exportCanvasNode, exportImageNode, exportTextNode, imageNodeRasterBlob, svgToRasterBlob } from "./canvas/nodeExport"; import { beginPenStroke, createPenStrokeNode, PenStrokeDraftOverlay, PenToolControls, type PenDraft, type PenSettings } from "@/ui/pages/CanvasWorkspace/canvas/PenTool"; import { PenStrokeToolbar, updatePenStrokeNode, type PenStrokePatch } from "./canvas/PenStrokeToolbar"; @@ -2993,12 +2993,13 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess source === "image-generator" ? imageGeneratorContents ?? [] : activeRef.current?.toAgentContents() ?? [{ type: "text" as const, text: activePrompt, textSource: "input" }]; + const anchoredContents = source === "agent" ? withLastCanvasReferenceAnchor(baseContents, referenceImagesRef.current, nodesRef.current) : baseContents; const hasUserText = baseContents.some((content) => content.type === "text" && !isHiddenTextSource(content.textSource) && content.text.replace(/\u00a0/g, " ").trim()); const contents = activeMode === "image" - ? withImageGeneratorTarget(baseContents, selectedGeneratorNode?.id) - : baseContents; - const visibleContents = activeMode === "image" ? baseContents : contents; + ? withImageGeneratorTarget(anchoredContents, selectedGeneratorNode?.id) + : anchoredContents; + const visibleContents = baseContents; if (!project || !hasUserText) return; const selectedThreadId = selectedAgentThreadIdRef.current; const reusableAgentThreadId = diff --git a/frontend/tests/nodeReferenceImages.test.mjs b/frontend/tests/nodeReferenceImages.test.mjs index e6c6d26..02f0c37 100644 --- a/frontend/tests/nodeReferenceImages.test.mjs +++ b/frontend/tests/nodeReferenceImages.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { canvasNodeToReferenceImage } from "../src/ui/pages/CanvasWorkspace/canvas/nodeReferenceImages.ts"; +import { canvasNodeToReferenceImage, lastCanvasNodeIdForReferences, withLastCanvasReferenceAnchor } from "../src/ui/pages/CanvasWorkspace/canvas/nodeReferenceImages.ts"; test("keeps the SVG source for model preparation and uses WebP for the visible capsule", () => { const reference = canvasNodeToReferenceImage({ @@ -24,3 +24,29 @@ test("keeps the SVG source for model preparation and uses WebP for the visible c assert.equal(reference.previewUrl, "https://assets.example/drawing.render.webp"); assert.equal(reference.semanticContext, "CAD context"); }); + +test("anchors generation to the last canvas-backed image capsule", () => { + const nodes = [ + { id: "image-1", content: "/assets/one.webp" }, + { id: "image-2", content: "/assets/two.webp" } + ]; + const references = [ + { id: "canvas-node:image-1", publicUrl: "/assets/one.webp" }, + { id: "upload-external", publicUrl: "/assets/external.webp" }, + { id: "canvas-node:image-2", publicUrl: "/assets/two.webp" } + ]; + + assert.equal(lastCanvasNodeIdForReferences(references, nodes), "image-2"); + assert.equal(lastCanvasNodeIdForReferences(references.slice(0, 2), nodes), "image-1"); + assert.equal(lastCanvasNodeIdForReferences([{ id: "upload-two", publicUrl: "/assets/two.webp" }], nodes), "image-2"); + + const contents = withLastCanvasReferenceAnchor([{ type: "text", text: "generate", textSource: "input" }], references, nodes); + assert.deepEqual(contents.at(-1), { + type: "text", + text: "Reference anchor node: image-2", + textSource: "settings" + }); + + const unanchoredContents = [{ type: "text", text: "generate", textSource: "input" }]; + assert.equal(withLastCanvasReferenceAnchor(unanchoredContents, [{ id: "upload-external", publicUrl: "/assets/external.webp" }], nodes), unanchoredContents); +});