diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index cbef1e2e0..835ef7184 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -98,6 +98,7 @@ import { shouldBypassCanvasImagePointsPrecheckForAgentControl } from "@/lib/canv import { applyCanvasOperationPlan } from "@/lib/canvas/agent-mutations"; import { isProductionEnvironment } from "@/lib/constants"; import type { + CanvasAgentContext, CanvasOperationPlan, CanvasViewOperationPlan, } from "@/types/canvas/agent-protocol"; @@ -881,23 +882,28 @@ export async function POST(request: Request) { return ""; } })(); - const isCanvasPageRequest = (() => { + const canvasPageRequest = (() => { const referer = request.headers.get("referer"); - if (!referer) return false; + if (!referer) return { isCanvasPage: false, projectId: "" }; try { const pathname = new URL(referer).pathname; - return pathname === "/canvas" || pathname.startsWith("/canvas/"); + const match = pathname.match(/^\/canvas\/([^/?#]+)/); + return { + isCanvasPage: pathname === "/canvas" || pathname.startsWith("/canvas/"), + projectId: match?.[1] || "", + }; } catch { - return false; + return { isCanvasPage: false, projectId: "" }; } })(); + const isCanvasPageRequest = canvasPageRequest.isCanvasPage; const requestTextContent = message.parts .filter((part): part is { type: "text"; text: string } => part.type === "text") .map((part) => part.text) .join(" ") .trim(); const autoContextText = typeof autoContext === "string" ? autoContext.trim() : ""; - const requestCanvasAgentContext = normalizeCanvasAgentContext( + const initialRequestCanvasAgentContext = normalizeCanvasAgentContext( contextPayload || undefined ); const canvasAgentFeatures = getCanvasAgentFeatureFlags(); @@ -910,7 +916,7 @@ export async function POST(request: Request) { composerMode !== "agent" && (contextPayload?.source === "canvas" || contextPayload?.source === "realtime-canvas" || - Boolean(requestCanvasAgentContext) || + Boolean(initialRequestCanvasAgentContext) || isCanvasPageRequest) && shouldBypassCanvasImagePointsPrecheckForAgentControl({ text: requestTextContent, @@ -920,6 +926,35 @@ export async function POST(request: Request) { hasWorkflowContext: Boolean(agentModePayload), agentEnabled: true, }); + const inferredCanvasProjectId = + contextPayload?.projectId || + initialRequestCanvasAgentContext?.projectId || + canvasPageRequest.projectId || + (isCanvasPageRequest ? id : ""); + const requestCanvasAgentContext: CanvasAgentContext | null = + initialRequestCanvasAgentContext || + (hasCanvasControlIntentFastPath && inferredCanvasProjectId + ? { + version: 1, + source: "canvas", + projectId: inferredCanvasProjectId, + viewport: { x: 0, y: 0, zoom: 1 }, + selectedNodeIds: [], + selectedNodes: [], + candidateNodes: [], + slots: [], + annotations: [], + focusPolicy: "auto", + } + : null); + const canvasAgentProjectId = + contextPayload?.projectId || + requestCanvasAgentContext?.projectId || + inferredCanvasProjectId || + ""; + const isCanvasAgentCanvasRequest = + contextPayload?.source === "canvas" || + (hasCanvasControlIntentFastPath && Boolean(canvasAgentProjectId)); if (requestCanvasAgentContext) { chatApiLogger.info("[CanvasAgentContext] Normalized request context:", { source: requestCanvasAgentContext.source, @@ -3401,7 +3436,7 @@ export async function POST(request: Request) { const resolveCanvasLinkOperationPlan = (): CanvasOperationPlan | null => { if (!canApplyCanvasAgentMutation) return null; if (composerMode !== "agent" && !hasCanvasControlIntentFastPath) return null; - if (contextPayload?.source !== "canvas" || !contextPayload.projectId) return null; + if (!isCanvasAgentCanvasRequest || !canvasAgentProjectId) return null; if (!requestCanvasAgentContext) return null; const text = requestTextContent.trim().toLowerCase(); @@ -3428,7 +3463,7 @@ export async function POST(request: Request) { version: 1, operationId: `${message.id}:canvas-link`, operation: "link_nodes", - projectId: contextPayload.projectId, + projectId: canvasAgentProjectId, baseCanvasRevision: requestCanvasAgentContext.canvasRevision, baseCanvasUpdatedAt: requestCanvasAgentContext.canvasUpdatedAt, sourceNodeIds: nodeIds, @@ -3471,7 +3506,7 @@ export async function POST(request: Request) { const resolveCanvasTextOperationPlan = (): CanvasOperationPlan | null => { if (!canApplyCanvasAgentMutation) return null; if (composerMode !== "agent" && !hasCanvasControlIntentFastPath) return null; - if (contextPayload?.source !== "canvas" || !contextPayload.projectId) return null; + if (!isCanvasAgentCanvasRequest || !canvasAgentProjectId) return null; if (!requestCanvasAgentContext) return null; const text = requestTextContent.trim(); @@ -3498,7 +3533,7 @@ export async function POST(request: Request) { version: 1, operationId: `${message.id}:canvas-update-text`, operation: "update_text", - projectId: contextPayload.projectId, + projectId: canvasAgentProjectId, baseCanvasRevision: requestCanvasAgentContext.canvasRevision, baseCanvasUpdatedAt: requestCanvasAgentContext.canvasUpdatedAt, targetNodeIds: selectedTextNodeIds, @@ -3516,12 +3551,12 @@ export async function POST(request: Request) { (): CanvasAgentExplicitOperationPlan | null => { if (!canApplyCanvasAgentMutation) return null; if (composerMode !== "agent" && !hasCanvasControlIntentFastPath) return null; - if (contextPayload?.source !== "canvas" || !contextPayload.projectId) return null; + if (!isCanvasAgentCanvasRequest || !canvasAgentProjectId) return null; if (!requestCanvasAgentContext) return null; return buildCanvasAgentExplicitOperationPlan({ context: requestCanvasAgentContext, - projectId: contextPayload.projectId, + projectId: canvasAgentProjectId, messageId: message.id, chatId: id, userText: requestTextContent, @@ -3534,7 +3569,7 @@ export async function POST(request: Request) { const resolveCanvasModelPlannerOutcome = async (): Promise => { if (composerMode !== "agent" && !hasCanvasControlIntentFastPath) return null; - if (contextPayload?.source !== "canvas" || !contextPayload.projectId) return null; + if (!isCanvasAgentCanvasRequest || !canvasAgentProjectId) return null; if (!requestCanvasAgentContext) return null; if ( !canvasAgentFeatures.viewOperationEnabled && @@ -3561,7 +3596,7 @@ export async function POST(request: Request) { model: plannerModel || myProvider.languageModel("chat-model"), userText: requestTextContent, context: requestCanvasAgentContext, - projectId: contextPayload.projectId, + projectId: canvasAgentProjectId, messageId: message.id, chatId: id, }); diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 0bf52c694..e3daa4029 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -5890,6 +5890,16 @@ export function CanvasChatPanel({ const effectiveComposerMode = activeComposerModeRef.current || activeComposerMode; const shouldIsolateDirectModeContext = effectiveComposerMode !== "agent"; + const shouldPreserveCanvasControlContext = + shouldBypassCanvasImagePointsPrecheckForAgentControl({ + text: input, + activeComposerMode: effectiveComposerMode, + attachmentCount: attachments.length, + hasSkillTag: Boolean(selectedSkillTag), + hasWorkflowContext, + isDetailGenerationMode, + agentEnabled: effectiveAgentEnabled, + }); const selectedImageNodesForContext = Array.from( new Map( [ @@ -5901,7 +5911,11 @@ export function CanvasChatPanel({ ).values() ); - if (shouldIsolateDirectModeContext && selectedImageNodesForContext.length === 0) { + if ( + shouldIsolateDirectModeContext && + selectedImageNodesForContext.length === 0 && + !shouldPreserveCanvasControlContext + ) { generationAnchorNodesRef.current = []; return undefined; } @@ -6172,10 +6186,16 @@ export function CanvasChatPanel({ }; }, [ activeComposerMode, + attachments.length, canvasId, canvasRevision, canvasUpdatedAt, + effectiveAgentEnabled, + hasWorkflowContext, + input, + isDetailGenerationMode, nodes, + selectedSkillTag, selectedImageNodes, selectedNodeIds, viewport, diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 3fdfd7f51..dd79c3abc 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -235,6 +235,7 @@ test.describe("canvas chat pure helpers", () => { expect(hasCanvasAgentNonGenerativeControlIntent("把画布放大一点")).toBe(true); expect(hasCanvasAgentNonGenerativeControlIntent("缩小看全局")).toBe(true); expect(hasCanvasAgentNonGenerativeControlIntent("新建一个图片槽位")).toBe(true); + expect(hasCanvasAgentNonGenerativeControlIntent("创建一个图片槽位")).toBe(true); expect(hasCanvasAgentNonGenerativeControlIntent("删除选中的节点")).toBe(true); expect(hasCanvasAgentNonGenerativeControlIntent("把选中图片背景换白")).toBe(false); expect(hasCanvasAgentNonGenerativeControlIntent("生成一张产品主图")).toBe(false); @@ -1126,6 +1127,31 @@ test.describe("canvas chat pure helpers", () => { }, }); expect(outcome?.reply).toContain("视频槽位"); + + const imageOutcome = buildCanvasAgentExplicitOperationPlan({ + context, + projectId: plannerContext.projectId || "", + messageId: "message-2", + chatId: "chat-1", + userText: "创建一个图片槽位", + }); + + expect(imageOutcome?.operationPlan).toMatchObject({ + operation: "create_agent_slot", + targetPosition: { x: 340, y: 240 }, + slot: { + slotType: "image", + label: "图片槽位", + aspectRatio: "1:1", + fillPolicy: "replace-empty", + }, + metadata: { + messageId: "message-2", + chatId: "chat-1", + planner: "deterministic", + }, + }); + expect(imageOutcome?.reply).toContain("图片槽位"); }); test("builds deterministic node rename plans without treating titles as image edits", () => {