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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import type { CanvasNode } from "@/domain/design";
|
import type { AgentContent, CanvasNode } from "@/domain/design";
|
||||||
import type { UploadedReferenceImage } from "@/ui/components/PromptComposer";
|
import type { UploadedReferenceImage } from "@/ui/components/PromptComposer";
|
||||||
|
|
||||||
const canvasNodeReferencePrefix = "canvas-node:";
|
const canvasNodeReferencePrefix = "canvas-node:";
|
||||||
@@ -21,6 +21,31 @@ export function isCanvasNodeReferenceImage(reference: UploadedReferenceImage) {
|
|||||||
return reference.id.startsWith(canvasNodeReferencePrefix);
|
return reference.id.startsWith(canvasNodeReferencePrefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function lastCanvasNodeIdForReferences(references: Array<Pick<UploadedReferenceImage, "id" | "publicUrl">>, nodes: Array<Pick<CanvasNode, "id" | "content">>) {
|
||||||
|
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<Pick<UploadedReferenceImage, "id" | "publicUrl">>, nodes: Array<Pick<CanvasNode, "id" | "content">>): 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[]) {
|
export function isAssetReferencedByCanvas(publicUrl: string, nodes: CanvasNode[]) {
|
||||||
const targetKeys = assetReferenceKeys(publicUrl);
|
const targetKeys = assetReferenceKeys(publicUrl);
|
||||||
if (targetKeys.size === 0) return false;
|
if (targetKeys.size === 0) return false;
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ import { useCanvasPersistence } from "./canvas/hooks/useCanvasPersistence";
|
|||||||
import { useCanvasSelection } from "./canvas/hooks/useCanvasSelection";
|
import { useCanvasSelection } from "./canvas/hooks/useCanvasSelection";
|
||||||
import { useGenerationStream, type GenerationChannel } from "./canvas/hooks/useGenerationStream";
|
import { useGenerationStream, type GenerationChannel } from "./canvas/hooks/useGenerationStream";
|
||||||
import { imageGeneratorPlaceholderSize, imageGeneratorTargetSizeFromNode, imageGeneratorTargetSizeFromSettings, serializeImageGeneratorTargetSize, type ImageGeneratorTargetSize } from "./canvas/imageGeneratorSizing";
|
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 { 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 { beginPenStroke, createPenStrokeNode, PenStrokeDraftOverlay, PenToolControls, type PenDraft, type PenSettings } from "@/ui/pages/CanvasWorkspace/canvas/PenTool";
|
||||||
import { PenStrokeToolbar, updatePenStrokeNode, type PenStrokePatch } from "./canvas/PenStrokeToolbar";
|
import { PenStrokeToolbar, updatePenStrokeNode, type PenStrokePatch } from "./canvas/PenStrokeToolbar";
|
||||||
@@ -2993,12 +2993,13 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
|||||||
source === "image-generator"
|
source === "image-generator"
|
||||||
? imageGeneratorContents ?? []
|
? imageGeneratorContents ?? []
|
||||||
: activeRef.current?.toAgentContents() ?? [{ type: "text" as const, text: activePrompt, textSource: "input" }];
|
: 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 hasUserText = baseContents.some((content) => content.type === "text" && !isHiddenTextSource(content.textSource) && content.text.replace(/\u00a0/g, " ").trim());
|
||||||
const contents =
|
const contents =
|
||||||
activeMode === "image"
|
activeMode === "image"
|
||||||
? withImageGeneratorTarget(baseContents, selectedGeneratorNode?.id)
|
? withImageGeneratorTarget(anchoredContents, selectedGeneratorNode?.id)
|
||||||
: baseContents;
|
: anchoredContents;
|
||||||
const visibleContents = activeMode === "image" ? baseContents : contents;
|
const visibleContents = baseContents;
|
||||||
if (!project || !hasUserText) return;
|
if (!project || !hasUserText) return;
|
||||||
const selectedThreadId = selectedAgentThreadIdRef.current;
|
const selectedThreadId = selectedAgentThreadIdRef.current;
|
||||||
const reusableAgentThreadId =
|
const reusableAgentThreadId =
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
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", () => {
|
test("keeps the SVG source for model preparation and uses WebP for the visible capsule", () => {
|
||||||
const reference = canvasNodeToReferenceImage({
|
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.previewUrl, "https://assets.example/drawing.render.webp");
|
||||||
assert.equal(reference.semanticContext, "CAD context");
|
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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user