From e73c2da84ed2935e0145c88944f1b7f84d296b46 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 23 Jun 2026 15:55:10 +0800 Subject: [PATCH] fix: add canvas agent pan view intent --- app/(chat)/api/chat/route.ts | 34 ++++++-- lib/canvas/agent-model-planner.ts | 2 + lib/canvas/agent-view-pan.ts | 99 ++++++++++++++++++++++++ tests/routes/canvas-chat-helpers.test.ts | 72 +++++++++++++++++ 4 files changed, 199 insertions(+), 8 deletions(-) create mode 100644 lib/canvas/agent-view-pan.ts diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 07b519ced..e35e28502 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -78,6 +78,10 @@ import { buildCanvasAgentComparisonViewPlan, hasCanvasAgentComparisonViewIntent, } from "@/lib/canvas/agent-view-intent"; +import { + buildCanvasAgentPanViewPlan, + hasCanvasAgentPanViewIntent, +} from "@/lib/canvas/agent-view-pan"; import { buildCanvasAgentDirectWritebackPlan } from "@/lib/canvas/agent-direct-writeback"; import { getCanvasAgentOperationErrorMessage, @@ -3180,8 +3184,10 @@ export async function POST(request: Request) { const lowerText = text.toLowerCase(); const hasComparisonViewIntent = hasCanvasAgentComparisonViewIntent(text); + const hasPanViewIntent = hasCanvasAgentPanViewIntent(text); const hasViewKeyword = hasComparisonViewIntent || + hasPanViewIntent || /视角|视野|画布|聚焦|定位|居中|看全局|看整体|看全部|缩小|放大|拉近|拉远|zoom/.test( lowerText ); @@ -3267,6 +3273,16 @@ export async function POST(request: Request) { }; } + if (hasPanViewIntent) { + const panViewPlan = buildCanvasAgentPanViewPlan({ + userText: text, + context: requestCanvasAgentContext, + projectId: contextPayload.projectId, + operationId: viewOperationId, + }); + if (panViewPlan) return panViewPlan; + } + if (/放大|拉近|zoom\s*in/.test(lowerText)) { return { version: 1, @@ -3716,14 +3732,16 @@ export async function POST(request: Request) { const canvasViewOperationPlan = resolveCanvasViewOperationPlan(); if (canvasViewOperationPlan) { - const canvasViewReply = - canvasViewOperationPlan.operation === "fit_all" - ? "已缩小到画布全局视图。" - : canvasViewOperationPlan.operation === "zoom_in" - ? "已放大画布视角。" - : canvasViewOperationPlan.operation === "zoom_out" - ? "已缩小画布视角。" - : "已聚焦到当前编辑对象。"; + let canvasViewReply = "已聚焦到当前编辑对象。"; + if (canvasViewOperationPlan.operation === "fit_all") { + canvasViewReply = "已缩小到画布全局视图。"; + } else if (canvasViewOperationPlan.operation === "zoom_in") { + canvasViewReply = "已放大画布视角。"; + } else if (canvasViewOperationPlan.operation === "zoom_out") { + canvasViewReply = "已缩小画布视角。"; + } else if (canvasViewOperationPlan.operation === "pan_to") { + canvasViewReply = "已平移画布视角。"; + } const canvasViewStream = createUIMessageStream({ execute: async ({ writer }) => { const messageId = generateUUID(); diff --git a/lib/canvas/agent-model-planner.ts b/lib/canvas/agent-model-planner.ts index 1433e6289..dc0b43efb 100644 --- a/lib/canvas/agent-model-planner.ts +++ b/lib/canvas/agent-model-planner.ts @@ -6,6 +6,7 @@ import type { CanvasViewOperationPlan, } from "@/types/canvas/agent-protocol"; import { hasCanvasAgentComparisonViewIntent } from "@/lib/canvas/agent-view-intent"; +import { hasCanvasAgentPanViewIntent } from "@/lib/canvas/agent-view-pan"; const plannerDecisionSchema = z.object({ action: z.enum([ @@ -409,6 +410,7 @@ export function shouldConsiderCanvasAgentModelPlanner(params: { const hasViewIntent = hasCanvasAgentComparisonViewIntent(text) || + hasCanvasAgentPanViewIntent(text) || /视角|视野|画布|聚焦|定位|居中|看全局|看整体|看全部|缩小|放大|拉近|拉远|zoom/.test( text ); diff --git a/lib/canvas/agent-view-pan.ts b/lib/canvas/agent-view-pan.ts new file mode 100644 index 000000000..bbb0a4070 --- /dev/null +++ b/lib/canvas/agent-view-pan.ts @@ -0,0 +1,99 @@ +import type { + CanvasAgentContext, + CanvasViewOperationPlan, +} from "@/types/canvas/agent-protocol"; + +const VIEWPORT_ANCHOR_X_RATIO = 0.52; +const VIEWPORT_ANCHOR_Y_RATIO = 0.46; + +function toFiniteNumber(value: unknown, fallback: number) { + const numericValue = Number(value); + return Number.isFinite(numericValue) ? numericValue : fallback; +} + +function getPanDirection(userText: string) { + const text = userText.trim().toLowerCase(); + const hasHorizontalIntent = + /左上|左下|右上|右下|向左|往左|左边|左侧|向右|往右|右边|右侧/.test( + text + ); + const hasVerticalIntent = + /左上|右上|左下|右下|向上|往上|上面|上方|向下|往下|下面|下方/.test( + text + ); + if (!hasHorizontalIntent && !hasVerticalIntent) return null; + + const hasPanVerb = + /平移|拖动|拖拽|挪动|移动(?:画布|视角|视野)|拖(?:一下|一点|画布)?|看(?:看|一下|一点)?/.test( + text + ) || + /(?:向|往)(?:左|右|上|下).*(?:看|移动|拖|挪)/.test(text) || + /(?:左边|左侧|右边|右侧|上面|上方|下面|下方).*(?:看看|看一下|视角|视野|画布)/.test( + text + ); + if (!hasPanVerb) return null; + + return { + x: /右上|右下|向右|往右|右边|右侧/.test(text) + ? 1 + : /左上|左下|向左|往左|左边|左侧/.test(text) + ? -1 + : 0, + y: /左下|右下|向下|往下|下面|下方/.test(text) + ? 1 + : /左上|右上|向上|往上|上面|上方/.test(text) + ? -1 + : 0, + }; +} + +function getPanScale(userText: string) { + const text = userText.trim().toLowerCase(); + if (/大幅|多一点|远一点|一大段|很多|more|far/.test(text)) return 0.52; + if (/一点|一下|少许|稍微|轻微|小幅|slightly/.test(text)) return 0.26; + return 0.36; +} + +export function hasCanvasAgentPanViewIntent(userText: string) { + const direction = getPanDirection(userText); + return Boolean(direction && (direction.x !== 0 || direction.y !== 0)); +} + +export function buildCanvasAgentPanViewPlan(params: { + userText: string; + context: CanvasAgentContext; + projectId: string; + operationId: string; + reason?: string; +}): CanvasViewOperationPlan | null { + const direction = getPanDirection(params.userText); + if (!direction || (direction.x === 0 && direction.y === 0)) return null; + + const viewport = params.context.viewport || { x: 0, y: 0, zoom: 1 }; + const zoom = Math.max(0.1, toFiniteNumber(viewport.zoom, 1)); + const stageWidth = Math.max(1, toFiniteNumber(viewport.width, 1200)); + const stageHeight = Math.max(1, toFiniteNumber(viewport.height, 800)); + const currentCenterX = + (stageWidth * VIEWPORT_ANCHOR_X_RATIO - toFiniteNumber(viewport.x, 0)) / + zoom; + const currentCenterY = + (stageHeight * VIEWPORT_ANCHOR_Y_RATIO - toFiniteNumber(viewport.y, 0)) / + zoom; + const scale = getPanScale(params.userText); + const stepX = (stageWidth * scale) / zoom; + const stepY = (stageHeight * scale) / zoom; + + return { + version: 1, + viewOperationId: `${params.operationId}:view`, + operation: "pan_to", + projectId: params.projectId, + center: { + x: currentCenterX + direction.x * stepX, + y: currentCenterY + direction.y * stepY, + }, + zoom, + persist: false, + reason: params.reason || "pan_canvas_view", + }; +} diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index ac4f2c910..8141232ab 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -67,6 +67,10 @@ import { hasCanvasAgentComparisonViewIntent, resolveCanvasAgentComparisonNodeIds, } from "@/lib/canvas/agent-view-intent"; +import { + buildCanvasAgentPanViewPlan, + hasCanvasAgentPanViewIntent, +} from "@/lib/canvas/agent-view-pan"; import { computeCanvasAgentPanViewport, resolveCanvasViewOperation, @@ -272,6 +276,12 @@ test.describe("canvas chat pure helpers", () => { context: plannerContext, }) ).toBe(true); + expect( + shouldConsiderCanvasAgentModelPlanner({ + userText: "向右移动视角一点", + context: plannerContext, + }) + ).toBe(true); }); test("treats selected agent slots as canvas routing context", () => { @@ -1777,6 +1787,48 @@ test.describe("canvas chat pure helpers", () => { }); }); + test("builds relative pan view plans from canvas viewport context", () => { + expect(hasCanvasAgentPanViewIntent("右边那张图")).toBe(false); + expect(hasCanvasAgentPanViewIntent("向右移动视角一点")).toBe(true); + + const plan = buildCanvasAgentPanViewPlan({ + userText: "向右移动视角一点", + context: { + ...plannerContext, + viewport: { x: -200, y: -100, zoom: 2, width: 1000, height: 800 }, + }, + projectId: plannerContext.projectId || "", + operationId: "view-pan", + }); + + expect(plan).toMatchObject({ + version: 1, + viewOperationId: "view-pan:view", + operation: "pan_to", + projectId: plannerContext.projectId, + center: { + x: 490, + y: 234, + }, + zoom: 2, + persist: false, + reason: "pan_canvas_view", + }); + + const diagonalPlan = buildCanvasAgentPanViewPlan({ + userText: "把画布往左上拖一点", + context: { + ...plannerContext, + viewport: { x: -200, y: -100, zoom: 2, width: 1000, height: 800 }, + }, + projectId: plannerContext.projectId || "", + operationId: "view-pan-diagonal", + }); + + expect(diagonalPlan?.center?.x).toBeCloseTo(230); + expect(diagonalPlan?.center?.y).toBeCloseTo(130); + }); + test("resolves canvas view operation plans into transient viewport targets", () => { const zoomIn = resolveCanvasViewOperation({ plan: { @@ -1853,6 +1905,26 @@ test.describe("canvas chat pure helpers", () => { expect(fitAll?.viewport.zoom).toBeCloseTo(0.1784615385); expect(fitAll?.viewport.x).toBeCloseTo(305.8461538); expect(fitAll?.viewport.y).toBeCloseTo(153.8461538); + + const panTo = resolveCanvasViewOperation({ + plan: { + version: 1, + viewOperationId: "view-pan-to", + operation: "pan_to", + center: { x: 490, y: 234 }, + zoom: 2, + }, + nodes: [], + currentViewport: { x: -200, y: -100, zoom: 2 }, + stage: { width: 1000, height: 800 }, + }); + + expect(panTo?.targetNodeIds).toEqual([]); + expect(panTo?.viewport).toEqual({ + x: -460, + y: -100, + zoom: 2, + }); }); test("resolves grid media placements to the right of an anchor node", () => {