feat: support canvas comparison view intent

This commit is contained in:
134 2026-06-22 22:33:05 +08:00
parent ad6b09d1b2
commit 2945528f44
4 changed files with 230 additions and 0 deletions

View File

@ -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,

View File

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

View File

@ -0,0 +1,119 @@
import type {
CanvasAgentContext,
CanvasAgentNodeRef,
CanvasViewOperationPlan,
} from "@/types/canvas/agent-protocol";
function normalizeNodeIds(values: Array<string | null | undefined>, 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",
};
}

View File

@ -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({