fix: add canvas agent pan view intent

This commit is contained in:
134 2026-06-23 15:55:10 +08:00
parent 38435f9401
commit e73c2da84e
4 changed files with 199 additions and 8 deletions

View File

@ -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();

View File

@ -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
);

View File

@ -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",
};
}

View File

@ -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", () => {