From 1e41335ebb168769f41fc49540e964370cadb541 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 23 Jun 2026 16:44:34 +0800 Subject: [PATCH] fix: center canvas agent writebacks without anchors --- ...框Agent模式升级开发文档-借鉴Cowart-2026-06-22.md | 2 +- lib/canvas/agent-direct-writeback.ts | 4 +- lib/canvas/agent-placement.ts | 68 ++++++++++++++-- tests/routes/canvas-chat-helpers.test.ts | 78 +++++++++++++++++++ 4 files changed, 142 insertions(+), 10 deletions(-) diff --git a/docs/无限画布对话框Agent模式升级开发文档-借鉴Cowart-2026-06-22.md b/docs/无限画布对话框Agent模式升级开发文档-借鉴Cowart-2026-06-22.md index a73f7e2e0..42f65a1a0 100644 --- a/docs/无限画布对话框Agent模式升级开发文档-借鉴Cowart-2026-06-22.md +++ b/docs/无限画布对话框Agent模式升级开发文档-借鉴Cowart-2026-06-22.md @@ -1211,7 +1211,7 @@ canvasViewOperationPlan?: CanvasViewOperationPlan; - 有 selected slot:`fill_slot` - 有 selected image:`insert_beside` -- 无选中:`insert_beside` 当前视口或 `viewport-center` +- 无选中:`insert_beside` + `placement = "viewport-center"`,并在 `metadata.canvasAgentViewport` 写入请求时的 viewport(含 width/height),服务端 mutation 按真实当前视口中心计算落点 图片编辑: diff --git a/lib/canvas/agent-direct-writeback.ts b/lib/canvas/agent-direct-writeback.ts index e5c02a5fc..3226561ef 100644 --- a/lib/canvas/agent-direct-writeback.ts +++ b/lib/canvas/agent-direct-writeback.ts @@ -168,6 +168,7 @@ export function buildCanvasAgentDirectWritebackPlan( : params.mode === "edit" ? "append_revision" : "insert_beside"; + const hasWritebackAnchor = Boolean(anchorNode?.nodeId); const mediaItems: CanvasMediaItem[] = resultUrls.map((url, index) => { const mediaItemId = `${params.assistantMessageId}:${params.operationLabel}:${index + 1}`; const itemSourceNodeIds = perResultSourceNodeIds[index] @@ -206,7 +207,7 @@ export function buildCanvasAgentDirectWritebackPlan( sourceNodeIds, targetSlotId: targetSlotId || undefined, anchorNodeId: shouldFillSlot ? undefined : anchorNode?.nodeId, - placement: "right", + placement: hasWritebackAnchor ? "right" : "viewport-center", placementStrategy: perResultSourceNodeIds.length === mediaItems.length ? "beside_each_source" @@ -220,6 +221,7 @@ export function buildCanvasAgentDirectWritebackPlan( chatId: params.chatId, mode: params.mode, prompt: params.prompt || params.requestTextContent || "", + canvasAgentViewport: params.context.viewport, }, }; diff --git a/lib/canvas/agent-placement.ts b/lib/canvas/agent-placement.ts index e58ff063a..18cbe1f9c 100644 --- a/lib/canvas/agent-placement.ts +++ b/lib/canvas/agent-placement.ts @@ -17,6 +17,14 @@ export type ResolvedCanvasAgentMediaPlacement = { edgeSourceId?: string; }; +export type CanvasAgentPlacementViewport = { + x: number; + y: number; + zoom: number; + width?: number; + height?: number; +}; + function uniqueStrings(values: Array) { return Array.from( new Set(values.map((value) => String(value || "").trim()).filter(Boolean)) @@ -40,11 +48,51 @@ function getNodeBounds(node?: CanvasAgentPlacementNode | null) { }; } -function getViewportCenter(viewport?: { x: number; y: number; zoom: number }) { - const zoom = Math.max(0.1, Number(viewport?.zoom || 1) || 1); +function toFiniteNumber(value: unknown) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function normalizePlacementViewport( + value: unknown +): CanvasAgentPlacementViewport | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const raw = value as Record; + const x = toFiniteNumber(raw.x); + const y = toFiniteNumber(raw.y); + const zoom = toFiniteNumber(raw.zoom); + if (x === undefined || y === undefined || zoom === undefined) return undefined; return { - x: -Number(viewport?.x || 0) / zoom, - y: -Number(viewport?.y || 0) / zoom, + x, + y, + zoom, + width: toFiniteNumber(raw.width), + height: toFiniteNumber(raw.height), + }; +} + +function getPlanViewport(plan: CanvasOperationPlan) { + const metadata = + plan.metadata && typeof plan.metadata === "object" && !Array.isArray(plan.metadata) + ? (plan.metadata as Record) + : {}; + return normalizePlacementViewport(metadata.canvasAgentViewport); +} + +function getEffectiveViewport(params: { + plan: CanvasOperationPlan; + viewport?: CanvasAgentPlacementViewport; +}) { + return getPlanViewport(params.plan) || params.viewport; +} + +function getViewportCenter(viewport?: CanvasAgentPlacementViewport) { + const zoom = Math.max(0.1, Number(viewport?.zoom || 1) || 1); + const width = toFiniteNumber(viewport?.width); + const height = toFiniteNumber(viewport?.height); + return { + x: ((width && width > 0 ? width / 2 : 0) - Number(viewport?.x || 0)) / zoom, + y: ((height && height > 0 ? height / 2 : 0) - Number(viewport?.y || 0)) / zoom, }; } @@ -54,7 +102,7 @@ function getViewportCenteredPosition(params: { groupIndex: number; size: { width: number; height: number }; strategy: CanvasOperationPlan["placementStrategy"]; - viewport?: { x: number; y: number; zoom: number }; + viewport?: CanvasAgentPlacementViewport; }) { const stepGap = 24; const center = getViewportCenter(params.viewport); @@ -155,7 +203,7 @@ function resolvePosition(params: { size: { width: number; height: number }; anchorNode?: CanvasAgentPlacementNode | null; targetSlot?: CanvasAgentPlacementNode | null; - viewport?: { x: number; y: number; zoom: number }; + viewport?: CanvasAgentPlacementViewport; }) { const gap = 40; const stepGap = 24; @@ -258,9 +306,13 @@ export function resolveCanvasAgentMediaPlacements(params: { mediaItems: CanvasMediaItem[]; nodes: CanvasAgentPlacementNode[]; targetSlot?: CanvasAgentPlacementNode | null; - viewport?: { x: number; y: number; zoom: number }; + viewport?: CanvasAgentPlacementViewport; }): ResolvedCanvasAgentMediaPlacement[] { const groupCounts = new Map(); + const viewport = getEffectiveViewport({ + plan: params.plan, + viewport: params.viewport, + }); return params.mediaItems.map((item, index) => { const sourceNodeIds = getItemSourceNodeIds(item, params.plan); @@ -289,7 +341,7 @@ export function resolveCanvasAgentMediaPlacements(params: { size, anchorNode, targetSlot: params.targetSlot, - viewport: params.viewport, + viewport, }); return { diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index bb1f4db73..aedd66e20 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -676,6 +676,53 @@ test.describe("canvas chat pure helpers", () => { ]); }); + test("plans direct generations into the current viewport when no anchor exists", () => { + const context: CanvasAgentContext = { + ...plannerContext, + selectedNodeIds: [], + selectedNodes: [], + candidateNodes: [], + slots: [], + annotations: [], + viewport: { x: -200, y: -100, zoom: 1, width: 1000, height: 800 }, + focusPolicy: "auto", + }; + + const result = buildCanvasAgentDirectWritebackPlan({ + projectId: plannerContext.projectId || "", + chatId: "chat-1", + assistantMessageId: "assistant-no-anchor", + operationLabel: "agent-generate-image", + resultType: "image", + resultUrls: ["https://cdn.example.com/no-anchor.png"], + prompt: "生成一张主图", + mode: "generate", + context, + }); + + expect(result).toMatchObject({ + operation: "insert_beside", + shouldFillSlot: false, + plan: { + operation: "insert_beside", + anchorNodeId: undefined, + placement: "viewport-center", + placementStrategy: "single", + sourceNodeIds: [], + metadata: { + canvasAgentViewport: { + x: -200, + y: -100, + zoom: 1, + width: 1000, + height: 800, + }, + }, + }, + }); + expect(result?.mediaItems[0]?.sourceNodeIds).toEqual([]); + }); + test("does not build direct writeback plans without valid result urls", () => { expect( buildCanvasAgentDirectWritebackPlan({ @@ -2244,6 +2291,37 @@ test.describe("canvas chat pure helpers", () => { { x: 88, y: 60 }, { x: 212, y: 60 }, ]); + + const viewportAwarePlacements = resolveCanvasAgentMediaPlacements({ + plan: { + version: 1, + operationId: "op-center-with-stage", + operation: "insert_beside", + projectId: "project-1", + placement: "viewport-center", + placementStrategy: "grid", + metadata: { + canvasAgentViewport: { + x: -200, + y: -100, + zoom: 1, + width: 1000, + height: 800, + }, + }, + }, + mediaItems: [ + { mediaItemId: "media-1", type: "image", url: "https://cdn.example.com/1.png", width: 100, height: 80 }, + { mediaItemId: "media-2", type: "image", url: "https://cdn.example.com/2.png", width: 100, height: 80 }, + ], + nodes: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }); + + expect(viewportAwarePlacements.map((placement) => placement.position)).toEqual([ + { x: 588, y: 460 }, + { x: 712, y: 460 }, + ]); }); test("normalizes slot-only canvas agent context without image nodes", () => {