diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 4a1e46c48..1386281e3 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -74,6 +74,10 @@ import { type CanvasAgentModelPlannerOutcome, } from "@/lib/canvas/agent-model-planner"; import { buildCanvasAgentResultViewOperation } from "@/lib/canvas/agent-result-view"; +import { + buildCanvasAgentComparisonViewPlan, + hasCanvasAgentComparisonViewIntent, +} from "@/lib/canvas/agent-view-intent"; import { getCanvasAgentOperationErrorMessage, normalizeCanvasAgentOperationErrorCode, @@ -3237,7 +3241,9 @@ export async function POST(request: Request) { if (!text) return null; const lowerText = text.toLowerCase(); + const hasComparisonViewIntent = hasCanvasAgentComparisonViewIntent(text); const hasViewKeyword = + hasComparisonViewIntent || /视角|视野|画布|聚焦|定位|居中|看全局|看整体|看全部|缩小|放大|拉近|拉远|zoom/.test( lowerText ); @@ -3268,6 +3274,15 @@ export async function POST(request: Request) { explicitTargetNodeIds.length > 0 || Boolean(anchorNode?.nodeId); const viewOperationId = `${message.id}:canvas-view:${Date.now()}`; + if (hasComparisonViewIntent) { + const comparisonViewPlan = buildCanvasAgentComparisonViewPlan({ + context: requestCanvasAgentContext, + projectId: contextPayload.projectId, + operationId: viewOperationId, + }); + if (comparisonViewPlan) return comparisonViewPlan; + } + if (/看全局|看整体|看全部|全局|整体/.test(lowerText)) { return { version: 1, diff --git a/lib/canvas/agent-model-planner.ts b/lib/canvas/agent-model-planner.ts index 095e104f1..a13262306 100644 --- a/lib/canvas/agent-model-planner.ts +++ b/lib/canvas/agent-model-planner.ts @@ -5,6 +5,7 @@ import type { CanvasOperationPlan, CanvasViewOperationPlan, } from "@/types/canvas/agent-protocol"; +import { hasCanvasAgentComparisonViewIntent } from "@/lib/canvas/agent-view-intent"; const plannerDecisionSchema = z.object({ action: z.enum([ @@ -401,6 +402,7 @@ export function shouldConsiderCanvasAgentModelPlanner(params: { if (!text) return false; const hasViewIntent = + hasCanvasAgentComparisonViewIntent(text) || /视角|视野|画布|聚焦|定位|居中|看全局|看整体|看全部|缩小|放大|拉近|拉远|zoom/.test( text ); diff --git a/lib/canvas/agent-view-intent.ts b/lib/canvas/agent-view-intent.ts new file mode 100644 index 000000000..ab85776db --- /dev/null +++ b/lib/canvas/agent-view-intent.ts @@ -0,0 +1,119 @@ +import type { + CanvasAgentContext, + CanvasAgentNodeRef, + CanvasViewOperationPlan, +} from "@/types/canvas/agent-protocol"; + +function normalizeNodeIds(values: Array, maxItems = 20) { + return Array.from( + new Set(values.map((value) => String(value || "").trim()).filter(Boolean)) + ).slice(0, maxItems); +} + +function getNodeSourceIds(node?: CanvasAgentNodeRef | null) { + const metadata = node?.metadata || {}; + const sourceIds = Array.isArray(metadata.sourceNodeIds) + ? metadata.sourceNodeIds + .map((value) => String(value || "").trim()) + .filter(Boolean) + : []; + return normalizeNodeIds([ + typeof metadata.revisionOf === "string" ? metadata.revisionOf : undefined, + typeof metadata.sourceNodeId === "string" ? metadata.sourceNodeId : undefined, + ...sourceIds, + ]); +} + +function isAgentResultNode(node?: CanvasAgentNodeRef | null) { + const metadata = node?.metadata || {}; + return ( + metadata.generatedByAgent === true || + typeof metadata.canvasAgentOperationId === "string" || + typeof metadata.canvasAgentMediaItemId === "string" || + getNodeSourceIds(node).length > 0 + ); +} + +export function hasCanvasAgentComparisonViewIntent(userText: string) { + return /对比|比较|一起看|同时看|原图.*新图|新图.*原图/.test( + userText.toLowerCase() + ); +} + +export function resolveCanvasAgentComparisonNodeIds(params: { + context: CanvasAgentContext; + maxItems?: number; +}) { + const maxItems = Math.max(2, Math.min(20, params.maxItems || 10)); + const allNodes = [...params.context.selectedNodes, ...params.context.candidateNodes]; + const nodeMap = new Map(allNodes.map((node) => [node.nodeId, node])); + const selectedNodeIds = normalizeNodeIds( + [ + ...params.context.selectedNodeIds, + ...params.context.selectedNodes + .filter((node) => node.isSelected) + .map((node) => node.nodeId), + ], + maxItems + ).filter((nodeId) => nodeMap.has(nodeId)); + + if (selectedNodeIds.length >= 2) return selectedNodeIds.slice(0, maxItems); + + const selectedNode = selectedNodeIds[0] ? nodeMap.get(selectedNodeIds[0]) : null; + const selectedSourceIds = getNodeSourceIds(selectedNode).filter((nodeId) => + nodeMap.has(nodeId) + ); + if (selectedNode?.nodeId && selectedSourceIds.length > 0) { + return normalizeNodeIds([...selectedSourceIds, selectedNode.nodeId], maxItems); + } + + if (selectedNode?.nodeId) { + const relatedResult = allNodes.find( + (node) => + node.nodeId !== selectedNode.nodeId && + isAgentResultNode(node) && + getNodeSourceIds(node).includes(selectedNode.nodeId) + ); + if (relatedResult) { + return normalizeNodeIds([selectedNode.nodeId, relatedResult.nodeId], maxItems); + } + } + + const latestResultWithSource = allNodes.find((node) => { + if (!isAgentResultNode(node)) return false; + return getNodeSourceIds(node).some((nodeId) => nodeMap.has(nodeId)); + }); + if (latestResultWithSource) { + return normalizeNodeIds( + [...getNodeSourceIds(latestResultWithSource), latestResultWithSource.nodeId], + maxItems + ).filter((nodeId) => nodeMap.has(nodeId)); + } + + return []; +} + +export function buildCanvasAgentComparisonViewPlan(params: { + context: CanvasAgentContext; + projectId: string; + operationId: string; + reason?: string; +}): CanvasViewOperationPlan | null { + const nodeIds = resolveCanvasAgentComparisonNodeIds({ + context: params.context, + maxItems: 10, + }); + if (nodeIds.length < 2) return null; + + return { + version: 1, + viewOperationId: `${params.operationId}:view`, + operation: "focus_nodes", + projectId: params.projectId, + nodeIds, + anchorNodeId: nodeIds[0], + padding: 140, + persist: false, + reason: params.reason || "focus_canvas_comparison_targets", + }; +} diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 041b37da9..ab4ddc049 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -49,6 +49,11 @@ import { } from "@/lib/canvas/agent-model-planner"; import { resolveCanvasAgentMediaPlacements } from "@/lib/canvas/agent-placement"; import { buildCanvasAgentResultViewOperation } from "@/lib/canvas/agent-result-view"; +import { + buildCanvasAgentComparisonViewPlan, + hasCanvasAgentComparisonViewIntent, + resolveCanvasAgentComparisonNodeIds, +} from "@/lib/canvas/agent-view-intent"; import { computeCanvasAgentPanViewport, resolveCanvasViewOperation, @@ -174,6 +179,12 @@ test.describe("canvas chat pure helpers", () => { context: plannerContext, }) ).toBe(true); + expect( + shouldConsiderCanvasAgentModelPlanner({ + userText: "对比原图和新图", + context: plannerContext, + }) + ).toBe(true); }); test("normalizes model-planned empty slot creation", () => { @@ -1259,6 +1270,89 @@ test.describe("canvas chat pure helpers", () => { ).toBeNull(); }); + test("resolves comparison view targets from selection and revision metadata", () => { + const comparisonContext: CanvasAgentContext = { + ...plannerContext, + selectedNodeIds: ["agent-result-1"], + selectedNodes: [ + { + nodeId: "agent-result-1", + type: "image", + title: "白底版本", + x: 460, + y: 20, + width: 300, + height: 240, + isSelected: true, + metadata: { + generatedByAgent: true, + revisionOf: "image-1", + sourceNodeIds: ["image-1"], + }, + }, + ], + candidateNodes: [ + { + nodeId: "image-1", + type: "image", + title: "原图", + x: 10, + y: 20, + width: 300, + height: 240, + }, + ], + }; + + expect(hasCanvasAgentComparisonViewIntent("对比原图和新图")).toBe(true); + expect( + resolveCanvasAgentComparisonNodeIds({ + context: comparisonContext, + }) + ).toEqual(["image-1", "agent-result-1"]); + + expect( + buildCanvasAgentComparisonViewPlan({ + context: comparisonContext, + projectId: plannerContext.projectId || "", + operationId: "operation-compare", + }) + ).toMatchObject({ + operation: "focus_nodes", + nodeIds: ["image-1", "agent-result-1"], + anchorNodeId: "image-1", + persist: false, + reason: "focus_canvas_comparison_targets", + }); + + expect( + resolveCanvasAgentComparisonNodeIds({ + context: { + ...comparisonContext, + selectedNodeIds: ["image-1", "agent-result-1"], + selectedNodes: [ + { + nodeId: "image-1", + type: "image", + x: 10, + y: 20, + isSelected: true, + }, + { + nodeId: "agent-result-1", + type: "image", + x: 460, + y: 20, + isSelected: true, + metadata: { generatedByAgent: true, revisionOf: "image-1" }, + }, + ], + candidateNodes: [], + }, + }) + ).toEqual(["image-1", "agent-result-1"]); + }); + test("resolves latest-result and pan canvas view operations", () => { expect( resolveCanvasViewOperationTargetNodeIds({