diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 29066c50e..a415d432d 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -986,6 +986,7 @@ const CANVAS_TOOLBAR_FALLBACK_ICONS: Record = stylePro: Sparkles, batchProcessing: Layers, addImage: ImagePlus, + addAgentImageSlot: ImagePlus, addImageInput: ImagePlus, addText: Type, addScriptNode: Type, diff --git a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx index da40fd48d..d82d92b13 100644 --- a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx +++ b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx @@ -1926,7 +1926,7 @@ const AGENT_WORKFLOW_SETTINGS_FIELD_LABELS: Record
- 素材搜索接口 - 配置白底图、详情参考图、SKU 参考图共用接口 + 第三方图片素材配置 + + 配置白底图、任务-详情整套预览、详情参考图和 SKU 参考图共用接口 +
-
启用素材搜索
+
启用第三方图片素材
- 开启后支持搜索白底素材图、详情参考图和 SKU 参考图 + 开启后前台和预留接口都可以查询标准模型白底图与任务-详情整套素材
+
+ + + setMaterialApiConfig((prev) => ({ + ...prev, + endpointPath: e.target.value, + })) + } + placeholder="/standard-model-white-background" + /> +

+ 按文档默认调用 /api/external/standard-model-white-background;也支持填写完整 URL。 +

+
+
+
+
+
标准模型白底图
+
+ 对应 resourceType=white-background +
+
+ + setMaterialApiConfig((prev) => ({ + ...prev, + whiteBackgroundEnabled: checked, + })) + } + /> +
+
+
+
任务-详情整套预览
+
+ 对应 resourceType=task-detail +
+
+ + setMaterialApiConfig((prev) => ({ + ...prev, + taskDetailEnabled: checked, + })) + } + /> +
+
+ +
+
+ + + setMaterialApiConfig((prev) => ({ + ...prev, + resourceBaseUrl: e.target.value, + })) + } + placeholder="https://s3.jiansun.vip" + /> +
+
+ + + setMaterialApiConfig((prev) => ({ + ...prev, + fallbackResourceBaseUrl: e.target.value, + })) + } + placeholder="https://s3.yydd.ai" + /> +
+
+ + + setMaterialApiConfig((prev) => ({ + ...prev, + requestTimeoutMs: Number(e.target.value) || 30000, + })) + } + /> +
+
+ +
+ 已预留前端接口: + + /api/third-party-image-materials/search + + ,支持 + + resourceType=white-background|task-detail + + 与 + + sku/spu + + 查询。 +
+
diff --git a/app/(chat)/api/chat/_lib/stream.ts b/app/(chat)/api/chat/_lib/stream.ts index d7edcb496..33c390494 100644 --- a/app/(chat)/api/chat/_lib/stream.ts +++ b/app/(chat)/api/chat/_lib/stream.ts @@ -6,6 +6,7 @@ import type { ResolvedImageRef, ResolvedTargetAssets, } from "@/lib/ai/context-memory/types"; +import type { CanvasViewOperationPlan } from "@/types/canvas/agent-protocol"; import { generateUUID } from "@/lib/utils"; const chatApiLogger = createLogger("chat.stream"); @@ -197,6 +198,8 @@ export function writeCanvasMediaChunk( agentCategoryId?: string | null; agentCategoryName?: string | null; agentExecutionSnapshotId?: string | null; + mediaItemIds?: string[] | null; + handledCanvasMediaItemIds?: string[] | null; }, label: string ) { @@ -242,6 +245,79 @@ export function writeCanvasMediaChunk( agentCategoryName: String(params.agentCategoryName || "").trim() || undefined, agentExecutionSnapshotId: String(params.agentExecutionSnapshotId || "").trim() || undefined, + mediaItemIds: Array.isArray(params.mediaItemIds) + ? params.mediaItemIds + .map((id) => String(id || "").trim()) + .filter(Boolean) + : [], + handledCanvasMediaItemIds: Array.isArray(params.handledCanvasMediaItemIds) + ? params.handledCanvasMediaItemIds + .map((id) => String(id || "").trim()) + .filter(Boolean) + : [], + }, + label + ); +} + +export function writeCanvasOperationChunk( + writer: StreamWriter, + params: { + projectId: string; + operationId: string; + operation: string; + createdNodeIds: string[]; + createdNodeIdsByMediaItemId?: Record; + updatedNodeIds?: string[]; + createdEdgeIds?: string[]; + canvasRevision?: string; + anchorNodeId?: string | null; + handledCanvasMediaItemIds?: string[]; + message?: string; + }, + label: string +) { + writeStructuredDataChunk( + writer, + "data-canvasOperation", + { + projectId: params.projectId, + operationId: params.operationId, + operation: params.operation, + createdNodeIds: params.createdNodeIds || [], + createdNodeIdsByMediaItemId: params.createdNodeIdsByMediaItemId || {}, + updatedNodeIds: params.updatedNodeIds || [], + createdEdgeIds: params.createdEdgeIds || [], + canvasRevision: params.canvasRevision, + anchorNodeId: params.anchorNodeId || undefined, + handledCanvasMediaItemIds: params.handledCanvasMediaItemIds || [], + message: params.message, + }, + label + ); +} + +export function writeCanvasViewOperationChunk( + writer: StreamWriter, + params: CanvasViewOperationPlan, + label: string +) { + writeStructuredDataChunk( + writer, + "data-canvasViewOperation", + { + version: 1, + viewOperationId: params.viewOperationId, + operation: params.operation, + projectId: params.projectId, + nodeIds: params.nodeIds || [], + anchorNodeId: params.anchorNodeId, + zoom: params.zoom, + padding: params.padding, + durationMs: params.durationMs, + persist: false, + center: params.center, + reason: params.reason, }, label ); diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index b571480c3..e134f7d99 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -45,8 +45,31 @@ import { import { buildAgentSceneImageRolePrompt } from "@/lib/agents/scene-image-role-prompt"; import { optimizeAgentSceneWorkflowPrompt } from "@/lib/agents/scene-workflow-prompt-optimizer"; import { getEnabledTools } from "@/lib/ai/tools-registry"; -import { normalizeCanvasAgentContext } from "@/lib/canvas/agent-context"; +import { + getBestAnchorNode, + getSelectedMediaNodes, + getSelectedSlots, + normalizeCanvasAgentContext, +} from "@/lib/canvas/agent-context"; +import { + buildPendingCanvasOperation, + extractPendingCanvasOperationFromWorkingState, + isCanvasOperationCancelText, + isCanvasOperationConfirmText, + withConfirmedCanvasOperationPlan, +} from "@/lib/canvas/agent-confirmation"; +import { + planCanvasAgentOperationWithModel, + type CanvasAgentModelPlannerOutcome, +} from "@/lib/canvas/agent-model-planner"; +import { applyCanvasOperationPlan } from "@/lib/canvas/agent-mutations"; import { isProductionEnvironment } from "@/lib/constants"; +import type { + CanvasMediaItem, + CanvasOperationKind, + CanvasOperationPlan, + CanvasViewOperationPlan, +} from "@/types/canvas/agent-protocol"; import { createStreamId, deleteChatById, @@ -73,6 +96,7 @@ import { getConversationWorkingStateByChatId, getMediaAssetsByUserAndUrls, getSiteSettings, + upsertConversationWorkingState, upsertMediaAsset, } from "@/lib/db/queries"; import { db } from "@/lib/db"; @@ -326,6 +350,8 @@ import { writeAgentTargetLockChunk, writeAssistantTextChunk, writeCanvasMediaChunk, + writeCanvasOperationChunk, + writeCanvasViewOperationChunk, writeImageGenerationStatusChunk, writeProtocolDebugChunk, } from "./_lib/stream"; @@ -2639,9 +2665,1136 @@ export async function POST(request: Request) { writeAgentTargetLockChunk(writer, payload, label); }; + const getConversationWorkingSummary = () => + requestWorkingState?.lastTurnSummary && + typeof requestWorkingState.lastTurnSummary === "object" && + !Array.isArray(requestWorkingState.lastTurnSummary) + ? (requestWorkingState.lastTurnSummary as Record) + : {}; + + const persistPendingCanvasOperation = async ( + pendingCanvasOperation: ReturnType | null + ) => { + await upsertConversationWorkingState({ + userId: session.user.id, + chatId: id, + projectId: pendingCanvasOperation?.projectId || contextPayload?.projectId || null, + currentIntent: pendingCanvasOperation ? "canvas_operation" : undefined, + lastMessageId: message.id, + lastTurnSummary: { + ...getConversationWorkingSummary(), + pendingCanvasOperation, + }, + }).catch((error) => { + chatApiLogger.warn("[CanvasAgentConfirmation] Failed to persist pending operation:", error); + return null; + }); + }; + + const applyDirectAgentCanvasWriteback = async (params: { + writer: StreamWriter; + assistantMessageId: string; + operationLabel: string; + resultType: "image" | "video"; + resultUrls: string[]; + prompt: string; + mode: "generate" | "edit" | "video"; + sourceImageUrls?: string[]; + assetIdsByUrl?: Record; + model?: string | null; + }): Promise<{ + mediaItemIds: string[]; + handledCanvasMediaItemIds: string[]; + }> => { + const projectId = contextPayload?.projectId; + if ( + contextPayload?.source !== "canvas" || + !projectId || + !requestCanvasAgentContext + ) { + return { mediaItemIds: [], handledCanvasMediaItemIds: [] }; + } + + const resultUrls = params.resultUrls + .map((url) => String(url || "").trim()) + .filter((url) => /^https?:\/\//i.test(url)); + if (resultUrls.length === 0) { + return { mediaItemIds: [], handledCanvasMediaItemIds: [] }; + } + + const selectedSlot = getSelectedSlots(requestCanvasAgentContext)[0] || null; + const selectedMediaNodes = getSelectedMediaNodes(requestCanvasAgentContext); + const anchorNode = + selectedSlot || selectedMediaNodes[0] || getBestAnchorNode(requestCanvasAgentContext); + const targetRefs = requestTargetResolution?.targetRefs || []; + const targetNodeIds = Array.from( + new Set( + targetRefs + .map((ref) => ref.nodeId) + .filter((value): value is string => Boolean(value)) + ) + ); + const selectedMediaNodeIds = selectedMediaNodes + .map((node) => node.nodeId) + .filter((value): value is string => Boolean(value)); + const perResultSourceNodeIds = + !selectedSlot && resultUrls.length > 1 + ? [targetNodeIds, selectedMediaNodeIds].find( + (nodeIds) => nodeIds.length === resultUrls.length + ) || [] + : []; + const sourceNodeIds = Array.from( + new Set( + [ + ...targetNodeIds, + ...selectedMediaNodeIds, + anchorNode?.nodeId, + ].filter((value): value is string => Boolean(value)) + ) + ); + const operation: CanvasOperationKind = selectedSlot + ? "fill_slot" + : params.mode === "edit" + ? "append_revision" + : "insert_beside"; + const mediaItems: CanvasMediaItem[] = resultUrls.map((url, index) => { + const mediaItemId = `${params.assistantMessageId}:${params.operationLabel}:${index + 1}`; + return { + mediaItemId, + type: params.resultType, + url, + assetId: params.assetIdsByUrl?.[url], + title: params.resultType === "video" ? "Agent 视频结果" : "Agent 图片结果", + prompt: params.prompt, + sourceNodeIds: perResultSourceNodeIds[index] + ? [perResultSourceNodeIds[index]] + : sourceNodeIds, + metadata: { + messageId: params.assistantMessageId, + chatId: id, + model: params.model || undefined, + sourceImageUrls: params.sourceImageUrls || [], + }, + }; + }); + const operationId = `${params.assistantMessageId}:${params.operationLabel}`; + const plan: CanvasOperationPlan = { + version: 1, + operationId, + operation, + projectId, + baseCanvasRevision: requestCanvasAgentContext.canvasRevision, + baseCanvasUpdatedAt: requestCanvasAgentContext.canvasUpdatedAt, + sourceNodeIds, + targetSlotId: selectedSlot?.nodeId, + anchorNodeId: selectedSlot ? undefined : anchorNode?.nodeId, + placement: "right", + placementStrategy: + perResultSourceNodeIds.length === mediaItems.length + ? "beside_each_source" + : mediaItems.length > 1 + ? "grid" + : "single", + preserveOriginal: true, + mediaItems, + metadata: { + messageId: params.assistantMessageId, + chatId: id, + mode: params.mode, + prompt: params.prompt || requestTextContent, + }, + }; + + try { + const result = await applyCanvasOperationPlan({ + userId: session.user.id, + projectId, + plan, + }); + const handledCanvasMediaItemIds = mediaItems.map((item) => item.mediaItemId); + writeCanvasOperationChunk( + params.writer, + { + projectId, + operationId, + operation, + createdNodeIds: result.createdNodeIds, + createdNodeIdsByMediaItemId: result.createdNodeIdsByMediaItemId, + updatedNodeIds: result.updatedNodeIds, + createdEdgeIds: result.createdEdgeIds, + canvasRevision: result.canvasRevision, + anchorNodeId: plan.anchorNodeId || plan.targetSlotId || null, + handledCanvasMediaItemIds, + message: selectedSlot ? "已填充画布槽位" : "已写回画布", + }, + `${params.operationLabel}:canvas-operation`, + ); + return { + mediaItemIds: handledCanvasMediaItemIds, + handledCanvasMediaItemIds, + }; + } catch (error) { + chatApiLogger.warn("[CanvasAgentOperation] Writeback failed, fallback to canvasMedia:", { + operationId, + error, + }); + return { + mediaItemIds: mediaItems.map((item) => item.mediaItemId), + handledCanvasMediaItemIds: [], + }; + } + }; + + const resolveCanvasViewOperationPlan = (): CanvasViewOperationPlan | null => { + if (composerMode !== "agent") return null; + if (contextPayload?.source !== "canvas" || !contextPayload.projectId) return null; + if (!requestCanvasAgentContext) return null; + + const text = requestTextContent.trim(); + if (!text) return null; + + const lowerText = text.toLowerCase(); + const hasViewKeyword = + /视角|视野|画布|聚焦|定位|居中|看全局|看整体|看全部|缩小|放大|拉近|拉远|zoom/.test( + lowerText + ); + if (!hasViewKeyword) return null; + + const hasMediaMutationKeyword = + /生成|出图|做一版|再出|换|改|编辑|抠图|去水印|高清|清晰|扩图|裁剪|删除|替换|背景|视频|模型|洗图|重绘|尺寸|像素|分辨率/.test( + lowerText + ); + if (hasMediaMutationKeyword) return null; + + const explicitTargetNodeIds = Array.from( + new Set( + [ + ...(requestTargetResolution?.targetRefs || []) + .map((ref) => ref.nodeId) + .filter(Boolean), + ...(requestCanvasAgentContext.selectedNodeIds || []), + ].filter((value): value is string => Boolean(value)) + ) + ).slice(0, 20); + const anchorNode = + getBestAnchorNode(requestCanvasAgentContext) || + getSelectedMediaNodes(requestCanvasAgentContext)[0] || + requestCanvasAgentContext.selectedNodes[0] || + null; + const hasSpecificFocusTarget = + explicitTargetNodeIds.length > 0 || Boolean(anchorNode?.nodeId); + const viewOperationId = `${message.id}:canvas-view:${Date.now()}`; + + if (/看全局|看整体|看全部|全局|整体/.test(lowerText)) { + return { + version: 1, + viewOperationId, + operation: "fit_all", + projectId: contextPayload.projectId, + padding: 140, + persist: false, + reason: "fit_all_canvas", + }; + } + + if (/缩小|拉远|zoom\s*out/.test(lowerText)) { + return { + version: 1, + viewOperationId, + operation: "zoom_out", + projectId: contextPayload.projectId, + persist: false, + reason: "zoom_out_canvas", + }; + } + + if ( + /聚焦|定位|居中|选中|这张|当前|主体|编辑对象|放大.*看|看清/.test(lowerText) && + hasSpecificFocusTarget + ) { + const nodeIds = + explicitTargetNodeIds.length > 0 + ? explicitTargetNodeIds + : anchorNode?.nodeId + ? [anchorNode.nodeId] + : []; + return { + version: 1, + viewOperationId, + operation: nodeIds.length > 1 ? "focus_nodes" : "focus_selection", + projectId: contextPayload.projectId, + nodeIds, + anchorNodeId: anchorNode?.nodeId, + padding: 120, + persist: false, + reason: "focus_canvas_target", + }; + } + + if (/放大|拉近|zoom\s*in/.test(lowerText)) { + return { + version: 1, + viewOperationId, + operation: "zoom_in", + projectId: contextPayload.projectId, + persist: false, + reason: "zoom_in_canvas", + }; + } + + return null; + }; + + const resolveCanvasLinkOperationPlan = (): CanvasOperationPlan | null => { + if (composerMode !== "agent") return null; + if (contextPayload?.source !== "canvas" || !contextPayload.projectId) return null; + if (!requestCanvasAgentContext) return null; + + const text = requestTextContent.trim().toLowerCase(); + if (!/连起来|连线|连接|关联|参考关系|版本关系/.test(text)) { + return null; + } + if (/生成|出图|换|编辑|抠图|删除|替换|背景|视频|洗图|重绘/.test(text)) { + return null; + } + + const nodeIds = Array.from( + new Set( + [ + ...(requestTargetResolution?.targetRefs || []) + .map((ref) => ref.nodeId) + .filter(Boolean), + ...(requestCanvasAgentContext.selectedNodeIds || []), + ].filter((value): value is string => Boolean(value)) + ) + ).slice(0, 20); + if (nodeIds.length < 2) return null; + + return { + version: 1, + operationId: `${message.id}:canvas-link`, + operation: "link_nodes", + projectId: contextPayload.projectId, + baseCanvasRevision: requestCanvasAgentContext.canvasRevision, + baseCanvasUpdatedAt: requestCanvasAgentContext.canvasUpdatedAt, + sourceNodeIds: nodeIds, + edgeType: "reference", + preserveOriginal: true, + metadata: { + messageId: message.id, + chatId: id, + prompt: requestTextContent, + }, + }; + }; + + const extractCanvasTextUpdateContent = (rawText: string) => { + const text = rawText.trim(); + const hasExplicitFinalTextDelimiter = /[::"“'‘]/.test(text); + const patterns = [ + /(?:改成|改为|改到|替换为|设置为|设为|写成|变成)\s*[::]?\s*([\s\S]+)$/i, + /[::]\s*([\s\S]+)$/i, + ]; + for (const pattern of patterns) { + const match = text.match(pattern); + const content = String(match?.[1] || "") + .trim() + .replace(/^["'“”‘’]+|["'“”‘’。;;]+$/g, "") + .trim(); + if (!content) continue; + const isVagueRewriteInstruction = + !hasExplicitFinalTextDelimiter && + content.length <= 24 && + /更|高级|高级感|优化|润色|简洁|专业|吸引|好看|一点|一些|风格|语气/.test( + content + ); + if (isVagueRewriteInstruction) return ""; + return content.slice(0, 4000); + } + return ""; + }; + + const resolveCanvasTextOperationPlan = (): CanvasOperationPlan | null => { + if (composerMode !== "agent") return null; + if (contextPayload?.source !== "canvas" || !contextPayload.projectId) return null; + if (!requestCanvasAgentContext) return null; + + const text = requestTextContent.trim(); + if (!text) return null; + const hasTextIntent = + /文本|文字|文案|标题|这段|选中/.test(text) && + /改成|改为|改到|替换为|设置为|设为|写成|变成/.test(text); + if (!hasTextIntent) return null; + if (/图片|背景|抠图|视频|生成|出图|高清|去水印|扩图|重绘/.test(text)) { + return null; + } + + const selectedTextNodeIds = requestCanvasAgentContext.selectedNodes + .filter((node) => node.type === "text") + .map((node) => node.nodeId) + .filter(Boolean) + .slice(0, 10); + if (selectedTextNodeIds.length === 0) return null; + + const textContent = extractCanvasTextUpdateContent(text); + if (!textContent) return null; + + return { + version: 1, + operationId: `${message.id}:canvas-update-text`, + operation: "update_text", + projectId: contextPayload.projectId, + baseCanvasRevision: requestCanvasAgentContext.canvasRevision, + baseCanvasUpdatedAt: requestCanvasAgentContext.canvasUpdatedAt, + targetNodeIds: selectedTextNodeIds, + textContent, + preserveOriginal: false, + metadata: { + messageId: message.id, + chatId: id, + prompt: requestTextContent, + }, + }; + }; + + const resolveCanvasModelPlannerOutcome = + async (): Promise => { + if (composerMode !== "agent") return null; + if (contextPayload?.source !== "canvas" || !contextPayload.projectId) return null; + if (!requestCanvasAgentContext) return null; + + const plannerModelId = + configuredAgentConversationModelId || + (requestedModelIsFixedVisual ? undefined : preferredAgentModelId); + const plannerModel = plannerModelId + ? await getLanguageModelById(plannerModelId).catch((error) => { + chatApiLogger.warn( + "[CanvasAgentModelPlanner] Failed to resolve preferred planner model:", + error + ); + return null; + }) + : null; + + try { + return await planCanvasAgentOperationWithModel({ + model: plannerModel || myProvider.languageModel("chat-model"), + userText: requestTextContent, + context: requestCanvasAgentContext, + projectId: contextPayload.projectId, + messageId: message.id, + chatId: id, + }); + } catch (error) { + chatApiLogger.warn("[CanvasAgentModelPlanner] Planning failed:", error); + return null; + } + }; + const streamId = generateUUID(); await createStreamId({ streamId, chatId: id }); + const pendingCanvasOperation = + extractPendingCanvasOperationFromWorkingState(requestWorkingState); + if ( + pendingCanvasOperation && + (isCanvasOperationConfirmText(requestTextContent) || + isCanvasOperationCancelText(requestTextContent)) + ) { + const isCancelPendingCanvasOperation = + isCanvasOperationCancelText(requestTextContent); + const isConfirm = !isCancelPendingCanvasOperation; + const pendingCanvasOperationStream = createUIMessageStream({ + execute: async ({ writer }) => { + const messageId = generateUUID(); + + if (!isConfirm) { + const reply = "已取消这次待确认的画布操作。"; + await persistPendingCanvasOperation(null); + writeAgentProgressChunk( + writer, + { + stage: "completed", + currentStep: "finalize", + title: "已取消画布操作", + message: reply, + toolName: "canvasOperation", + capability: pendingCanvasOperation.riskOperation, + }, + "canvas-pending-operation:cancelled", + ); + writeAssistantTextChunk( + writer, + reply, + "canvas-pending-operation:cancelled-reply", + messageId, + ); + await saveMessages({ + messages: [ + { + id: messageId, + chatId: id, + role: "assistant", + parts: [{ type: "text", text: reply }], + attachments: [], + createdAt: new Date(), + videoStorageStatus: null, + temporaryVideoUrl: null, + permanentVideoUrl: null, + archivedAt: null, + videoMetadata: null, + }, + ], + }); + return; + } + + const confirmedPlan = withConfirmedCanvasOperationPlan( + pendingCanvasOperation, + message.id + ); + if (!confirmedPlan) { + const reply = "已收到确认,但这个高风险画布操作当前还没有开放自动执行。"; + await persistPendingCanvasOperation(null); + writeAgentProgressChunk( + writer, + { + stage: "completed", + currentStep: "finalize", + title: "画布操作未执行", + message: reply, + toolName: "canvasOperation", + capability: pendingCanvasOperation.riskOperation, + }, + "canvas-pending-operation:unsupported", + ); + writeAssistantTextChunk( + writer, + reply, + "canvas-pending-operation:unsupported-reply", + messageId, + ); + await saveMessages({ + messages: [ + { + id: messageId, + chatId: id, + role: "assistant", + parts: [{ type: "text", text: reply }], + attachments: [], + createdAt: new Date(), + videoStorageStatus: null, + temporaryVideoUrl: null, + permanentVideoUrl: null, + archivedAt: null, + videoMetadata: null, + }, + ], + }); + return; + } + + const reply = "已确认,画布操作已执行。"; + writeAgentProgressChunk( + writer, + { + stage: "executing", + currentStep: "execute", + title: "正在执行已确认的画布操作", + message: reply, + toolName: "canvasOperation", + capability: confirmedPlan.operation, + }, + "canvas-pending-operation:executing", + ); + + try { + const result = await applyCanvasOperationPlan({ + userId: session.user.id, + projectId: confirmedPlan.projectId, + plan: confirmedPlan, + }); + await persistPendingCanvasOperation(null); + writeCanvasOperationChunk( + writer, + { + projectId: confirmedPlan.projectId, + operationId: confirmedPlan.operationId, + operation: confirmedPlan.operation, + createdNodeIds: result.createdNodeIds, + createdNodeIdsByMediaItemId: result.createdNodeIdsByMediaItemId, + updatedNodeIds: result.updatedNodeIds, + createdEdgeIds: result.createdEdgeIds, + canvasRevision: result.canvasRevision, + anchorNodeId: + confirmedPlan.targetNodeIds?.[0] || + confirmedPlan.sourceNodeIds?.[0] || + confirmedPlan.anchorNodeId || + null, + handledCanvasMediaItemIds: [], + message: reply, + }, + "canvas-pending-operation:operation", + ); + if (pendingCanvasOperation.viewPlan) { + writeCanvasViewOperationChunk( + writer, + pendingCanvasOperation.viewPlan, + "canvas-pending-operation:view-operation", + ); + } + writeAgentProgressChunk( + writer, + { + stage: "completed", + currentStep: "finalize", + title: "画布操作完成", + message: reply, + toolName: "canvasOperation", + capability: confirmedPlan.operation, + }, + "canvas-pending-operation:completed", + ); + writeAssistantTextChunk( + writer, + reply, + "canvas-pending-operation:reply", + messageId, + ); + await saveMessages({ + messages: [ + { + id: messageId, + chatId: id, + role: "assistant", + parts: [{ type: "text", text: reply }], + attachments: [], + createdAt: new Date(), + videoStorageStatus: null, + temporaryVideoUrl: null, + permanentVideoUrl: null, + archivedAt: null, + videoMetadata: null, + }, + ], + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "执行已确认的画布操作失败"; + writeAgentProgressChunk( + writer, + { + stage: "failed", + currentStep: "finalize", + title: "画布操作失败", + message: errorMessage, + toolName: "canvasOperation", + capability: confirmedPlan.operation, + }, + "canvas-pending-operation:failed", + ); + writeAssistantTextChunk( + writer, + errorMessage, + "canvas-pending-operation:error", + messageId, + ); + } + }, + }); + + return createSseResponse(pendingCanvasOperationStream); + } + + const canvasViewOperationPlan = resolveCanvasViewOperationPlan(); + if (canvasViewOperationPlan) { + const canvasViewReply = + canvasViewOperationPlan.operation === "fit_all" + ? "已缩小到画布全局视图。" + : canvasViewOperationPlan.operation === "zoom_in" + ? "已放大画布视角。" + : canvasViewOperationPlan.operation === "zoom_out" + ? "已缩小画布视角。" + : "已聚焦到当前编辑对象。"; + const canvasViewStream = createUIMessageStream({ + execute: async ({ writer }) => { + const messageId = generateUUID(); + writeAgentProgressChunk( + writer, + { + stage: "completed", + currentStep: "finalize", + title: "正在调整画布视角", + message: canvasViewReply, + toolName: "canvasView", + capability: canvasViewOperationPlan.operation, + }, + "canvas-view:progress", + ); + writeCurrentAgentTargetLockChunk(writer, "canvas-view:target-lock"); + writeCanvasViewOperationChunk( + writer, + canvasViewOperationPlan, + "canvas-view:operation", + ); + writeAssistantTextChunk(writer, canvasViewReply, "canvas-view:reply", messageId); + + await saveMessages({ + messages: [ + { + id: messageId, + chatId: id, + role: "assistant", + parts: [{ type: "text", text: canvasViewReply }], + attachments: [], + createdAt: new Date(), + videoStorageStatus: null, + temporaryVideoUrl: null, + permanentVideoUrl: null, + archivedAt: null, + videoMetadata: null, + }, + ], + }); + }, + }); + + return createSseResponse(canvasViewStream); + } + + const canvasLinkOperationPlan = resolveCanvasLinkOperationPlan(); + if (canvasLinkOperationPlan) { + const canvasLinkReply = "已为选中的画布节点建立参考连线。"; + const canvasLinkStream = createUIMessageStream({ + execute: async ({ writer }) => { + const messageId = generateUUID(); + writeAgentProgressChunk( + writer, + { + stage: "executing", + currentStep: "execute", + title: "正在连接画布节点", + message: "我会保留原节点,只新增参考连线。", + toolName: "canvasOperation", + capability: "link_nodes", + }, + "canvas-link:progress-executing", + ); + try { + const result = await applyCanvasOperationPlan({ + userId: session.user.id, + projectId: canvasLinkOperationPlan.projectId, + plan: canvasLinkOperationPlan, + }); + writeCanvasOperationChunk( + writer, + { + projectId: canvasLinkOperationPlan.projectId, + operationId: canvasLinkOperationPlan.operationId, + operation: canvasLinkOperationPlan.operation, + createdNodeIds: result.createdNodeIds, + createdNodeIdsByMediaItemId: result.createdNodeIdsByMediaItemId, + updatedNodeIds: result.updatedNodeIds, + createdEdgeIds: result.createdEdgeIds, + canvasRevision: result.canvasRevision, + anchorNodeId: canvasLinkOperationPlan.sourceNodeIds?.[0] || null, + handledCanvasMediaItemIds: [], + message: canvasLinkReply, + }, + "canvas-link:operation", + ); + writeCanvasViewOperationChunk( + writer, + { + version: 1, + viewOperationId: `${canvasLinkOperationPlan.operationId}:view`, + operation: "focus_nodes", + projectId: canvasLinkOperationPlan.projectId, + nodeIds: canvasLinkOperationPlan.sourceNodeIds, + padding: 140, + persist: false, + reason: "focus_linked_nodes", + }, + "canvas-link:view-operation", + ); + writeAgentProgressChunk( + writer, + { + stage: "completed", + currentStep: "finalize", + title: "画布连线完成", + message: canvasLinkReply, + toolName: "canvasOperation", + capability: "link_nodes", + }, + "canvas-link:progress-completed", + ); + writeAssistantTextChunk(writer, canvasLinkReply, "canvas-link:reply", messageId); + + await saveMessages({ + messages: [ + { + id: messageId, + chatId: id, + role: "assistant", + parts: [{ type: "text", text: canvasLinkReply }], + attachments: [], + createdAt: new Date(), + videoStorageStatus: null, + temporaryVideoUrl: null, + permanentVideoUrl: null, + archivedAt: null, + videoMetadata: null, + }, + ], + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "连接画布节点失败"; + writeAgentProgressChunk( + writer, + { + stage: "failed", + currentStep: "finalize", + title: "画布连线失败", + message: errorMessage, + toolName: "canvasOperation", + capability: "link_nodes", + }, + "canvas-link:progress-failed", + ); + writeAssistantTextChunk(writer, errorMessage, "canvas-link:error", messageId); + } + }, + }); + + return createSseResponse(canvasLinkStream); + } + + const canvasTextOperationPlan = resolveCanvasTextOperationPlan(); + if (canvasTextOperationPlan) { + const canvasTextReply = "已更新选中的文本节点。"; + const canvasTextStream = createUIMessageStream({ + execute: async ({ writer }) => { + const messageId = generateUUID(); + writeAgentProgressChunk( + writer, + { + stage: "executing", + currentStep: "execute", + title: "正在更新画布文本", + message: "我会只修改选中的文本节点内容。", + toolName: "canvasOperation", + capability: "update_text", + }, + "canvas-text:progress-executing", + ); + try { + const result = await applyCanvasOperationPlan({ + userId: session.user.id, + projectId: canvasTextOperationPlan.projectId, + plan: canvasTextOperationPlan, + }); + writeCanvasOperationChunk( + writer, + { + projectId: canvasTextOperationPlan.projectId, + operationId: canvasTextOperationPlan.operationId, + operation: canvasTextOperationPlan.operation, + createdNodeIds: result.createdNodeIds, + createdNodeIdsByMediaItemId: result.createdNodeIdsByMediaItemId, + updatedNodeIds: result.updatedNodeIds, + createdEdgeIds: result.createdEdgeIds, + canvasRevision: result.canvasRevision, + anchorNodeId: canvasTextOperationPlan.targetNodeIds?.[0] || null, + handledCanvasMediaItemIds: [], + message: canvasTextReply, + }, + "canvas-text:operation", + ); + writeCanvasViewOperationChunk( + writer, + { + version: 1, + viewOperationId: `${canvasTextOperationPlan.operationId}:view`, + operation: "focus_nodes", + projectId: canvasTextOperationPlan.projectId, + nodeIds: canvasTextOperationPlan.targetNodeIds, + padding: 120, + persist: false, + reason: "focus_updated_text_nodes", + }, + "canvas-text:view-operation", + ); + writeAgentProgressChunk( + writer, + { + stage: "completed", + currentStep: "finalize", + title: "画布文本已更新", + message: canvasTextReply, + toolName: "canvasOperation", + capability: "update_text", + }, + "canvas-text:progress-completed", + ); + writeAssistantTextChunk(writer, canvasTextReply, "canvas-text:reply", messageId); + + await saveMessages({ + messages: [ + { + id: messageId, + chatId: id, + role: "assistant", + parts: [{ type: "text", text: canvasTextReply }], + attachments: [], + createdAt: new Date(), + videoStorageStatus: null, + temporaryVideoUrl: null, + permanentVideoUrl: null, + archivedAt: null, + videoMetadata: null, + }, + ], + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "更新画布文本失败"; + writeAgentProgressChunk( + writer, + { + stage: "failed", + currentStep: "finalize", + title: "画布文本更新失败", + message: errorMessage, + toolName: "canvasOperation", + capability: "update_text", + }, + "canvas-text:progress-failed", + ); + writeAssistantTextChunk(writer, errorMessage, "canvas-text:error", messageId); + } + }, + }); + + return createSseResponse(canvasTextStream); + } + + const canvasModelPlannerOutcome = await resolveCanvasModelPlannerOutcome(); + if (canvasModelPlannerOutcome && canvasModelPlannerOutcome.kind !== "none") { + const canvasModelPlannerStream = createUIMessageStream({ + execute: async ({ writer }) => { + const messageId = generateUUID(); + + if (canvasModelPlannerOutcome.kind === "confirmation_required") { + const pendingOperation = buildPendingCanvasOperation({ + projectId: contextPayload?.projectId || canvasModelPlannerOutcome.operationPlan?.projectId || "", + messageId: message.id, + userText: requestTextContent, + riskOperation: canvasModelPlannerOutcome.riskOperation, + targetNodeIds: canvasModelPlannerOutcome.targetNodeIds, + reply: canvasModelPlannerOutcome.reply, + operationPlan: canvasModelPlannerOutcome.operationPlan, + viewPlan: canvasModelPlannerOutcome.viewPlan, + }); + await persistPendingCanvasOperation(pendingOperation); + writeAgentProgressChunk( + writer, + { + stage: "clarify", + currentStep: "finalize", + title: "画布操作需要确认", + message: canvasModelPlannerOutcome.reply, + toolName: "canvasOperation", + capability: canvasModelPlannerOutcome.riskOperation, + }, + "canvas-model-planner:confirmation", + ); + writeCurrentAgentTargetLockChunk(writer, "canvas-model-planner:target-lock"); + writeAssistantTextChunk( + writer, + canvasModelPlannerOutcome.reply, + "canvas-model-planner:reply", + messageId, + ); + await saveMessages({ + messages: [ + { + id: messageId, + chatId: id, + role: "assistant", + parts: [{ type: "text", text: canvasModelPlannerOutcome.reply }], + attachments: [], + createdAt: new Date(), + videoStorageStatus: null, + temporaryVideoUrl: null, + permanentVideoUrl: null, + archivedAt: null, + videoMetadata: null, + }, + ], + }); + return; + } + + if (canvasModelPlannerOutcome.kind === "view") { + writeAgentProgressChunk( + writer, + { + stage: "completed", + currentStep: "finalize", + title: "正在调整画布视角", + message: canvasModelPlannerOutcome.reply, + toolName: "canvasView", + capability: canvasModelPlannerOutcome.viewPlan.operation, + }, + "canvas-model-planner:view-progress", + ); + writeCurrentAgentTargetLockChunk(writer, "canvas-model-planner:target-lock"); + writeCanvasViewOperationChunk( + writer, + canvasModelPlannerOutcome.viewPlan, + "canvas-model-planner:view-operation", + ); + writeAssistantTextChunk( + writer, + canvasModelPlannerOutcome.reply, + "canvas-model-planner:reply", + messageId, + ); + await saveMessages({ + messages: [ + { + id: messageId, + chatId: id, + role: "assistant", + parts: [{ type: "text", text: canvasModelPlannerOutcome.reply }], + attachments: [], + createdAt: new Date(), + videoStorageStatus: null, + temporaryVideoUrl: null, + permanentVideoUrl: null, + archivedAt: null, + videoMetadata: null, + }, + ], + }); + return; + } + + const operationPlan = canvasModelPlannerOutcome.operationPlan; + writeAgentProgressChunk( + writer, + { + stage: "executing", + currentStep: "execute", + title: "正在执行模型规划的画布操作", + message: canvasModelPlannerOutcome.reply, + toolName: "canvasOperation", + capability: operationPlan.operation, + }, + "canvas-model-planner:operation-progress", + ); + writeCurrentAgentTargetLockChunk(writer, "canvas-model-planner:target-lock"); + + try { + const result = await applyCanvasOperationPlan({ + userId: session.user.id, + projectId: operationPlan.projectId, + plan: operationPlan, + }); + writeCanvasOperationChunk( + writer, + { + projectId: operationPlan.projectId, + operationId: operationPlan.operationId, + operation: operationPlan.operation, + createdNodeIds: result.createdNodeIds, + createdNodeIdsByMediaItemId: result.createdNodeIdsByMediaItemId, + updatedNodeIds: result.updatedNodeIds, + createdEdgeIds: result.createdEdgeIds, + canvasRevision: result.canvasRevision, + anchorNodeId: + operationPlan.targetNodeIds?.[0] || + operationPlan.sourceNodeIds?.[0] || + operationPlan.anchorNodeId || + null, + handledCanvasMediaItemIds: [], + message: canvasModelPlannerOutcome.reply, + }, + "canvas-model-planner:operation", + ); + if (canvasModelPlannerOutcome.viewPlan) { + writeCanvasViewOperationChunk( + writer, + canvasModelPlannerOutcome.viewPlan, + "canvas-model-planner:view-operation", + ); + } + writeAgentProgressChunk( + writer, + { + stage: "completed", + currentStep: "finalize", + title: "画布操作完成", + message: canvasModelPlannerOutcome.reply, + toolName: "canvasOperation", + capability: operationPlan.operation, + }, + "canvas-model-planner:progress-completed", + ); + writeAssistantTextChunk( + writer, + canvasModelPlannerOutcome.reply, + "canvas-model-planner:reply", + messageId, + ); + + await saveMessages({ + messages: [ + { + id: messageId, + chatId: id, + role: "assistant", + parts: [{ type: "text", text: canvasModelPlannerOutcome.reply }], + attachments: [], + createdAt: new Date(), + videoStorageStatus: null, + temporaryVideoUrl: null, + permanentVideoUrl: null, + archivedAt: null, + videoMetadata: null, + }, + ], + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "模型规划的画布操作执行失败"; + writeAgentProgressChunk( + writer, + { + stage: "failed", + currentStep: "finalize", + title: "画布操作失败", + message: errorMessage, + toolName: "canvasOperation", + capability: operationPlan.operation, + }, + "canvas-model-planner:progress-failed", + ); + writeAssistantTextChunk( + writer, + errorMessage, + "canvas-model-planner:error", + messageId, + ); + } + }, + }); + + return createSseResponse(canvasModelPlannerStream); + } + const promptOnlySourceImageUrls = requestResolvedMediaInput.currentMessageImageUrls.length > 0 ? requestResolvedMediaInput.currentMessageImageUrls.slice(0, 4) @@ -5590,6 +6743,17 @@ export async function POST(request: Request) { assistantMessageId, ); if (contextPayload?.source === "canvas") { + const canvasWriteback = await applyDirectAgentCanvasWriteback({ + writer, + assistantMessageId, + operationLabel: "agent-generate-image", + resultType: "image", + resultUrls: directImageResult.images.map((image) => image.originalUrl), + prompt: directImageResult.prompt, + mode: "generate", + sourceImageUrls: directAgentToolTargetImageUrls, + model: directImageResult.modelRef || finalModelId, + }); writeCanvasMediaChunk( writer, { @@ -5597,6 +6761,8 @@ export async function POST(request: Request) { imageUrls: directImageResult.images.map((image) => image.originalUrl), prompt: directImageResult.prompt, sourceImageUrls: directAgentToolTargetImageUrls, + mediaItemIds: canvasWriteback.mediaItemIds, + handledCanvasMediaItemIds: canvasWriteback.handledCanvasMediaItemIds, }, "agent:generate_image:canvas-media", ); @@ -5765,6 +6931,17 @@ export async function POST(request: Request) { assistantMessageId, ); if (contextPayload?.source === "canvas") { + const canvasWriteback = await applyDirectAgentCanvasWriteback({ + writer, + assistantMessageId, + operationLabel: "agent-generate-video", + resultType: "video", + resultUrls: [videoUrl], + prompt: directVideoResult.prompt, + mode: "video", + sourceImageUrls: directAgentToolTargetImageUrls, + model: result.modelRef || finalModelId, + }); writeCanvasMediaChunk( writer, { @@ -5772,6 +6949,8 @@ export async function POST(request: Request) { videoUrls: [videoUrl], prompt: directVideoResult.prompt, sourceImageUrls: directAgentToolTargetImageUrls, + mediaItemIds: canvasWriteback.mediaItemIds, + handledCanvasMediaItemIds: canvasWriteback.handledCanvasMediaItemIds, }, "agent:generate_video:canvas-media", ); @@ -5956,6 +7135,20 @@ export async function POST(request: Request) { assistantMessageId, ); if (contextPayload?.source === "canvas") { + const canvasWriteback = await applyDirectAgentCanvasWriteback({ + writer, + assistantMessageId, + operationLabel: `agent-${directAgentToolCapability}`, + resultType: "image", + resultUrls: resultOriginalUrl ? [resultOriginalUrl] : [], + prompt: requestTextContent || directToolLabel, + mode: "edit", + sourceImageUrls: directAgentToolTargetImageUrls, + assetIdsByUrl: resultOriginalUrl + ? { [resultOriginalUrl]: resultAssetId || undefined } + : undefined, + model: finalModelId, + }); writeCanvasMediaChunk( writer, { @@ -5963,6 +7156,8 @@ export async function POST(request: Request) { imageUrls: resultOriginalUrl ? [resultOriginalUrl] : [], prompt: requestTextContent || directToolLabel, sourceImageUrls: directAgentToolTargetImageUrls, + mediaItemIds: canvasWriteback.mediaItemIds, + handledCanvasMediaItemIds: canvasWriteback.handledCanvasMediaItemIds, }, `agent:${directAgentToolCapability}:canvas-media`, ); diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts index ab4b38c94..f3d970b02 100644 --- a/app/api/admin/settings/route.ts +++ b/app/api/admin/settings/route.ts @@ -67,6 +67,10 @@ import { EXTERNAL_IMAGE_API_CONFIG_KEY, normalizeExternalImageApiConfig, } from "@/lib/external-image-api/config"; +import { + AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY, + normalizeAgentWorkflowMaterialApiConfig, +} from "@/lib/agents/workflow-material-config"; import { clearScopedSiteSettingsCache } from "@/lib/site-settings/public-cache"; const settingSchema = z.object({ @@ -139,6 +143,12 @@ export async function GET(request: Request) { setting: setting ? { ...setting, value } : { key, value }, }); } + if (key === AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY) { + const value = normalizeAgentWorkflowMaterialApiConfig(setting?.value); + return Response.json({ + setting: setting ? { ...setting, value } : { key, value }, + }); + } return Response.json({ setting }); } @@ -278,6 +288,19 @@ export async function POST(request: Request) { }); } + if (key === AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY) { + const saved = await upsertSystemSetting({ + key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY, + value: normalizeAgentWorkflowMaterialApiConfig(value), + updatedBy: session.user.id, + }); + clearScopedSiteSettingsCache(); + + return Response.json({ + setting: saved[0], + }); + } + if (key === VIDEO_CANVAS_FUNCTION_CONFIG_KEY) { nextValue = normalizeVideoCanvasFunctionConfig(value); } diff --git a/app/api/agent-workflow/material-search/route.ts b/app/api/agent-workflow/material-search/route.ts index 45a82df4a..7404a38ed 100644 --- a/app/api/agent-workflow/material-search/route.ts +++ b/app/api/agent-workflow/material-search/route.ts @@ -8,6 +8,8 @@ import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; import { getEnabledAiModelsWithProvider, getSystemSetting } from "@/lib/db/queries"; import { AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY, + buildAgentWorkflowMaterialApiUrl, + isAgentWorkflowMaterialResourceEnabled, normalizeAgentWorkflowMaterialApiConfig, } from "@/lib/agents/workflow-material-config"; import { @@ -677,6 +679,12 @@ export async function GET(request: NextRequest) { { status: 400 } ); } + if (!isAgentWorkflowMaterialResourceEnabled(config, "white-background")) { + return NextResponse.json( + { error: "白底图素材接口未开启,请先在系统设置中启用" }, + { status: 400 } + ); + } if (!config.apiKey) { return NextResponse.json( { error: "素材搜索 API Key 未配置,请先在系统设置中保存" }, @@ -696,15 +704,18 @@ export async function GET(request: NextRequest) { return null; }) : Promise.resolve(null); - const upstreamUrl = `${normalizeBaseUrl( - config.baseUrl - )}/standard-model-white-background?resourceType=white-background&${queryType}=${encodeURIComponent(normalizedKeyword)}`; + const upstreamUrl = buildAgentWorkflowMaterialApiUrl(config, { + resourceType: "white-background", + queryType, + keyword: normalizedKeyword, + }); const upstreamResponse = await fetch(upstreamUrl, { method: "GET", headers: { "X-API-Key": config.apiKey, }, cache: "no-store", + signal: AbortSignal.timeout(config.requestTimeoutMs || 30_000), }); const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalSearchResponse; diff --git a/app/api/agent-workflow/reference-search/route.ts b/app/api/agent-workflow/reference-search/route.ts index fa3b5464a..1372f2bee 100644 --- a/app/api/agent-workflow/reference-search/route.ts +++ b/app/api/agent-workflow/reference-search/route.ts @@ -6,6 +6,8 @@ import { auth } from "@/app/(auth)/auth"; import { getSystemSetting } from "@/lib/db/queries"; import { AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY, + buildAgentWorkflowMaterialApiUrl, + isAgentWorkflowMaterialResourceEnabled, normalizeAgentWorkflowMaterialApiConfig, } from "@/lib/agents/workflow-material-config"; import { @@ -469,13 +471,11 @@ async function requestTaskDetailAttempt(params: { verifiedGoodsBrandName?: string | null; config: ReturnType; }) { - const upstreamParams = new URLSearchParams({ + const upstreamUrl = buildAgentWorkflowMaterialApiUrl(params.config, { resourceType: "task-detail", - [params.queryType]: params.keyword, + queryType: params.queryType, + keyword: params.keyword, }); - const upstreamUrl = `${normalizeBaseUrl( - params.config.baseUrl - )}/standard-model-white-background?${upstreamParams.toString()}`; const upstreamResponse = await fetch(upstreamUrl, { method: "GET", @@ -483,6 +483,7 @@ async function requestTaskDetailAttempt(params: { "X-API-Key": params.config.apiKey, }, cache: "no-store", + signal: AbortSignal.timeout(params.config.requestTimeoutMs || 30_000), }); const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalTaskDetailResponse; const upstreamBrandName = getExternalBrandName(upstreamResult.data || {}); @@ -936,6 +937,12 @@ export async function GET(request: NextRequest) { { status: 400 } ); } + if (!isAgentWorkflowMaterialResourceEnabled(config, "task-detail")) { + return jsonNoStore( + { error: "任务-详情整套素材接口未开启,请先在系统设置中启用" }, + { status: 400 } + ); + } if (!config.apiKey) { return jsonNoStore( { error: "素材搜索 API Key 未配置,请先在系统设置中保存" }, diff --git a/app/api/canvas/[id]/agent-operations/route.ts b/app/api/canvas/[id]/agent-operations/route.ts new file mode 100644 index 000000000..6f39f15cb --- /dev/null +++ b/app/api/canvas/[id]/agent-operations/route.ts @@ -0,0 +1,149 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/app/(auth)/auth"; +import { + applyCanvasOperationPlan, + CanvasAgentMutationError, +} from "@/lib/canvas/agent-mutations"; +import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol"; + +const canvasOperationPlanSchema = z + .object({ + version: z.literal(1), + operationId: z.string().min(1).max(160), + operation: z.enum([ + "fill_slot", + "insert_beside", + "append_revision", + "link_nodes", + "move_nodes", + "update_text", + ]), + projectId: z.string().uuid().optional(), + baseCanvasRevision: z.string().max(160).optional(), + baseCanvasUpdatedAt: z.string().max(160).optional(), + sourceNodeIds: z.array(z.string().min(1).max(160)).max(20).optional(), + targetNodeIds: z.array(z.string().min(1).max(160)).max(20).optional(), + targetSlotId: z.string().min(1).max(160).optional(), + anchorNodeId: z.string().min(1).max(160).optional(), + edgeType: z.enum(["default", "reference", "conversation"]).optional(), + textContent: z.string().min(1).max(4000).optional(), + confirmed: z.boolean().optional(), + layoutStrategy: z.enum(["row", "column", "grid"]).optional(), + spacing: z.number().positive().max(240).optional(), + targetPosition: z + .object({ + x: z.number(), + y: z.number(), + }) + .optional(), + placement: z + .enum(["right", "left", "below", "above", "viewport-center"]) + .optional(), + placementStrategy: z + .enum(["single", "row", "grid", "overlay", "beside_each_source"]) + .optional(), + preserveOriginal: z.boolean().optional(), + media: z + .object({ + type: z.enum(["image", "video"]), + url: z.string().url(), + assetId: z.string().uuid().optional(), + width: z.number().positive().optional(), + height: z.number().positive().optional(), + title: z.string().max(256).optional(), + prompt: z.string().max(2000).optional(), + sourceNodeIds: z.array(z.string().min(1).max(160)).max(20).optional(), + metadata: z.record(z.unknown()).optional(), + }) + .optional(), + mediaItems: z + .array( + z.object({ + mediaItemId: z.string().min(1).max(160), + type: z.enum(["image", "video"]), + url: z.string().url(), + assetId: z.string().uuid().optional(), + width: z.number().positive().optional(), + height: z.number().positive().optional(), + title: z.string().max(256).optional(), + prompt: z.string().max(2000).optional(), + sourceNodeIds: z.array(z.string().min(1).max(160)).max(20).optional(), + metadata: z.record(z.unknown()).optional(), + }) + ) + .max(20) + .optional(), + metadata: z.record(z.unknown()).optional(), + }) + .refine( + (value) => + value.operation === "link_nodes" || + value.operation === "update_text" || + value.operation === "move_nodes" || + value.media || + (value.mediaItems?.length ?? 0) > 0, + { + message: "缺少媒体结果", + path: ["mediaItems"], + } + ) + .refine( + (value) => + value.operation !== "update_text" || + Boolean(String(value.textContent || "").trim()), + { + message: "缺少文本内容", + path: ["textContent"], + } + ); + +const requestSchema = z.object({ + operationPlan: canvasOperationPlanSchema, +}); + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: "未登录" }, + { status: 401 } + ); + } + + const { id } = await params; + const body = requestSchema.parse(await request.json()); + const result = await applyCanvasOperationPlan({ + userId: session.user.id, + projectId: id, + plan: body.operationPlan as CanvasOperationPlan, + }); + + return NextResponse.json({ + success: true, + data: result, + }); + } catch (error) { + if (error instanceof CanvasAgentMutationError) { + return NextResponse.json( + { success: false, error: error.message }, + { status: error.status } + ); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { success: false, error: "请求参数无效", details: error.flatten() }, + { status: 400 } + ); + } + console.error("[CanvasAgentOperations] apply operation failed:", error); + return NextResponse.json( + { success: false, error: "执行画布操作失败" }, + { status: 500 } + ); + } +} diff --git a/app/api/canvas/[id]/route.ts b/app/api/canvas/[id]/route.ts index 77c3b61d2..88af0bd0d 100644 --- a/app/api/canvas/[id]/route.ts +++ b/app/api/canvas/[id]/route.ts @@ -13,6 +13,19 @@ function getDefaultWorkspaceTitle(workspaceType: ReturnType= MIN_SCALE_ADJUST_PERCENT && + value <= MAX_SCALE_ADJUST_PERCENT + ) { + return value; } throw new Error("请选择有效的比例调整幅度"); } @@ -385,6 +404,39 @@ function scaleRectAroundCenter( }; } +function getScaleAdjustTargetRect( + region: NormalizedLocalEditRegion, + scalePercent: ScaleAdjustPercent +) { + return region.targetRect || scaleRectAroundCenter(region.rect, scalePercent); +} + +function deriveScaleAdjustPercentFromTargetRect( + sourceRect: NormalizedLocalEditRegion["rect"], + targetRect: NormalizedLocalEditRegion["rect"] | null | undefined +) { + if (!targetRect || sourceRect.width <= 0 || sourceRect.height <= 0) { + return null; + } + const widthRatio = Math.max(0.05, targetRect.width / sourceRect.width); + const heightRatio = Math.max(0.05, targetRect.height / sourceRect.height); + const uniformRatio = Math.sqrt(widthRatio * heightRatio); + const percent = Math.round((uniformRatio - 1) * 100); + if (!Number.isFinite(percent) || percent === 0) return null; + return clamp(percent, MIN_SCALE_ADJUST_PERCENT, MAX_SCALE_ADJUST_PERCENT); +} + +function deriveScaleAdjustPercentFromRegions(regions: NormalizedLocalEditRegion[]) { + for (const region of regions) { + const percent = deriveScaleAdjustPercentFromTargetRect( + region.rect, + region.targetRect + ); + if (percent !== null) return percent; + } + return null; +} + function expandRectWithMargins( rect: NormalizedLocalEditRegion["rect"], marginX: number, @@ -428,7 +480,7 @@ function buildScaleAdjustCompositeRegions( scalePercent: ScaleAdjustPercent ) { return regions.flatMap((region) => { - const targetRect = scaleRectAroundCenter(region.rect, scalePercent); + const targetRect = getScaleAdjustTargetRect(region, scalePercent); const workspaceRect = getScaleAdjustWorkspaceRect( region.rect, targetRect, @@ -546,12 +598,16 @@ function normalizeRegions( if (!rect) { throw new Error(`区域 ${itemIndex + 1} 缺少有效框选范围`); } + const targetRect = normalizeRect(region.targetRect); const prompt = String(region.prompt || "").trim(); const resolvedPrompt = prompt || String(options.fallbackPrompt || "").trim(); if (options.requirePrompt !== false && !resolvedPrompt) { throw new Error(`区域 ${itemIndex + 1} 请填写局部编辑提示词`); } const pixelRect = rectToPixels(rect, sourceWidth, sourceHeight); + const targetPixelRect = targetRect + ? rectToPixels(targetRect, sourceWidth, sourceHeight) + : null; if (pixelRect.width < MIN_REGION_EDGE_PX || pixelRect.height < MIN_REGION_EDGE_PX) { throw new Error(`区域 ${itemIndex + 1} 过小,请重新框选更大的范围`); } @@ -567,7 +623,9 @@ function normalizeRegions( prompt: resolvedPrompt.slice(0, 300), referenceImages: normalizeRegionReferenceImages(region, itemIndex), rect, + targetRect, pixelRect, + targetPixelRect, }; }); } @@ -654,8 +712,9 @@ function buildScaleAdjustAnnotationSvg(params: { const regionSvg = regions .map((region) => { const source = region.pixelRect; - const targetRect = scaleRectAroundCenter(region.rect, scalePercent); - const target = rectToPixels(targetRect, width, height); + const targetRect = getScaleAdjustTargetRect(region, scalePercent); + const target = + region.targetPixelRect || rectToPixels(targetRect, width, height); const radius = Math.max(8, Math.round(strokeWidth * 2)); const badgeSize = Math.max(22, Math.round(minSide * 0.022)); const badgeX = clamp( @@ -745,11 +804,31 @@ function buildScaleAdjustPrompt( const cropIndex = cropReferences.findIndex( (reference) => reference.regionIndex === region.index ); + const targetRect = getScaleAdjustTargetRect(region, scalePercent); + const sourceCenterX = region.rect.x + region.rect.width / 2; + const sourceCenterY = region.rect.y + region.rect.height / 2; + const targetCenterX = targetRect.x + targetRect.width / 2; + const targetCenterY = targetRect.y + targetRect.height / 2; + const targetLeft = Math.round(targetRect.x * 1000) / 10; + const targetTop = Math.round(targetRect.y * 1000) / 10; + const targetWidth = Math.round(targetRect.width * 1000) / 10; + const targetHeight = Math.round(targetRect.height * 1000) / 10; + const regionScalePercent = + deriveScaleAdjustPercentFromTargetRect(region.rect, targetRect) || + scalePercent; + const regionActionLabel = getScaleAdjustLabel(regionScalePercent); + const regionTargetLabel = getScaleAdjustTargetLabel(regionScalePercent); + const moved = + Math.hypot(targetCenterX - sourceCenterX, targetCenterY - sourceCenterY) > + 0.01; + const targetPositionNote = moved + ? `青色虚线框是用户指定的最终目标位置和目标大小,目标主体调整后的视觉中心应移动到青色虚线框中心附近;目标框坐标 x=${targetLeft}%,y=${targetTop}%,w=${targetWidth}%,h=${targetHeight}%。` + : `青色虚线框是用户指定的最终目标大小范围,目标框坐标 x=${targetLeft}%,y=${targetTop}%,w=${targetWidth}%,h=${targetHeight}%。`; const cropNote = cropIndex >= 0 ? `;输入图片${cropIndex + 3}是该区域的干净局部裁切参考图,只用于识别目标主体的身份、轮廓与原始比例` : ""; - return `区域 ${region.index}${cropNote}:请在输入图片1中找到输入图片2红色实线框所标记范围内最主要、可独立缩放的商品/物体主体。红色实线框只是“寻找主体”的范围,不是放大后的硬边界,也不是要缩放或贴回的矩形补丁;放大时主体可以自然超出红色实线框,并由周边背景补全衔接。目标虚线框只是表达目标尺寸范围,不是要求主体填满矩形或变成矩形比例。请以主体自身轮廓、原始宽高比和视觉中心为准,执行整体等比${actionLabel},宽度与高度必须使用同一个缩放倍率,目标主体视觉尺寸约为原主体的 ${targetLabel}。严禁宽度变窄但高度变高,严禁横向挤压、纵向拉伸或改变主体透视形体。如果框中只覆盖到主体的一部分,请根据连接关系推断该主体的完整可见结构,只调整该主体,不调整矩形框内背景。`; + return `区域 ${region.index}${cropNote}:请在输入图片1中找到输入图片2红色实线框所标记范围内最主要、可独立缩放的商品/物体主体。红色实线框只是“寻找主体”的范围,不是放大后的硬边界,也不是要缩放或贴回的矩形补丁;放大时主体可以自然超出红色实线框,并由周边背景补全衔接。${targetPositionNote}目标虚线框不是要求主体填满矩形或变成矩形比例,请以主体自身轮廓、原始宽高比和视觉中心为准,执行整体等比${regionActionLabel},宽度与高度必须使用同一个缩放倍率,目标主体视觉尺寸约为原主体的 ${regionTargetLabel}。严禁宽度变窄但高度变高,严禁横向挤压、纵向拉伸或改变主体透视形体。如果框中只覆盖到主体的一部分,请根据连接关系推断该主体的完整可见结构,只调整该主体,不调整矩形框内背景。`; }) .join("\n"); const cropLines = cropReferences @@ -767,7 +846,7 @@ function buildScaleAdjustPrompt( cropLines ? `输入图片3及之后是框选区域的干净局部裁切参考图,作用是帮助你确认“要缩放的是哪个主体”。这些裁切图不是输出底图,不得被整体放大、贴回或覆盖到原图上。\n${cropLines}` : "", - "输入图片2中的红色实线框表示输入图片1里目标主体当前大致位置;它只用于定位主体,不限制放大后的空间。青绿色或绿色虚线框只表示比例调整后的安全尺寸范围。最终画面不得出现这些框线,主体也不需要填满虚线矩形;必须保持主体自身原始宽高比和透视形体。", + "输入图片2中的红色实线框表示输入图片1里目标主体当前大致位置;它只用于定位主体,不限制放大后的空间。青绿色或绿色虚线框表示用户指定的比例调整后目标大小和目标位置。最终画面不得出现这些框线,主体也不需要填满虚线矩形;必须保持主体自身原始宽高比和透视形体。", "如果系统或模型只能读取一张参考图,必须优先以输入图片1为输出基底,不得清理、改写或删除输入图片1中框选区域外的任何文字和图形。", "系统已根据用户框选区域计算出下方每个区域的原图坐标;框选区域只是定位目标主体的搜索范围,不是最终画面的裁切块、贴片、蒙版边界,也不是放大后的空间上限,更不是要整体缩放的矩形图层。", "最终结果必须保持原始干净图的画幅比例、构图边界、镜头透视、光线、色温、材质质感和清晰度;严禁整张图整体缩放、裁切、平移、重新排版或重新生成。", @@ -781,8 +860,8 @@ function buildScaleAdjustPrompt( `1. 只调整目标主体物体本身的整体视觉比例,必须等比缩放到约 ${targetLabel},不得保留原尺寸;主体轮廓宽度和高度必须使用同一个倍率同步变化,宽高比必须与原主体一致,不能为了贴合虚线框而拉宽、压窄、拉高或压扁。`, "2. 严禁把整个矩形框选区域、输入图片1整张图或任意裁切图当成图片块整体缩放、平移、复制、裁切或贴回;最终画面不得出现矩形拼接边缘、色差硬边、补丁感、裁切线或局部贴片痕迹。", "2.1 严禁把输入图片1整张图整体放大、缩小、裁切或重新构图;如果画面中的标题、Logo、页面文字、边框、背景家具或非目标主体发生整体位移/缩放/消失,必须视为失败并重新生成。", - `3. 缩小时,目标主体应围绕原位置视觉中心自然变小到约 ${targetLabel};主体缩小后,原来被遮挡的背景、桌面、墙面、阴影、纹理和透视必须被重新生成并无缝补全,选区内应出现明显新增留白或背景。`, - `4. 放大时,目标主体应围绕原位置视觉中心自然变大到约 ${targetLabel};允许主体自然超出用户手画的原始红框,使用周围真实背景完成边缘、阴影和遮挡衔接。宽度和高度都要同步变大,不能出现“高度变高但宽度变窄”的非等比变形;主体边缘与遮挡关系要合理,不能产生突兀裁切。`, + `3. 缩小时,目标主体应自然变小到约 ${targetLabel};如果青色目标框中心与红框中心不同,允许主体同步移动到青色目标框中心附近。主体缩小后,原来被遮挡的背景、桌面、墙面、阴影、纹理和透视必须被重新生成并无缝补全,选区内应出现明显新增留白或背景。`, + `4. 放大时,目标主体应自然变大到约 ${targetLabel};如果青色目标框中心与红框中心不同,允许主体同步移动到青色目标框中心附近。允许主体自然超出用户手画的原始红框,使用周围真实背景完成边缘、阴影和遮挡衔接。宽度和高度都要同步变大,不能出现“高度变高但宽度变窄”的非等比变形;主体边缘与遮挡关系要合理,不能产生突兀裁切。`, "5. 如果主体靠近画布边界导致目标尺寸空间不足,请用能完整放入画面的最大统一倍率整体缩放;绝不能为了塞进画面而只压缩宽度或只拉伸高度。", "6. 框选区域内非目标主体的背景、墙面、桌面、植物、文字和其它物体应保持原场景逻辑,不要跟着主体一起缩放。", "6.1 比例调整后的主体边缘必须和周围环境自然融合,选区边界不能被肉眼看出;不得出现一整块矩形区域清晰度、颜色、光照、纹理或透视突然变化。", @@ -823,19 +902,23 @@ export async function POST(request: NextRequest) { const operation = resolveLocalEditOperation(body.operation); let scalePercent: ScaleAdjustPercent | null = null; if (operation === SCALE_ADJUST_OPERATION) { - try { - scalePercent = normalizeScaleAdjustPercent(body.scalePercent); - } catch (validationError) { - return NextResponse.json( - { - success: false, - error: - validationError instanceof Error - ? validationError.message - : "比例调整参数无效", - }, - { status: 400 } - ); + const hasRequestedScalePercent = + body.scalePercent !== undefined && body.scalePercent !== null; + if (hasRequestedScalePercent) { + try { + scalePercent = normalizeScaleAdjustPercent(body.scalePercent); + } catch (validationError) { + return NextResponse.json( + { + success: false, + error: + validationError instanceof Error + ? validationError.message + : "比例调整参数无效", + }, + { status: 400 } + ); + } } } @@ -845,6 +928,8 @@ export async function POST(request: NextRequest) { fallbackPrompt: operation === SCALE_ADJUST_OPERATION && scalePercent !== null ? `比例调整:${getScaleAdjustLabel(scalePercent)}` + : operation === SCALE_ADJUST_OPERATION + ? "比例调整" : undefined, requirePrompt: operation !== SCALE_ADJUST_OPERATION, }); @@ -860,6 +945,18 @@ export async function POST(request: NextRequest) { { status: 400 } ); } + if (operation === SCALE_ADJUST_OPERATION && scalePercent === null) { + scalePercent = deriveScaleAdjustPercentFromRegions(regions); + if (scalePercent === null) { + return NextResponse.json( + { + success: false, + error: "请提供比例调整幅度,或为每个区域提供有效的目标框 targetRect", + }, + { status: 400 } + ); + } + } const shouldUseScaleAdjustDetailModel = operation === SCALE_ADJUST_OPERATION && scalePercent !== null; @@ -1023,8 +1120,10 @@ export async function POST(request: NextRequest) { regionCount: regions.length, scalePercent, scaleAdjustCropReferenceCount: scaleAdjustCropReferenceImageUrls.length, + scaleAdjustTargetRegionCount: regions.filter((region) => region.targetRect) + .length, scaleAdjustPromptStrategy: isScaleAdjustOperation - ? "locked-plate-local-subject-v3" + ? "locked-plate-local-subject-target-box-v4" : undefined, scaleAdjustUsesFullFrameOutput: isScaleAdjustOperation, sourceHeight, diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index 40d3de440..2ff5d1713 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -1803,7 +1803,13 @@ const updateSchema = z.object({ .object({ enabled: z.boolean().optional(), baseUrl: z.string().trim().max(255).optional(), + endpointPath: z.string().trim().max(500).optional(), apiKey: z.string().trim().max(500).optional(), + whiteBackgroundEnabled: z.boolean().optional(), + taskDetailEnabled: z.boolean().optional(), + resourceBaseUrl: z.string().trim().max(500).optional(), + fallbackResourceBaseUrl: z.string().trim().max(500).optional(), + requestTimeoutMs: z.number().int().min(3000).max(120000).optional(), productDetailEnabled: z.boolean().optional(), productDetailBaseUrl: z.string().trim().max(500).optional(), productDetailApiKey: z.string().trim().max(500).optional(), diff --git a/app/api/third-party-image-materials/config/route.ts b/app/api/third-party-image-materials/config/route.ts new file mode 100644 index 000000000..038ce094f --- /dev/null +++ b/app/api/third-party-image-materials/config/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { getThirdPartyImageMaterialPublicConfig } from "@/lib/agents/third-party-image-material-service"; + +function jsonNoStore(body: Parameters[0], init?: ResponseInit) { + return NextResponse.json(body, { + ...init, + headers: { + "Cache-Control": "no-store, max-age=0", + ...(init?.headers || {}), + }, + }); +} + +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return jsonNoStore({ error: "未登录" }, { status: 401 }); + } + + const config = await getThirdPartyImageMaterialPublicConfig(); + + return jsonNoStore({ + success: true, + data: config, + }); +} diff --git a/app/api/third-party-image-materials/search/route.ts b/app/api/third-party-image-materials/search/route.ts new file mode 100644 index 000000000..bde491341 --- /dev/null +++ b/app/api/third-party-image-materials/search/route.ts @@ -0,0 +1,133 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/app/(auth)/auth"; +import { + queryThirdPartyImageMaterials, + ThirdPartyImageMaterialError, +} from "@/lib/agents/third-party-image-material-service"; +import type { + AgentWorkflowMaterialQueryType, + AgentWorkflowMaterialResourceType, +} from "@/types/agent"; + +export const maxDuration = 180; + +const querySchema = z.object({ + resourceType: z.enum(["white-background", "task-detail"]).default("white-background"), + sku: z.string().trim().max(64).optional(), + spu: z.string().trim().max(64).optional(), + keyword: z.string().trim().max(64).optional(), + queryType: z.enum(["sku", "spu"]).optional(), +}); + +function jsonNoStore(body: Parameters[0], init?: ResponseInit) { + return NextResponse.json(body, { + ...init, + headers: { + "Cache-Control": "no-store, max-age=0", + ...(init?.headers || {}), + }, + }); +} + +function resolveQuery(params: z.infer) { + const spu = String(params.spu || "").trim(); + const sku = String(params.sku || "").trim(); + const keyword = String(params.keyword || "").trim(); + + if (spu) { + return { + queryType: "spu" as AgentWorkflowMaterialQueryType, + keyword: spu, + }; + } + if (sku) { + return { + queryType: "sku" as AgentWorkflowMaterialQueryType, + keyword: sku, + }; + } + if (keyword) { + return { + queryType: (params.queryType || "sku") as AgentWorkflowMaterialQueryType, + keyword, + }; + } + return null; +} + +export async function GET(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return jsonNoStore({ error: "未登录" }, { status: 401 }); + } + + const parsed = querySchema.parse({ + resourceType: request.nextUrl.searchParams.get("resourceType") || undefined, + sku: request.nextUrl.searchParams.get("sku") || undefined, + spu: request.nextUrl.searchParams.get("spu") || undefined, + keyword: request.nextUrl.searchParams.get("keyword") || undefined, + queryType: request.nextUrl.searchParams.get("queryType") || undefined, + }); + const resolvedQuery = resolveQuery(parsed); + if (!resolvedQuery) { + return jsonNoStore( + { + success: false, + error: { + code: "MISSING_PARAM", + message: "sku 和 spu 二选一必填;也可以传 keyword + queryType", + }, + }, + { status: 400 } + ); + } + + const result = await queryThirdPartyImageMaterials({ + resourceType: parsed.resourceType as AgentWorkflowMaterialResourceType, + ...resolvedQuery, + }); + + return jsonNoStore(result); + } catch (error) { + if (error instanceof ThirdPartyImageMaterialError) { + return jsonNoStore( + { + success: false, + error: { + code: error.code, + message: error.message, + }, + }, + { status: error.status } + ); + } + + if (error instanceof z.ZodError) { + return jsonNoStore( + { + success: false, + error: { + code: "INVALID_QUERY", + message: "第三方图片素材查询参数无效", + details: error.errors, + }, + }, + { status: 400 } + ); + } + + console.error("[Third Party Image Materials] Search failed:", error); + return jsonNoStore( + { + success: false, + error: { + code: "SERVER_ERROR", + message: "第三方图片素材查询失败,请稍后重试", + }, + }, + { status: 500 } + ); + } +} diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index c2d0ed6f7..da932f6cc 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -3118,6 +3118,9 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { if (result.success && result.data) { useCanvasStore.getState().setProject(id, result.data.title); + useCanvasStore + .getState() + .setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || "")); if (result.data.canvasData) { useCanvasStore.getState().loadCanvas(result.data.canvasData); const persistedWorkflow = result.data.canvasData.workflowState; @@ -4447,13 +4450,25 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { const persistCanvasState = useCallback( async (reason: "autosave" | "delete" | "beforeunload") => { const canvasData = buildPersistedCanvasData(); + const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState(); const result = await canvasService.saveCanvasState( id, canvasData, - activeCanvasWorkspaceType + activeCanvasWorkspaceType, + { + baseCanvasRevision: canvasRevision, + baseCanvasUpdatedAt: canvasUpdatedAt, + } ); if (!result.success) { console.error(`[Canvas] ${reason} save failed:`, result.error); + if (result.status === 409) { + toast.warning("画布已被 Agent 更新,请刷新后再保存当前修改"); + } + } else if (result.data?.updatedAt) { + useCanvasStore + .getState() + .setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt)); } return result.success; }, @@ -4515,12 +4530,15 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { const handleBeforeUnload = () => { try { const canvasData = buildPersistedCanvasData(); + const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState(); void fetch(`/api/canvas/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ canvasData, workspaceType: activeCanvasWorkspaceType, + baseCanvasRevision: canvasRevision, + baseCanvasUpdatedAt: canvasUpdatedAt, }), keepalive: true, }); @@ -6755,6 +6773,34 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { }); }, [createCanvasNode, handleAddWorkflowNode, isVideoMode]); + const handleAddAgentImageSlotNode = useCallback(() => { + if (isVideoMode) return; + + const origin = getVisibleCanvasOrigin(); + createCanvasNode({ + type: "agent-slot", + position: { + x: origin.x + 40, + y: origin.y + 40, + }, + size: { width: 320, height: 320 }, + data: { + slotType: "image", + label: "图片槽位", + status: "empty", + aspectRatio: "1:1", + fillPolicy: "replace-empty", + acceptedSources: ["text", "image", "selection", "annotation"], + filledNodeIds: [], + metadata: { + createdBy: "toolbar", + createdAt: new Date().toISOString(), + }, + }, + connections: [], + }); + }, [createCanvasNode, getVisibleCanvasOrigin, isVideoMode]); + const handleAddVideoNode = useCallback(() => { if (isVideoMode) { handleAddWorkflowNode("video-input"); @@ -10352,6 +10398,40 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { + {!isVideoMode && + isCanvasToolbarItemEnabled( + canvasToolbarConfig, + "addAgentImageSlot" + ) ? ( + + + + + + { + getCanvasToolbarItem( + canvasToolbarConfig, + "addAgentImageSlot" + ).label + } + + + ) : null} +
+ ); + } + )} {localEditRegionOverlays.map(({ region, style }) => { const left = Number(style.left || 0); const top = Number(style.top || 0); const width = Number(style.width || 0); const height = Number(style.height || 0); + const active = + localEditRegionInteraction?.regionId === region.id && + localEditRegionInteraction.target === "source"; return (
{ setLocalEditOperationMode("scale_adjust"); - setSelectedLocalEditScalePercent((current) => current || -30); + if (!selectedLocalEditScalePercent) { + applyLocalEditScalePercent(CANVAS_SCALE_TARGET_DEFAULT_PERCENT); + } setEditingLocalEditRegionId(null); }} type="button" @@ -9059,25 +9330,28 @@ export function ImageNode({ {localEditOperationMode === "scale_adjust" ? (
+ + 拖动青色目标框 + {CANVAS_SCALE_ADJUST_OPTIONS.map((option) => { const active = selectedLocalEditScalePercent === option; const Icon = option > 0 ? Maximize2 : Minimize2; return ( ); @@ -9089,9 +9363,7 @@ export function ImageNode({ className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl bg-slate-950 px-3 font-semibold text-[13px] text-white hover:bg-slate-800 hover:text-white disabled:bg-slate-300 disabled:text-slate-500 dark:border dark:border-white/14 dark:bg-black dark:text-white dark:disabled:border-transparent dark:disabled:bg-slate-700 dark:disabled:text-slate-400 dark:hover:bg-zinc-900" disabled={ isSubmittingLocalEdit || - localEditRegions.length === 0 || - (localEditOperationMode === "scale_adjust" && - !selectedLocalEditScalePercent) + localEditRegions.length === 0 } onClick={() => void handleSubmitLocalEdit()} title={ diff --git a/components/canvas/nodes/image-node/image-node-local-edit.ts b/components/canvas/nodes/image-node/image-node-local-edit.ts index 7fab1b769..32f539b5b 100644 --- a/components/canvas/nodes/image-node/image-node-local-edit.ts +++ b/components/canvas/nodes/image-node/image-node-local-edit.ts @@ -14,6 +14,7 @@ export type LocalEditRegion = { color: string; prompt: string; rect: NormalizedCropRect; + targetRect?: NormalizedCropRect | null; referenceImages?: LocalEditReferenceImage[]; createdAt: number; }; @@ -131,6 +132,7 @@ export const normalizeLocalEditRegions = (value: unknown): LocalEditRegion[] => const record = item as Record; const rect = normalizeLocalEditRect(record.rect); if (!rect) return; + const targetRect = normalizeLocalEditRect(record.targetRect); regions.push({ id: typeof record.id === "string" && record.id.trim() @@ -146,6 +148,7 @@ export const normalizeLocalEditRegions = (value: unknown): LocalEditRegion[] => : LOCAL_EDIT_COLORS[index % LOCAL_EDIT_COLORS.length], prompt: typeof record.prompt === "string" ? record.prompt.trim() : "", rect, + ...(targetRect ? { targetRect } : {}), referenceImages: normalizeLocalEditReferenceImages(record.referenceImages), createdAt: typeof record.createdAt === "number" && Number.isFinite(record.createdAt) diff --git a/lib/agents/third-party-image-material-service.ts b/lib/agents/third-party-image-material-service.ts new file mode 100644 index 000000000..787fc2f96 --- /dev/null +++ b/lib/agents/third-party-image-material-service.ts @@ -0,0 +1,426 @@ +import "server-only"; + +import { createHash } from "node:crypto"; +import { getSystemSetting } from "@/lib/db/queries"; +import { + AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY, + buildAgentWorkflowMaterialApiUrl, + isAgentWorkflowMaterialResourceEnabled, + normalizeAgentWorkflowMaterialApiConfig, +} from "@/lib/agents/workflow-material-config"; +import type { + AgentWorkflowMaterialQueryType, + AgentWorkflowMaterialResourceType, +} from "@/types/agent"; + +type UpstreamMaterialResponse = { + success?: boolean; + data?: Record; + error?: { + code?: string; + message?: string; + }; +}; + +type UpstreamImageRecord = { + key?: string; + label?: string; + fileName?: string; + fileSize?: number; + previewUrl?: string; + downloadUrl?: string; +}; + +type ThirdPartyImageMaterialDiagnostics = { + status: number; + traceId: string | null; + serverDurationMs: string | null; + serverTiming: string | null; + resourceType: string | null; + requestSku: string | null; + requestSpu: string | null; +}; + +export class ThirdPartyImageMaterialError extends Error { + readonly code: string; + readonly status: number; + + constructor(code: string, message: string, status = 500) { + super(message); + this.name = "ThirdPartyImageMaterialError"; + this.code = code; + this.status = status; + } +} + +function sanitizeText(value: unknown, maxLength = 500) { + return String(value || "").trim().slice(0, maxLength); +} + +function normalizeUrlList(value: unknown) { + if (!Array.isArray(value)) return []; + return Array.from( + new Set( + value + .map((item) => sanitizeText(item, 2000)) + .filter((item) => /^https?:\/\//i.test(item)) + ) + ); +} + +function inferMimeTypeFromFilename(filename: string) { + const normalized = filename.toLowerCase(); + if (normalized.endsWith(".jpg") || normalized.endsWith(".jpeg")) return "image/jpeg"; + if (normalized.endsWith(".webp")) return "image/webp"; + if (normalized.endsWith(".gif")) return "image/gif"; + return "image/png"; +} + +function getRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function getDownloadUrls(value: unknown) { + return getRecord(value).downloadUrls && + typeof getRecord(value).downloadUrls === "object" && + !Array.isArray(getRecord(value).downloadUrls) + ? (getRecord(value).downloadUrls as Record) + : {}; +} + +function mapUpstreamErrorStatus(code: string) { + if (code === "MISSING_PARAM" || code === "RESOURCE_TYPE_INVALID") return 400; + if ( + code === "SKU_NOT_FOUND" || + code === "SPU_NOT_FOUND" || + code === "NO_SKUS" || + code === "WHITE_BACKGROUND_NOT_FOUND" || + code === "TASK_DETAIL_NOT_FOUND" || + code === "FULL_SET_NOT_FOUND" + ) { + return 404; + } + if (code === "SKU_NOT_UNIQUE") return 409; + if (code === "INVALID_API_KEY") return 500; + return 502; +} + +function readDiagnostics(response: Response): ThirdPartyImageMaterialDiagnostics { + return { + status: response.status, + traceId: response.headers.get("x-trace-id"), + serverDurationMs: response.headers.get("x-server-duration-ms"), + serverTiming: response.headers.get("server-timing"), + resourceType: response.headers.get("x-resource-type"), + requestSku: response.headers.get("x-request-sku"), + requestSpu: response.headers.get("x-request-spu"), + }; +} + +function mapWhiteBackgroundImage(params: { + matchedSku: string; + group: "fixed" | "other"; + sortOrder: number; + image: UpstreamImageRecord; +}) { + const previewUrl = sanitizeText(params.image.previewUrl, 2000); + const downloadUrl = sanitizeText(params.image.downloadUrl, 2000); + const imageUrl = previewUrl || downloadUrl; + if (!imageUrl) return null; + + const filename = + sanitizeText(params.image.fileName, 255) || + sanitizeText(params.image.label, 128) || + `${params.matchedSku}-${params.group}-${params.sortOrder + 1}.webp`; + const identityHash = createHash("sha1") + .update(`${params.group}:${params.sortOrder}:${imageUrl}`, "utf8") + .digest("hex") + .slice(0, 12); + + return { + id: `third-party-${params.group}-${params.sortOrder + 1}-${identityHash}`, + key: sanitizeText(params.image.key, 64) || `${params.group}-${params.sortOrder + 1}`, + label: sanitizeText(params.image.label, 128) || `图片${params.sortOrder + 1}`, + fileName: filename, + fileSize: Number.isFinite(Number(params.image.fileSize)) + ? Math.max(0, Math.round(Number(params.image.fileSize))) + : null, + imageUrl, + previewUrl: previewUrl || null, + downloadUrl: downloadUrl || null, + mimeType: inferMimeTypeFromFilename(filename), + group: params.group, + sortOrder: params.sortOrder, + }; +} + +function normalizeWhiteBackgroundData(params: { + query: string; + queryType: AgentWorkflowMaterialQueryType; + data: Record; +}) { + const data = params.data; + const matchedSku = sanitizeText(data.sku, 64); + const matchedSpu = + sanitizeText(data.spu, 64) || sanitizeText(data.productModel, 128) || null; + const skuList = Array.isArray(data.skuList) ? data.skuList : []; + const skuOptions = skuList + .map((item) => { + const raw = getRecord(item); + const skuName = sanitizeText(raw.skuName, 64); + if (!skuName) return null; + const previewImage = getRecord(raw.previewImage); + return { + skuName, + skuId: sanitizeText(raw.skuId, 128) || null, + hasWhiteBackground: raw.hasWhiteBackground === true, + whiteBackgroundCount: Number.isFinite(Number(raw.whiteBackgroundCount)) + ? Math.max(0, Math.round(Number(raw.whiteBackgroundCount))) + : 0, + previewImageUrl: sanitizeText(previewImage.previewUrl, 2000) || null, + downloadUrl: sanitizeText(previewImage.downloadUrl, 2000) || null, + previewFileName: sanitizeText(previewImage.fileName, 255) || null, + previewFileSize: Number.isFinite(Number(previewImage.fileSize)) + ? Math.max(0, Math.round(Number(previewImage.fileSize))) + : null, + }; + }) + .filter((item): item is NonNullable => Boolean(item)); + + const whiteBackground = getRecord(data.whiteBackground); + const fixedImages = Array.isArray(whiteBackground.fixed) ? whiteBackground.fixed : []; + const otherImages = Array.isArray(whiteBackground.other) ? whiteBackground.other : []; + const images = [ + ...fixedImages.map((image, index) => + mapWhiteBackgroundImage({ + matchedSku: matchedSku || params.query, + group: "fixed", + sortOrder: index, + image: getRecord(image), + }) + ), + ...otherImages.map((image, index) => + mapWhiteBackgroundImage({ + matchedSku: matchedSku || params.query, + group: "other", + sortOrder: fixedImages.length + index, + image: getRecord(image), + }) + ), + ].filter((item): item is NonNullable => Boolean(item)); + + return { + resourceType: "white-background" as const, + query: params.query, + queryType: params.queryType, + matchedSku: matchedSku || null, + matchedSpu, + materialId: sanitizeText(data.materialId, 128) || null, + productModel: sanitizeText(data.productModel, 128) || matchedSpu, + categoryName: sanitizeText(data.categoryName, 128) || null, + shopName: sanitizeText(data.shopName, 128) || sanitizeText(data.categoryName, 128) || null, + whiteBackgroundUploaded: data.whiteBackgroundUploaded === true, + whiteBackgroundComplete: data.whiteBackgroundComplete === true, + fixedCount: Number.isFinite(Number(data.whiteBackgroundFixedCount)) + ? Math.max(0, Math.round(Number(data.whiteBackgroundFixedCount))) + : images.filter((item) => item.group === "fixed").length, + otherCount: Number.isFinite(Number(data.whiteBackgroundOtherCount)) + ? Math.max(0, Math.round(Number(data.whiteBackgroundOtherCount))) + : images.filter((item) => item.group === "other").length, + totalCount: Number.isFinite(Number(data.whiteBackgroundCount)) + ? Math.max(0, Math.round(Number(data.whiteBackgroundCount))) + : images.length || skuOptions.length, + missingViews: Array.isArray(data.missingViews) ? data.missingViews : [], + images, + skuOptions, + downloadUrls: { + zip: sanitizeText(getDownloadUrls(data).zip, 2000) || null, + }, + }; +} + +function normalizeTaskDetailData(params: { + query: string; + queryType: AgentWorkflowMaterialQueryType; + data: Record; +}) { + const candidates = (Array.isArray(params.data.candidates) ? params.data.candidates : []) + .map((item, index) => { + const raw = getRecord(item); + const downloadUrls = getDownloadUrls(raw); + const mainImageUrls = normalizeUrlList(downloadUrls.main); + const skuImageUrls = normalizeUrlList(downloadUrls.sku); + const detailImageUrls = normalizeUrlList(downloadUrls.detail); + const allImageUrls = normalizeUrlList(downloadUrls.all); + const mergedAllImageUrls = + allImageUrls.length > 0 + ? allImageUrls + : Array.from(new Set([...mainImageUrls, ...skuImageUrls, ...detailImageUrls])); + const materialId = sanitizeText(raw.materialId, 128) || null; + const taskNo = sanitizeText(raw.taskNo, 128) || null; + return { + id: taskNo || materialId || `candidate-${index + 1}`, + materialId, + taskNo, + productModel: sanitizeText(raw.productModel, 128) || null, + categoryName: sanitizeText(raw.categoryName, 128) || null, + shopName: sanitizeText(raw.shopName, 128) || sanitizeText(raw.categoryName, 128) || null, + sceneName: sanitizeText(raw.sceneName, 128) || null, + uploadedAt: sanitizeText(raw.uploadedAt, 64) || null, + isFullSet: raw.isFullSet !== false, + mainImageUrls, + skuImageUrls, + detailImageUrls, + allImageUrls: mergedAllImageUrls, + }; + }) + .sort((left, right) => { + const leftTime = left.uploadedAt ? new Date(left.uploadedAt).getTime() : 0; + const rightTime = right.uploadedAt ? new Date(right.uploadedAt).getTime() : 0; + return rightTime - leftTime; + }); + + return { + resourceType: "task-detail" as const, + query: params.query, + queryType: params.queryType, + matchedSku: sanitizeText(params.data.sku, 64) || null, + matchedSpu: sanitizeText(params.data.spu, 64) || null, + categoryName: sanitizeText(params.data.categoryName, 128) || null, + shopName: + sanitizeText(params.data.shopName, 128) || + sanitizeText(params.data.categoryName, 128) || + null, + candidateCount: Number.isFinite(Number(params.data.candidateCount)) + ? Math.max(0, Math.round(Number(params.data.candidateCount))) + : candidates.length, + candidates, + }; +} + +export async function getThirdPartyImageMaterialPublicConfig() { + const setting = await getSystemSetting({ + key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY, + }); + const config = normalizeAgentWorkflowMaterialApiConfig(setting?.value); + + return { + enabled: config.enabled, + endpoint: "/api/third-party-image-materials/search", + queryTypes: ["sku", "spu"] as const, + resourceTypes: [ + { + value: "white-background" as const, + label: "标准模型白底图", + enabled: + config.enabled && + isAgentWorkflowMaterialResourceEnabled(config, "white-background"), + }, + { + value: "task-detail" as const, + label: "任务-详情整套预览", + enabled: + config.enabled && + isAgentWorkflowMaterialResourceEnabled(config, "task-detail"), + }, + ], + requestTimeoutMs: config.requestTimeoutMs || 30_000, + }; +} + +export async function queryThirdPartyImageMaterials(params: { + resourceType: AgentWorkflowMaterialResourceType; + queryType: AgentWorkflowMaterialQueryType; + keyword: string; +}) { + const setting = await getSystemSetting({ + key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY, + }); + const config = normalizeAgentWorkflowMaterialApiConfig(setting?.value); + const keyword = sanitizeText(params.keyword, 64).toUpperCase(); + + if (!config.enabled) { + throw new ThirdPartyImageMaterialError( + "MATERIAL_API_DISABLED", + "第三方图片素材接口未开启,请先在系统设置中启用", + 400 + ); + } + if (!isAgentWorkflowMaterialResourceEnabled(config, params.resourceType)) { + throw new ThirdPartyImageMaterialError( + "RESOURCE_TYPE_DISABLED", + params.resourceType === "task-detail" + ? "任务-详情整套素材接口未开启,请先在系统设置中启用" + : "白底图素材接口未开启,请先在系统设置中启用", + 400 + ); + } + if (!config.apiKey) { + throw new ThirdPartyImageMaterialError( + "MATERIAL_API_KEY_MISSING", + "第三方图片素材 API Key 未配置,请先在系统设置中保存", + 500 + ); + } + if (!keyword) { + throw new ThirdPartyImageMaterialError("MISSING_PARAM", "sku 和 spu 二选一必填", 400); + } + + const upstreamUrl = buildAgentWorkflowMaterialApiUrl(config, { + resourceType: params.resourceType, + queryType: params.queryType, + keyword, + }); + + let response: Response; + try { + response = await fetch(upstreamUrl, { + method: "GET", + headers: { + Accept: "application/json", + "X-API-Key": config.apiKey, + }, + cache: "no-store", + signal: AbortSignal.timeout(config.requestTimeoutMs || 30_000), + }); + } catch (error) { + const message = error instanceof Error ? error.message : "上游接口访问失败"; + throw new ThirdPartyImageMaterialError( + "UPSTREAM_FETCH_FAILED", + message.includes("timeout") ? "第三方图片素材接口访问超时" : "第三方图片素材接口访问失败", + message.includes("timeout") ? 504 : 502 + ); + } + + const diagnostics = readDiagnostics(response); + const upstreamResult = (await response.json().catch(() => ({}))) as UpstreamMaterialResponse; + + if (!response.ok || upstreamResult.success !== true || !upstreamResult.data) { + const code = sanitizeText(upstreamResult.error?.code, 64) || "UPSTREAM_ERROR"; + const message = + sanitizeText(upstreamResult.error?.message, 500) || + `第三方图片素材接口返回异常(${response.status})`; + throw new ThirdPartyImageMaterialError(code, message, mapUpstreamErrorStatus(code)); + } + + const data = + params.resourceType === "task-detail" + ? normalizeTaskDetailData({ + query: keyword, + queryType: params.queryType, + data: upstreamResult.data, + }) + : normalizeWhiteBackgroundData({ + query: keyword, + queryType: params.queryType, + data: upstreamResult.data, + }); + + return { + success: true, + data, + diagnostics, + }; +} diff --git a/lib/agents/workflow-material-config.ts b/lib/agents/workflow-material-config.ts index 928a29d75..75fc18e7c 100644 --- a/lib/agents/workflow-material-config.ts +++ b/lib/agents/workflow-material-config.ts @@ -1,6 +1,8 @@ import type { AgentWorkflowMaterialApiConfig, AgentWorkflowMaterialImage, + AgentWorkflowMaterialQueryType, + AgentWorkflowMaterialResourceType, AgentWorkflowMaterialSkuOption, AgentWorkflowMaterialSearchResult, AgentWorkflowMaterialSelectionState, @@ -8,11 +10,28 @@ import type { } from "@/types/agent"; export const AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY = "agent_workflow_material_api_config"; +export const AGENT_WORKFLOW_MATERIAL_DEFAULT_BASE_URL = + "https://sc.jiansun.vip/api/external"; +export const AGENT_WORKFLOW_MATERIAL_DEFAULT_ENDPOINT_PATH = + "/standard-model-white-background"; +export const AGENT_WORKFLOW_MATERIAL_DEFAULT_RESOURCE_BASE_URL = + "https://s3.jiansun.vip"; +export const AGENT_WORKFLOW_MATERIAL_FALLBACK_RESOURCE_BASE_URL = + "https://s3.yydd.ai"; +export const AGENT_WORKFLOW_MATERIAL_DEFAULT_TIMEOUT_MS = 30_000; +export const AGENT_WORKFLOW_MATERIAL_MIN_TIMEOUT_MS = 3_000; +export const AGENT_WORKFLOW_MATERIAL_MAX_TIMEOUT_MS = 120_000; export const defaultAgentWorkflowMaterialApiConfig: AgentWorkflowMaterialApiConfig = { enabled: false, - baseUrl: "https://sc.jiansun.vip/api/external", + baseUrl: AGENT_WORKFLOW_MATERIAL_DEFAULT_BASE_URL, + endpointPath: AGENT_WORKFLOW_MATERIAL_DEFAULT_ENDPOINT_PATH, apiKey: "", + whiteBackgroundEnabled: true, + taskDetailEnabled: true, + resourceBaseUrl: AGENT_WORKFLOW_MATERIAL_DEFAULT_RESOURCE_BASE_URL, + fallbackResourceBaseUrl: AGENT_WORKFLOW_MATERIAL_FALLBACK_RESOURCE_BASE_URL, + requestTimeoutMs: AGENT_WORKFLOW_MATERIAL_DEFAULT_TIMEOUT_MS, productDetailEnabled: false, productDetailBaseUrl: "", productDetailApiKey: "", @@ -27,6 +46,30 @@ function sanitizeText(value: unknown, maxLength: number) { return String(value || "").trim().slice(0, maxLength); } +function normalizeBaseUrl(value: unknown, fallback: string) { + return (sanitizeText(value, 500) || fallback).replace(/\/+$/, ""); +} + +function normalizeEndpointPath(value: unknown) { + const normalized = + sanitizeText(value, 500) || AGENT_WORKFLOW_MATERIAL_DEFAULT_ENDPOINT_PATH; + if (/^https?:\/\//i.test(normalized)) { + return normalized; + } + return `/${normalized.replace(/^\/+/, "")}`; +} + +function normalizeTimeoutMs(value: unknown) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return AGENT_WORKFLOW_MATERIAL_DEFAULT_TIMEOUT_MS; + } + return Math.max( + AGENT_WORKFLOW_MATERIAL_MIN_TIMEOUT_MS, + Math.min(AGENT_WORKFLOW_MATERIAL_MAX_TIMEOUT_MS, Math.round(parsed)) + ); +} + function normalizeMaterialImage( value: unknown, index: number @@ -213,9 +256,23 @@ export function normalizeAgentWorkflowMaterialApiConfig( return { enabled: raw.enabled === true, - baseUrl: - sanitizeText(raw.baseUrl, 255) || defaultAgentWorkflowMaterialApiConfig.baseUrl, + baseUrl: normalizeBaseUrl( + raw.baseUrl, + defaultAgentWorkflowMaterialApiConfig.baseUrl + ), + endpointPath: normalizeEndpointPath(raw.endpointPath), apiKey: sanitizeText(raw.apiKey, 500), + whiteBackgroundEnabled: raw.whiteBackgroundEnabled !== false, + taskDetailEnabled: raw.taskDetailEnabled !== false, + resourceBaseUrl: normalizeBaseUrl( + raw.resourceBaseUrl, + AGENT_WORKFLOW_MATERIAL_DEFAULT_RESOURCE_BASE_URL + ), + fallbackResourceBaseUrl: normalizeBaseUrl( + raw.fallbackResourceBaseUrl, + AGENT_WORKFLOW_MATERIAL_FALLBACK_RESOURCE_BASE_URL + ), + requestTimeoutMs: normalizeTimeoutMs(raw.requestTimeoutMs), productDetailEnabled: raw.productDetailEnabled === true, productDetailBaseUrl: sanitizeText(raw.productDetailBaseUrl, 500), productDetailApiKey: sanitizeText(raw.productDetailApiKey, 500), @@ -232,6 +289,35 @@ export function normalizeAgentWorkflowMaterialApiConfig( }; } +export function isAgentWorkflowMaterialResourceEnabled( + config: AgentWorkflowMaterialApiConfig, + resourceType: AgentWorkflowMaterialResourceType +) { + if (resourceType === "task-detail") { + return config.taskDetailEnabled !== false; + } + return config.whiteBackgroundEnabled !== false; +} + +export function buildAgentWorkflowMaterialApiUrl( + config: AgentWorkflowMaterialApiConfig, + params: { + resourceType: AgentWorkflowMaterialResourceType; + queryType: AgentWorkflowMaterialQueryType; + keyword: string; + } +) { + const endpointPath = normalizeEndpointPath(config.endpointPath); + const baseUrl = normalizeBaseUrl(config.baseUrl, AGENT_WORKFLOW_MATERIAL_DEFAULT_BASE_URL); + const requestUrl = /^https?:\/\//i.test(endpointPath) + ? endpointPath + : `${baseUrl}/${endpointPath.replace(/^\/+/, "")}`; + const url = new URL(requestUrl); + url.searchParams.set("resourceType", params.resourceType); + url.searchParams.set(params.queryType, params.keyword.trim().toUpperCase()); + return url.toString(); +} + export function normalizeAgentWorkflowMaterialApiConfigForPublic( value: unknown ): AgentWorkflowMaterialApiConfig { diff --git a/lib/canvas-toolbar-config.ts b/lib/canvas-toolbar-config.ts index d236f28c1..d03a04032 100644 --- a/lib/canvas-toolbar-config.ts +++ b/lib/canvas-toolbar-config.ts @@ -64,6 +64,12 @@ export const DEFAULT_CANVAS_TOOLBAR_ITEMS = [ label: "添加图片", description: "在普通画布添加图片节点", }, + { + key: "addAgentImageSlot", + group: "画布主工具栏", + label: "图片槽位", + description: "在普通画布添加 Agent 图片输出槽位", + }, { key: "addImageInput", group: "画布主工具栏", diff --git a/lib/canvas/agent-confirmation.ts b/lib/canvas/agent-confirmation.ts new file mode 100644 index 000000000..0448ed434 --- /dev/null +++ b/lib/canvas/agent-confirmation.ts @@ -0,0 +1,148 @@ +import type { + CanvasOperationPlan, + CanvasViewOperationPlan, +} from "@/types/canvas/agent-protocol"; + +export type PendingCanvasOperation = { + version: 1; + pendingId: string; + projectId: string; + operationId: string; + operation: string; + riskOperation: string; + targetNodeIds: string[]; + operationPlan?: CanvasOperationPlan; + viewPlan?: CanvasViewOperationPlan; + prompt: string; + reply: string; + requestedAt: string; + requestedByMessageId: string; +}; + +function trimString(value: unknown, maxLength: number) { + return typeof value === "string" ? value.trim().slice(0, maxLength) : ""; +} + +function normalizeStringArray(value: unknown, maxItems: number) { + if (!Array.isArray(value)) return []; + return value + .map((item) => trimString(item, 160)) + .filter(Boolean) + .slice(0, maxItems); +} + +function normalizeOperationPlan(value: unknown): CanvasOperationPlan | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const raw = value as CanvasOperationPlan; + if (raw.version !== 1 || !raw.operationId || !raw.operation || !raw.projectId) { + return undefined; + } + return raw; +} + +function normalizeViewPlan(value: unknown): CanvasViewOperationPlan | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const raw = value as CanvasViewOperationPlan; + if (raw.version !== 1 || !raw.viewOperationId || !raw.operation) { + return undefined; + } + return raw; +} + +export function extractPendingCanvasOperationFromWorkingState( + workingState?: { lastTurnSummary?: unknown } | null +): PendingCanvasOperation | null { + const summary = workingState?.lastTurnSummary; + if (!summary || typeof summary !== "object" || Array.isArray(summary)) return null; + const pending = (summary as Record).pendingCanvasOperation; + if (!pending || typeof pending !== "object" || Array.isArray(pending)) return null; + + const record = pending as Record; + const projectId = trimString(record.projectId, 160); + const operationId = trimString(record.operationId, 180); + const operation = trimString(record.operation, 80); + const riskOperation = trimString(record.riskOperation, 80); + const requestedByMessageId = trimString(record.requestedByMessageId, 160); + const prompt = trimString(record.prompt, 1000); + const reply = trimString(record.reply, 500); + if (!projectId || !operationId || !operation || !riskOperation || !requestedByMessageId) { + return null; + } + + return { + version: 1, + pendingId: trimString(record.pendingId, 180) || operationId, + projectId, + operationId, + operation, + riskOperation, + targetNodeIds: normalizeStringArray(record.targetNodeIds, 20), + operationPlan: normalizeOperationPlan(record.operationPlan), + viewPlan: normalizeViewPlan(record.viewPlan), + prompt, + reply, + requestedAt: trimString(record.requestedAt, 80) || new Date(0).toISOString(), + requestedByMessageId, + }; +} + +export function isCanvasOperationConfirmText(text: string) { + const normalized = String(text || "").toLowerCase().replace(/\s+/g, ""); + if (!normalized) return false; + return /确认|同意|可以执行|执行吧|就这样|没问题|按这个|开始执行|确认执行/.test( + normalized + ); +} + +export function isCanvasOperationCancelText(text: string) { + const normalized = String(text || "").toLowerCase().replace(/\s+/g, ""); + if (!normalized) return false; + return /取消|先不|不要|别执行|不执行|算了|停止/.test(normalized); +} + +export function buildPendingCanvasOperation(params: { + projectId: string; + messageId: string; + userText: string; + riskOperation: string; + targetNodeIds: string[]; + reply: string; + operationPlan?: CanvasOperationPlan; + viewPlan?: CanvasViewOperationPlan; +}): PendingCanvasOperation { + const operationId = + params.operationPlan?.operationId || `${params.messageId}:pending-canvas-operation`; + return { + version: 1, + pendingId: operationId, + projectId: params.projectId, + operationId, + operation: params.operationPlan?.operation || params.riskOperation, + riskOperation: params.riskOperation, + targetNodeIds: params.targetNodeIds.slice(0, 20), + operationPlan: params.operationPlan, + viewPlan: params.viewPlan, + prompt: params.userText.slice(0, 1000), + reply: params.reply.slice(0, 500), + requestedAt: new Date().toISOString(), + requestedByMessageId: params.messageId, + }; +} + +export function withConfirmedCanvasOperationPlan( + pending: PendingCanvasOperation, + confirmingMessageId: string +): CanvasOperationPlan | null { + if (!pending.operationPlan) return null; + return { + ...pending.operationPlan, + confirmed: true, + metadata: { + ...(pending.operationPlan.metadata || {}), + confirmedByUser: true, + confirmedByMessageId: confirmingMessageId, + confirmedAt: new Date().toISOString(), + pendingOperationId: pending.pendingId, + }, + }; +} diff --git a/lib/canvas/agent-model-planner.ts b/lib/canvas/agent-model-planner.ts new file mode 100644 index 000000000..d91a9915f --- /dev/null +++ b/lib/canvas/agent-model-planner.ts @@ -0,0 +1,546 @@ +import { generateObject, type LanguageModel } from "ai"; +import { z } from "zod"; +import type { + CanvasAgentContext, + CanvasOperationPlan, + CanvasViewOperationPlan, +} from "@/types/canvas/agent-protocol"; + +const plannerDecisionSchema = z.object({ + action: z.enum([ + "none", + "focus_nodes", + "fit_all", + "zoom_in", + "zoom_out", + "link_nodes", + "update_text", + "confirmation_required", + ]), + nodeIds: z.array(z.string().min(1).max(160)).max(20).optional(), + textContent: z.string().max(4000).optional(), + riskOperation: z + .enum([ + "replace_node_media", + "delete_nodes", + "move_nodes", + "overwrite_slot", + "batch_move", + "other", + ]) + .optional(), + reply: z.string().max(280).optional(), + reason: z.string().max(280).optional(), +}); + +type PlannerDecision = z.infer; + +export type CanvasAgentModelPlannerOutcome = + | { + kind: "view"; + viewPlan: CanvasViewOperationPlan; + reply: string; + reason?: string; + } + | { + kind: "operation"; + operationPlan: CanvasOperationPlan; + viewPlan?: CanvasViewOperationPlan; + reply: string; + reason?: string; + } + | { + kind: "confirmation_required"; + riskOperation: string; + targetNodeIds: string[]; + operationPlan?: CanvasOperationPlan; + viewPlan?: CanvasViewOperationPlan; + reply: string; + reason?: string; + } + | { + kind: "none"; + reason?: string; + }; + +function trimString(value: unknown, maxLength: number) { + return typeof value === "string" ? value.trim().slice(0, maxLength) : ""; +} + +function uniqueStrings(values: Array, maxItems = 20) { + return Array.from( + new Set(values.map((value) => trimString(value, 160)).filter(Boolean)) + ).slice(0, maxItems); +} + +function buildKnownNodeMap(context: CanvasAgentContext) { + const nodeMap = new Map(); + [...context.candidateNodes, ...context.selectedNodes].forEach((node) => { + nodeMap.set(node.nodeId, { + nodeId: node.nodeId, + type: node.type, + isSelected: node.isSelected, + }); + }); + context.slots.forEach((slot) => { + nodeMap.set(slot.nodeId, { + nodeId: slot.nodeId, + type: "agent-slot", + isSelected: slot.isSelected, + }); + }); + return nodeMap; +} + +function getSelectedKnownNodeIds(context: CanvasAgentContext, maxItems = 20) { + const selectedFromRefs = [ + ...context.selectedNodes.map((node) => node.nodeId), + ...context.slots + .filter((slot) => slot.isSelected || context.selectedNodeIds.includes(slot.nodeId)) + .map((slot) => slot.nodeId), + ]; + return uniqueStrings([...context.selectedNodeIds, ...selectedFromRefs], maxItems); +} + +function getDecisionNodeIds(params: { + decision: PlannerDecision; + context: CanvasAgentContext; + allowSelectedFallback: boolean; + maxItems?: number; +}) { + const knownNodes = buildKnownNodeMap(params.context); + const requestedIds = uniqueStrings(params.decision.nodeIds || [], params.maxItems || 20).filter( + (nodeId) => knownNodes.has(nodeId) + ); + if (requestedIds.length > 0 || !params.allowSelectedFallback) { + return requestedIds; + } + return getSelectedKnownNodeIds(params.context, params.maxItems || 20).filter((nodeId) => + knownNodes.has(nodeId) + ); +} + +function inferHighRiskOperation(userText: string) { + const text = userText.toLowerCase(); + if (/删除|删掉|移除/.test(text)) return "delete_nodes"; + if (/覆盖|替换当前|替换原图|直接替换|覆盖原图/.test(text)) { + return "replace_node_media"; + } + if (/批量移动|整体移动|移动到|移到|整理|排列|排版/.test(text)) { + return "move_nodes"; + } + return ""; +} + +function defaultConfirmationReply(riskOperation: string) { + if (riskOperation === "delete_nodes") { + return "这个动作会删除画布节点,需要先确认后才能执行。"; + } + if (riskOperation === "replace_node_media") { + return "这个动作会覆盖或替换现有图片,需要先确认后才能执行。"; + } + if (riskOperation === "move_nodes" || riskOperation === "batch_move") { + return "这个动作会移动或重排画布节点,需要先确认后才能执行。"; + } + return "这个画布动作风险较高,需要先确认后才能执行。"; +} + +function inferMoveLayoutStrategy(userText: string): "row" | "column" | "grid" { + const text = userText.toLowerCase(); + if (/网格|宫格|矩阵|两列|三列|多列|grid/.test(text)) return "grid"; + if (/竖|纵|列|上下|从上到下|column/.test(text)) return "column"; + return "row"; +} + +function buildViewPlan(params: { + action: PlannerDecision["action"]; + context: CanvasAgentContext; + projectId: string; + operationId: string; + nodeIds?: string[]; + reason?: string; +}): CanvasViewOperationPlan | null { + if (params.action === "fit_all") { + return { + version: 1, + viewOperationId: `${params.operationId}:view`, + operation: "fit_all", + projectId: params.projectId, + padding: 120, + persist: false, + reason: params.reason || "model_planned_fit_all", + }; + } + if (params.action === "zoom_in" || params.action === "zoom_out") { + return { + version: 1, + viewOperationId: `${params.operationId}:view`, + operation: params.action, + projectId: params.projectId, + persist: false, + reason: params.reason || `model_planned_${params.action}`, + }; + } + + const nodeIds = + params.nodeIds && params.nodeIds.length > 0 + ? params.nodeIds + : getSelectedKnownNodeIds(params.context, 10); + if (nodeIds.length === 0) return null; + return { + version: 1, + viewOperationId: `${params.operationId}:view`, + operation: nodeIds.length > 1 ? "focus_nodes" : "focus_selection", + projectId: params.projectId, + nodeIds, + anchorNodeId: nodeIds[0], + padding: 120, + persist: false, + reason: params.reason || "model_planned_focus", + }; +} + +function buildContextSummary(context: CanvasAgentContext) { + return { + projectId: context.projectId, + canvasRevision: context.canvasRevision, + selectedNodeIds: context.selectedNodeIds, + viewport: context.viewport, + selectedNodes: context.selectedNodes.slice(0, 12).map((node) => ({ + nodeId: node.nodeId, + type: node.type, + title: node.title, + x: node.x, + y: node.y, + width: node.width, + height: node.height, + })), + candidateNodes: context.candidateNodes.slice(0, 12).map((node) => ({ + nodeId: node.nodeId, + type: node.type, + title: node.title, + x: node.x, + y: node.y, + width: node.width, + height: node.height, + })), + slots: context.slots.slice(0, 12).map((slot) => ({ + nodeId: slot.nodeId, + slotType: slot.slotType, + label: slot.label, + isSelected: slot.isSelected, + fillPolicy: slot.fillPolicy, + })), + annotations: context.annotations.slice(0, 12).map((annotation) => ({ + id: annotation.id, + sourceNodeId: annotation.sourceNodeId, + type: annotation.type, + text: annotation.text, + })), + }; +} + +export function shouldConsiderCanvasAgentModelPlanner(params: { + userText: string; + context?: CanvasAgentContext | null; +}) { + if (!params.context?.projectId) return false; + const text = params.userText.trim().toLowerCase(); + if (!text) return false; + + const hasViewIntent = + /视角|视野|画布|聚焦|定位|居中|看全局|看整体|看全部|缩小|放大|拉近|拉远|zoom/.test( + text + ); + const hasLinkIntent = /连起来|连线|连接|关联|参考关系|版本关系|关系/.test(text); + const hasTextIntent = + /文本|文字|文案|标题|标签|改写|润色|优化|改成|改为|设置为|设为|替换为/.test( + text + ); + const hasHighRiskIntent = Boolean(inferHighRiskOperation(text)); + const hasMediaGenerationIntent = + /生成|出图|做一版|再出|换背景|背景|抠图|去水印|高清|扩图|重绘|视频|变体|图层|多角度|洗图/.test( + text + ); + + if ( + hasMediaGenerationIntent && + !hasViewIntent && + !hasLinkIntent && + !hasTextIntent && + !hasHighRiskIntent + ) { + return false; + } + + return hasViewIntent || hasLinkIntent || hasTextIntent || hasHighRiskIntent; +} + +export function normalizeCanvasAgentModelPlannerDecision(params: { + decision: unknown; + context: CanvasAgentContext; + projectId: string; + messageId: string; + chatId: string; + userText: string; +}): CanvasAgentModelPlannerOutcome { + const parsed = plannerDecisionSchema.safeParse(params.decision); + if (!parsed.success) { + return { kind: "none", reason: "invalid_model_planner_decision" }; + } + + const decision = parsed.data; + const reason = trimString(decision.reason, 280) || undefined; + const operationId = `${params.messageId}:canvas-model-${decision.action}`; + const explicitRisk = trimString(decision.riskOperation, 80); + const inferredRisk = inferHighRiskOperation(params.userText); + const riskOperation = + decision.action === "confirmation_required" + ? explicitRisk || inferredRisk || "other" + : inferredRisk; + if (riskOperation) { + const targetNodeIds = getDecisionNodeIds({ + decision, + context: params.context, + allowSelectedFallback: true, + maxItems: 20, + }); + const canBuildMovePlan = + (riskOperation === "move_nodes" || riskOperation === "batch_move") && + targetNodeIds.length > 0; + const operationPlan: CanvasOperationPlan | undefined = canBuildMovePlan + ? { + version: 1, + operationId: operationId.replace(`canvas-model-${decision.action}`, "canvas-move"), + operation: "move_nodes", + projectId: params.projectId, + baseCanvasRevision: params.context.canvasRevision, + baseCanvasUpdatedAt: params.context.canvasUpdatedAt, + targetNodeIds, + layoutStrategy: inferMoveLayoutStrategy(params.userText), + spacing: 40, + preserveOriginal: true, + metadata: { + messageId: params.messageId, + chatId: params.chatId, + planner: "model", + prompt: params.userText, + pendingConfirmation: true, + riskOperation, + }, + } + : undefined; + return { + kind: "confirmation_required", + riskOperation, + targetNodeIds, + operationPlan, + viewPlan: operationPlan + ? buildViewPlan({ + action: "focus_nodes", + context: params.context, + projectId: params.projectId, + operationId: operationPlan.operationId, + nodeIds: targetNodeIds, + reason: "focus_pending_move_nodes", + }) || undefined + : undefined, + reply: trimString(decision.reply, 280) || defaultConfirmationReply(riskOperation), + reason, + }; + } + + if (decision.action === "none") { + return { kind: "none", reason }; + } + + if ( + decision.action === "focus_nodes" || + decision.action === "fit_all" || + decision.action === "zoom_in" || + decision.action === "zoom_out" + ) { + const nodeIds = getDecisionNodeIds({ + decision, + context: params.context, + allowSelectedFallback: decision.action === "focus_nodes", + maxItems: 10, + }); + const viewPlan = buildViewPlan({ + action: decision.action, + context: params.context, + projectId: params.projectId, + operationId, + nodeIds, + reason, + }); + if (!viewPlan) return { kind: "none", reason: "missing_view_target" }; + return { + kind: "view", + viewPlan, + reply: + trimString(decision.reply, 280) || + (decision.action === "fit_all" + ? "已切到画布全局视图。" + : decision.action === "zoom_in" + ? "已放大画布视角。" + : decision.action === "zoom_out" + ? "已缩小画布视角。" + : "已聚焦到当前画布对象。"), + reason, + }; + } + + if (decision.action === "link_nodes") { + const nodeIds = getDecisionNodeIds({ + decision, + context: params.context, + allowSelectedFallback: true, + maxItems: 20, + }); + if (nodeIds.length < 2) { + return { kind: "none", reason: "link_nodes_requires_two_targets" }; + } + const operationPlan: CanvasOperationPlan = { + version: 1, + operationId, + operation: "link_nodes", + projectId: params.projectId, + baseCanvasRevision: params.context.canvasRevision, + baseCanvasUpdatedAt: params.context.canvasUpdatedAt, + sourceNodeIds: nodeIds, + edgeType: "reference", + preserveOriginal: true, + metadata: { + messageId: params.messageId, + chatId: params.chatId, + planner: "model", + prompt: params.userText, + }, + }; + return { + kind: "operation", + operationPlan, + viewPlan: buildViewPlan({ + action: "focus_nodes", + context: params.context, + projectId: params.projectId, + operationId, + nodeIds, + reason: "focus_model_linked_nodes", + }) || undefined, + reply: trimString(decision.reply, 280) || "已为选中的画布节点建立参考连线。", + reason, + }; + } + + if (decision.action === "update_text") { + const knownNodes = buildKnownNodeMap(params.context); + const requestedIds = getDecisionNodeIds({ + decision, + context: params.context, + allowSelectedFallback: true, + maxItems: 10, + }); + const targetNodeIds = requestedIds.filter( + (nodeId) => knownNodes.get(nodeId)?.type === "text" + ); + const textContent = trimString(decision.textContent, 4000); + if (targetNodeIds.length === 0 || !textContent) { + return { kind: "none", reason: "update_text_missing_target_or_content" }; + } + const operationPlan: CanvasOperationPlan = { + version: 1, + operationId, + operation: "update_text", + projectId: params.projectId, + baseCanvasRevision: params.context.canvasRevision, + baseCanvasUpdatedAt: params.context.canvasUpdatedAt, + targetNodeIds, + textContent, + preserveOriginal: false, + metadata: { + messageId: params.messageId, + chatId: params.chatId, + planner: "model", + prompt: params.userText, + }, + }; + return { + kind: "operation", + operationPlan, + viewPlan: buildViewPlan({ + action: "focus_nodes", + context: params.context, + projectId: params.projectId, + operationId, + nodeIds: targetNodeIds, + reason: "focus_model_updated_text_nodes", + }) || undefined, + reply: trimString(decision.reply, 280) || "已更新选中的文本节点。", + reason, + }; + } + + return { kind: "none", reason: "unsupported_model_planner_action" }; +} + +export async function planCanvasAgentOperationWithModel(params: { + model: LanguageModel; + userText: string; + context: CanvasAgentContext; + projectId: string; + messageId: string; + chatId: string; + abortSignal?: AbortSignal; +}): Promise { + if ( + !shouldConsiderCanvasAgentModelPlanner({ + userText: params.userText, + context: params.context, + }) + ) { + return { kind: "none", reason: "planner_not_relevant" }; + } + + const { object } = await generateObject({ + model: params.model, + schema: plannerDecisionSchema, + temperature: 0, + abortSignal: params.abortSignal || AbortSignal.timeout(18_000), + system: [ + "你是无限画布 Agent 的受控操作规划器。", + "你只能规划低风险画布操作:视角聚焦/缩放/看全局、连接节点、更新文本节点。", + "图片/视频生成、换背景、抠图、高清、扩图、重绘等媒体任务必须返回 action=none,交给其他执行器。", + "删除、覆盖、替换现有图片、移动/重排节点等高风险动作必须返回 action=confirmation_required。", + "只能使用上下文中出现的 nodeId。不要编造节点 ID。", + "如果用户只是聊天或目标不明确,返回 action=none。", + ].join("\n"), + prompt: JSON.stringify( + { + userText: params.userText, + context: buildContextSummary(params.context), + outputContract: { + action: + "none | focus_nodes | fit_all | zoom_in | zoom_out | link_nodes | update_text | confirmation_required", + nodeIds: "只填上下文存在的 nodeId", + textContent: "仅 update_text 时填写最终文本", + riskOperation: + "confirmation_required 时填写 replace_node_media/delete_nodes/move_nodes/overwrite_slot/batch_move/other", + reply: "一句给用户看的中文短回复", + }, + }, + null, + 2 + ), + }); + + return normalizeCanvasAgentModelPlannerDecision({ + decision: object, + context: params.context, + projectId: params.projectId, + messageId: params.messageId, + chatId: params.chatId, + userText: params.userText, + }); +} diff --git a/lib/canvas/agent-mutations.ts b/lib/canvas/agent-mutations.ts new file mode 100644 index 000000000..8805f72bc --- /dev/null +++ b/lib/canvas/agent-mutations.ts @@ -0,0 +1,662 @@ +import "server-only"; + +import { and, eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { canvasProject } from "@/lib/db/schema"; +import { syncCanvasNodesForProject } from "@/lib/db/queries"; +import { resolveCanvasAgentMediaPlacements } from "@/lib/canvas/agent-placement"; +import type { + CanvasMediaItem, + CanvasOperationPlan, +} from "@/types/canvas/agent-protocol"; + +type CanvasDataLike = { + nodes?: any[]; + edges?: any[]; + selectedNodeIds?: string[]; + viewport?: { x: number; y: number; zoom: number }; + [key: string]: unknown; +}; + +export type CanvasOperationResult = { + createdNodeIds: string[]; + createdNodeIdsByMediaItemId: Record; + updatedNodeIds: string[]; + createdEdgeIds: string[]; + canvasData: CanvasDataLike; + canvasRevision: string; + canvasUpdatedAt: string; + baseCanvasWasStale: boolean; +}; + +export class CanvasAgentMutationError extends Error { + status: number; + + constructor(message: string, status = 400) { + super(message); + this.name = "CanvasAgentMutationError"; + this.status = status; + } +} + +function generateId() { + return crypto.randomUUID(); +} + +function normalizeCanvasData(value: unknown): CanvasDataLike { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { + nodes: [], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }; + } + const raw = value as CanvasDataLike; + return { + ...raw, + nodes: Array.isArray(raw.nodes) ? [...raw.nodes] : [], + edges: Array.isArray(raw.edges) ? [...raw.edges] : [], + selectedNodeIds: Array.isArray(raw.selectedNodeIds) + ? raw.selectedNodeIds.filter((id): id is string => typeof id === "string") + : [], + viewport: raw.viewport || { x: 0, y: 0, zoom: 1 }, + }; +} + +function normalizeMediaItems(plan: CanvasOperationPlan): CanvasMediaItem[] { + if (Array.isArray(plan.mediaItems) && plan.mediaItems.length > 0) { + return plan.mediaItems + .map((item, index) => ({ + ...item, + mediaItemId: + typeof item.mediaItemId === "string" && item.mediaItemId.trim() + ? item.mediaItemId.trim() + : `${plan.operationId}:media-${index + 1}`, + url: typeof item.url === "string" ? item.url.trim() : "", + })) + .filter((item) => item.url); + } + + if (plan.media?.url) { + return [ + { + ...plan.media, + mediaItemId: `${plan.operationId}:media-1`, + url: plan.media.url.trim(), + }, + ]; + } + + return []; +} + +function findNode(nodes: any[], nodeId?: string) { + if (!nodeId) return null; + return nodes.find((node) => node?.id === nodeId) || null; +} + +function mediaNodeType(item: CanvasMediaItem) { + return item.type === "video" ? "video" : "image"; +} + +function buildMediaNode(params: { + item: CanvasMediaItem; + plan: CanvasOperationPlan; + position: { x: number; y: number }; + size: { width: number; height: number }; + sourceNodeIds: string[]; +}) { + const now = new Date().toISOString(); + const type = mediaNodeType(params.item); + const metadata = { + ...(params.item.metadata || {}), + generatedByAgent: true, + canvasAgentOperationId: params.plan.operationId, + canvasAgentOperation: params.plan.operation, + canvasAgentMediaItemId: params.item.mediaItemId, + canvasAgentPlacement: params.plan.placement, + canvasAgentPlacementStrategy: params.plan.placementStrategy, + sourceNodeIds: params.sourceNodeIds, + revisionOf: + params.plan.operation === "append_revision" + ? params.sourceNodeIds[0] || null + : undefined, + }; + + return { + id: generateId(), + type, + position: params.position, + size: params.size, + data: + type === "video" + ? { + src: params.item.url, + assetId: params.item.assetId, + status: "completed", + prompt: params.item.prompt, + title: params.item.title || "Agent 视频结果", + metadata, + } + : { + src: params.item.url, + assetId: params.item.assetId, + status: "completed", + prompt: params.item.prompt, + title: params.item.title || "Agent 图片结果", + metadata, + }, + connections: params.sourceNodeIds, + createdAt: now, + updatedAt: now, + }; +} + +function buildEdge(source: string, target: string, type: "default" | "reference") { + return { + id: generateId(), + source, + target, + type, + }; +} + +function rebuildConnections(nodes: any[], edges: any[]) { + const connectionMap = new Map>(); + nodes.forEach((node) => connectionMap.set(node.id, new Set())); + edges.forEach((edge) => { + connectionMap.get(edge.source)?.add(edge.target); + connectionMap.get(edge.target)?.add(edge.source); + }); + nodes.forEach((node) => { + node.connections = Array.from(connectionMap.get(node.id) || []); + }); +} + +function findExistingOperationNodes(nodes: any[], operationId: string) { + return nodes.filter( + (node) => node?.data?.metadata?.canvasAgentOperationId === operationId + ); +} + +function getPlanBaseTime(plan: CanvasOperationPlan) { + const raw = String(plan.baseCanvasUpdatedAt || plan.baseCanvasRevision || "").trim(); + if (!raw) return null; + const parsed = new Date(raw); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function isPlanBaseStale(plan: CanvasOperationPlan, projectUpdatedAt: Date) { + const baseTime = getPlanBaseTime(plan); + if (!baseTime) return false; + return baseTime.getTime() + 1000 < projectUpdatedAt.getTime(); +} + +function normalizeSlotFillPolicy(value: unknown) { + return value === "append-version" || value === "replace-existing" + ? value + : "replace-empty"; +} + +function normalizeEdgeType(value: unknown): "default" | "reference" | "conversation" { + return value === "default" || value === "conversation" ? value : "reference"; +} + +function edgeMatches(edge: any, source: string, target: string) { + return ( + (edge?.source === source && edge?.target === target) || + (edge?.source === target && edge?.target === source) + ); +} + +function getOperationTargetNodeIds(plan: CanvasOperationPlan) { + return Array.from( + new Set( + [ + plan.anchorNodeId || "", + ...(plan.targetNodeIds || []), + ...(plan.sourceNodeIds || []), + ].filter(Boolean) + ) + ); +} + +function getNodeSize(node: any) { + return { + width: Math.max(1, Number(node?.size?.width || 220) || 220), + height: Math.max(1, Number(node?.size?.height || 160) || 160), + }; +} + +function getNodePosition(node: any) { + return { + x: Number(node?.position?.x || 0) || 0, + y: Number(node?.position?.y || 0) || 0, + }; +} + +function isConfirmedHighRiskPlan(plan: CanvasOperationPlan) { + const metadata = (plan.metadata || {}) as Record; + return plan.confirmed === true || metadata.confirmedByUser === true; +} + +function normalizeLayoutStrategy(value: unknown): "row" | "column" | "grid" { + return value === "column" || value === "grid" ? value : "row"; +} + +function resolveMoveStartPosition(nodes: any[], plan: CanvasOperationPlan) { + const target = plan.targetPosition; + if ( + target && + Number.isFinite(Number(target.x)) && + Number.isFinite(Number(target.y)) + ) { + return { + x: Number(target.x), + y: Number(target.y), + }; + } + + const positions = nodes.map(getNodePosition); + return { + x: Math.min(...positions.map((position) => position.x)), + y: Math.min(...positions.map((position) => position.y)), + }; +} + +function applyMoveNodesOperation(params: { + canvasData: CanvasDataLike; + nodes: any[]; + edges: any[]; + plan: CanvasOperationPlan; +}) { + if (!isConfirmedHighRiskPlan(params.plan)) { + throw new CanvasAgentMutationError("移动画布节点需要用户确认", 409); + } + + const targetNodeIds = getOperationTargetNodeIds(params.plan); + if (targetNodeIds.length === 0) { + throw new CanvasAgentMutationError("缺少要移动的节点"); + } + + const targetNodes = targetNodeIds.map((nodeId) => findNode(params.nodes, nodeId)); + const missingIndex = targetNodes.findIndex((node) => !node); + if (missingIndex >= 0) { + throw new CanvasAgentMutationError(`移动节点不存在:${targetNodeIds[missingIndex]}`); + } + + const orderedNodes = (targetNodes as any[]).sort((a, b) => { + const posA = getNodePosition(a); + const posB = getNodePosition(b); + return posA.y === posB.y ? posA.x - posB.x : posA.y - posB.y; + }); + const spacing = Math.max(8, Math.min(240, Number(params.plan.spacing || 40) || 40)); + const layoutStrategy = normalizeLayoutStrategy(params.plan.layoutStrategy); + const start = resolveMoveStartPosition(orderedNodes, params.plan); + const sizes = orderedNodes.map(getNodeSize); + const maxWidth = Math.max(...sizes.map((size) => size.width)); + const maxHeight = Math.max(...sizes.map((size) => size.height)); + const now = new Date().toISOString(); + + orderedNodes.forEach((node, index) => { + let position = { x: start.x, y: start.y }; + if (layoutStrategy === "column") { + const previousHeights = sizes + .slice(0, index) + .reduce((sum, size) => sum + size.height + spacing, 0); + position = { x: start.x, y: start.y + previousHeights }; + } else if (layoutStrategy === "grid") { + const cols = Math.max(1, Math.ceil(Math.sqrt(orderedNodes.length))); + const col = index % cols; + const row = Math.floor(index / cols); + position = { + x: start.x + col * (maxWidth + spacing), + y: start.y + row * (maxHeight + spacing), + }; + } else { + const previousWidths = sizes + .slice(0, index) + .reduce((sum, size) => sum + size.width + spacing, 0); + position = { x: start.x + previousWidths, y: start.y }; + } + + const previousPosition = getNodePosition(node); + const data = (node.data || {}) as Record; + node.position = position; + node.data = { + ...data, + metadata: { + ...(data.metadata || {}), + movedByAgent: true, + canvasAgentOperationId: params.plan.operationId, + canvasAgentOperation: params.plan.operation, + canvasAgentLayoutStrategy: layoutStrategy, + previousPosition, + agentMovedAt: now, + }, + }; + node.updatedAt = now; + }); + + return { + canvasData: { + ...params.canvasData, + nodes: params.nodes, + edges: params.edges, + selectedNodeIds: orderedNodes.map((node) => node.id), + }, + createdNodeIds: [], + createdNodeIdsByMediaItemId: {}, + updatedNodeIds: orderedNodes.map((node) => node.id), + createdEdgeIds: [], + }; +} + +function applyOperationToCanvasData(params: { + canvasData: CanvasDataLike; + plan: CanvasOperationPlan; + baseCanvasWasStale?: boolean; +}) { + if (!params.plan.operationId) { + throw new CanvasAgentMutationError("缺少 operationId"); + } + if ( + params.plan.operation !== "insert_beside" && + params.plan.operation !== "append_revision" && + params.plan.operation !== "fill_slot" && + params.plan.operation !== "link_nodes" && + params.plan.operation !== "move_nodes" && + params.plan.operation !== "update_text" + ) { + throw new CanvasAgentMutationError("当前仅支持非破坏式画布写回操作"); + } + + const nodes = [...(params.canvasData.nodes || [])]; + const edges = [...(params.canvasData.edges || [])]; + if (params.plan.operation === "move_nodes") { + return applyMoveNodesOperation({ + canvasData: params.canvasData, + nodes, + edges, + plan: params.plan, + }); + } + + if (params.plan.operation === "update_text") { + const textContent = String(params.plan.textContent || "").trim(); + if (!textContent) { + throw new CanvasAgentMutationError("缺少文本内容"); + } + + const targetNodeIds = getOperationTargetNodeIds(params.plan); + if (targetNodeIds.length === 0) { + throw new CanvasAgentMutationError("缺少要更新的文本节点"); + } + + const now = new Date().toISOString(); + const updatedNodeIds: string[] = []; + targetNodeIds.forEach((nodeId) => { + const node = findNode(nodes, nodeId); + if (!node) { + throw new CanvasAgentMutationError(`文本节点不存在:${nodeId}`); + } + if (node.type !== "text") { + throw new CanvasAgentMutationError("update_text 只能修改文本节点"); + } + + const data = (node.data || {}) as Record; + node.data = { + ...data, + content: textContent, + metadata: { + ...(data.metadata || {}), + updatedByAgent: true, + canvasAgentOperationId: params.plan.operationId, + canvasAgentOperation: params.plan.operation, + previousContent: + typeof data.content === "string" ? String(data.content).slice(0, 2000) : undefined, + agentUpdatedAt: now, + }, + }; + node.updatedAt = now; + updatedNodeIds.push(node.id); + }); + + return { + canvasData: { + ...params.canvasData, + nodes, + edges, + selectedNodeIds: updatedNodeIds, + }, + createdNodeIds: [], + createdNodeIdsByMediaItemId: {}, + updatedNodeIds, + createdEdgeIds: [], + }; + } + + if (params.plan.operation === "link_nodes") { + const nodeIds = getOperationTargetNodeIds(params.plan); + if (nodeIds.length < 2) { + throw new CanvasAgentMutationError("连线操作至少需要两个节点"); + } + const missingNodeId = nodeIds.find((nodeId) => !findNode(nodes, nodeId)); + if (missingNodeId) { + throw new CanvasAgentMutationError(`连线节点不存在:${missingNodeId}`); + } + + const edgeType = normalizeEdgeType(params.plan.edgeType); + const [sourceNodeId, ...targetNodeIds] = nodeIds; + const createdEdgeIds: string[] = []; + targetNodeIds.forEach((targetNodeId) => { + const exists = edges.some((edge) => edgeMatches(edge, sourceNodeId, targetNodeId)); + if (exists) return; + const edge = { + id: generateId(), + source: sourceNodeId, + target: targetNodeId, + type: edgeType, + }; + edges.push(edge); + createdEdgeIds.push(edge.id); + }); + rebuildConnections(nodes, edges); + + return { + canvasData: { + ...params.canvasData, + nodes, + edges, + selectedNodeIds: nodeIds, + }, + createdNodeIds: [], + createdNodeIdsByMediaItemId: {}, + updatedNodeIds: nodeIds, + createdEdgeIds, + }; + } + + const existingOperationNodes = findExistingOperationNodes( + nodes, + params.plan.operationId + ); + if (existingOperationNodes.length > 0) { + const byMediaItemId: Record = {}; + existingOperationNodes.forEach((node) => { + const mediaItemId = String( + node?.data?.metadata?.canvasAgentMediaItemId || "" + ).trim(); + if (mediaItemId) byMediaItemId[mediaItemId] = node.id; + }); + return { + canvasData: params.canvasData, + createdNodeIds: existingOperationNodes.map((node) => node.id), + createdNodeIdsByMediaItemId: byMediaItemId, + updatedNodeIds: [], + createdEdgeIds: [], + }; + } + + const mediaItems = normalizeMediaItems(params.plan); + if (mediaItems.length === 0) { + throw new CanvasAgentMutationError("缺少可写回的媒体结果"); + } + + const targetSlot = + params.plan.operation === "fill_slot" + ? findNode(nodes, params.plan.targetSlotId) + : null; + if (params.plan.operation === "fill_slot" && !targetSlot) { + throw new CanvasAgentMutationError("目标 slot 不存在"); + } + if (targetSlot && params.plan.operation === "fill_slot") { + const targetSlotData = (targetSlot.data || {}) as Record; + const fillPolicy = normalizeSlotFillPolicy(targetSlotData.fillPolicy); + const filledNodeIds = Array.isArray(targetSlotData.filledNodeIds) + ? targetSlotData.filledNodeIds.filter((id) => typeof id === "string") + : []; + + if (fillPolicy === "replace-existing" && filledNodeIds.length > 0) { + throw new CanvasAgentMutationError( + "当前 slot 已有结果,替换已有结果需要用户确认", + 409 + ); + } + if (fillPolicy === "replace-empty" && filledNodeIds.length > 0) { + throw new CanvasAgentMutationError("当前 slot 已被填充,请新建 slot 或使用追加版本", 409); + } + if (params.baseCanvasWasStale && fillPolicy === "replace-existing") { + throw new CanvasAgentMutationError( + "画布已更新,替换 slot 前需要刷新并确认", + 409 + ); + } + } + + const createdNodeIds: string[] = []; + const createdNodeIdsByMediaItemId: Record = {}; + const createdEdgeIds: string[] = []; + const updatedNodeIds: string[] = []; + const placements = resolveCanvasAgentMediaPlacements({ + plan: params.plan, + mediaItems, + nodes, + targetSlot, + viewport: params.canvasData.viewport, + }); + + placements.forEach((placement) => { + const node = buildMediaNode({ + item: placement.item, + plan: params.plan, + position: placement.position, + size: placement.size, + sourceNodeIds: placement.sourceNodeIds, + }); + nodes.push(node); + createdNodeIds.push(node.id); + createdNodeIdsByMediaItemId[placement.item.mediaItemId] = node.id; + + const edgeSource = placement.edgeSourceId; + if (edgeSource && edgeSource !== node.id) { + const edge = buildEdge( + edgeSource, + node.id, + params.plan.operation === "append_revision" ? "reference" : "default" + ); + edges.push(edge); + createdEdgeIds.push(edge.id); + } + + if (targetSlot && params.plan.operation === "fill_slot") { + const data = { ...(targetSlot.data || {}) }; + const filledNodeIds = Array.isArray(data.filledNodeIds) + ? data.filledNodeIds + : []; + data.status = "completed"; + data.filledNodeIds = Array.from(new Set([...filledNodeIds, node.id])); + targetSlot.data = data; + if (!updatedNodeIds.includes(targetSlot.id)) { + updatedNodeIds.push(targetSlot.id); + } + } + }); + + rebuildConnections(nodes, edges); + + return { + canvasData: { + ...params.canvasData, + nodes, + edges, + selectedNodeIds: createdNodeIds, + }, + createdNodeIds, + createdNodeIdsByMediaItemId, + updatedNodeIds, + createdEdgeIds, + }; +} + +export async function applyCanvasOperationPlan(params: { + userId: string; + projectId: string; + plan: CanvasOperationPlan; +}): Promise { + if (params.plan.projectId && params.plan.projectId !== params.projectId) { + throw new CanvasAgentMutationError("operation projectId 与请求项目不一致"); + } + + const [project] = await db + .select() + .from(canvasProject) + .where( + and( + eq(canvasProject.id, params.projectId), + eq(canvasProject.userId, params.userId) + ) + ); + + if (!project) { + throw new CanvasAgentMutationError("画布不存在", 404); + } + + const currentCanvasData = normalizeCanvasData(project.canvasData); + const baseCanvasWasStale = isPlanBaseStale(params.plan, project.updatedAt); + const applied = applyOperationToCanvasData({ + canvasData: currentCanvasData, + plan: { + ...params.plan, + projectId: params.projectId, + }, + baseCanvasWasStale, + }); + const syncedCanvasData = await syncCanvasNodesForProject({ + projectId: params.projectId, + userId: params.userId, + canvasData: applied.canvasData, + }); + const updatedAt = new Date(); + const [updatedProject] = await db + .update(canvasProject) + .set({ + canvasData: syncedCanvasData, + updatedAt, + }) + .where( + and( + eq(canvasProject.id, params.projectId), + eq(canvasProject.userId, params.userId) + ) + ) + .returning(); + + return { + ...applied, + canvasData: (updatedProject?.canvasData || syncedCanvasData) as CanvasDataLike, + canvasRevision: updatedAt.toISOString(), + canvasUpdatedAt: updatedAt.toISOString(), + baseCanvasWasStale, + }; +} diff --git a/lib/canvas/agent-placement.ts b/lib/canvas/agent-placement.ts new file mode 100644 index 000000000..e58ff063a --- /dev/null +++ b/lib/canvas/agent-placement.ts @@ -0,0 +1,307 @@ +import type { + CanvasMediaItem, + CanvasOperationPlan, +} from "@/types/canvas/agent-protocol"; + +export type CanvasAgentPlacementNode = { + id: string; + position?: { x?: number; y?: number }; + size?: { width?: number; height?: number }; +}; + +export type ResolvedCanvasAgentMediaPlacement = { + item: CanvasMediaItem; + position: { x: number; y: number }; + size: { width: number; height: number }; + sourceNodeIds: string[]; + edgeSourceId?: string; +}; + +function uniqueStrings(values: Array) { + return Array.from( + new Set(values.map((value) => String(value || "").trim()).filter(Boolean)) + ); +} + +function findNode(nodes: CanvasAgentPlacementNode[], nodeId?: string | null) { + if (!nodeId) return null; + return nodes.find((node) => node.id === nodeId) || null; +} + +function getNodeBounds(node?: CanvasAgentPlacementNode | null) { + if (!node) return null; + const width = Math.max(1, Number(node.size?.width || 320) || 320); + const height = Math.max(1, Number(node.size?.height || 320) || 320); + return { + x: Number(node.position?.x || 0) || 0, + y: Number(node.position?.y || 0) || 0, + width, + height, + }; +} + +function getViewportCenter(viewport?: { x: number; y: number; zoom: number }) { + const zoom = Math.max(0.1, Number(viewport?.zoom || 1) || 1); + return { + x: -Number(viewport?.x || 0) / zoom, + y: -Number(viewport?.y || 0) / zoom, + }; +} + +function getViewportCenteredPosition(params: { + index: number; + itemCount: number; + groupIndex: number; + size: { width: number; height: number }; + strategy: CanvasOperationPlan["placementStrategy"]; + viewport?: { x: number; y: number; zoom: number }; +}) { + const stepGap = 24; + const center = getViewportCenter(params.viewport); + const placementIndex = + params.strategy === "beside_each_source" ? params.groupIndex : params.index; + + if (params.strategy === "grid") { + const cols = Math.max(1, Math.ceil(Math.sqrt(params.itemCount))); + const rows = Math.max(1, Math.ceil(params.itemCount / cols)); + const col = placementIndex % cols; + const row = Math.floor(placementIndex / cols); + const totalWidth = cols * params.size.width + (cols - 1) * stepGap; + const totalHeight = rows * params.size.height + (rows - 1) * stepGap; + return { + x: center.x - totalWidth / 2 + col * (params.size.width + stepGap), + y: center.y - totalHeight / 2 + row * (params.size.height + stepGap), + }; + } + + const totalWidth = + params.itemCount * params.size.width + (params.itemCount - 1) * stepGap; + return { + x: + center.x - + totalWidth / 2 + + placementIndex * (params.size.width + stepGap), + y: center.y - params.size.height / 2, + }; +} + +function getItemSourceNodeIds(item: CanvasMediaItem, plan: CanvasOperationPlan) { + const itemSourceNodeIds = Array.isArray(item.sourceNodeIds) + ? item.sourceNodeIds + : []; + const primarySourceIds = + itemSourceNodeIds.length > 0 ? itemSourceNodeIds : plan.sourceNodeIds || []; + + return uniqueStrings([ + ...primarySourceIds, + plan.operation === "fill_slot" ? plan.targetSlotId : plan.anchorNodeId, + ]); +} + +function getAnchorNode(params: { + plan: CanvasOperationPlan; + item: CanvasMediaItem; + nodes: CanvasAgentPlacementNode[]; + targetSlot?: CanvasAgentPlacementNode | null; + sourceNodeIds: string[]; +}) { + if (params.targetSlot && params.plan.operation === "fill_slot") { + return params.targetSlot; + } + + if (params.plan.placementStrategy === "beside_each_source") { + const itemSourceNodeId = Array.isArray(params.item.sourceNodeIds) + ? params.item.sourceNodeIds.find(Boolean) + : null; + const itemSourceNode = findNode(params.nodes, itemSourceNodeId); + if (itemSourceNode) return itemSourceNode; + } + + return ( + findNode(params.nodes, params.plan.anchorNodeId) || + params.sourceNodeIds + .map((nodeId) => findNode(params.nodes, nodeId)) + .find(Boolean) || + null + ); +} + +function resolveSize(params: { + item: CanvasMediaItem; + anchorNode?: CanvasAgentPlacementNode | null; + targetSlot?: CanvasAgentPlacementNode | null; + isSlotFill: boolean; +}) { + const slotBounds = getNodeBounds(params.targetSlot); + if (params.isSlotFill && slotBounds) { + return { + width: slotBounds.width, + height: slotBounds.height, + }; + } + + const anchorBounds = getNodeBounds(params.anchorNode); + return { + width: Math.max(1, Number(params.item.width || anchorBounds?.width || 320)), + height: Math.max(1, Number(params.item.height || anchorBounds?.height || 320)), + }; +} + +function resolvePosition(params: { + plan: CanvasOperationPlan; + index: number; + itemCount: number; + groupIndex: number; + size: { width: number; height: number }; + anchorNode?: CanvasAgentPlacementNode | null; + targetSlot?: CanvasAgentPlacementNode | null; + viewport?: { x: number; y: number; zoom: number }; +}) { + const gap = 40; + const stepGap = 24; + const targetSlotBounds = getNodeBounds(params.targetSlot); + if (params.plan.operation === "fill_slot" && targetSlotBounds) { + return { x: targetSlotBounds.x, y: targetSlotBounds.y }; + } + + const anchorBounds = getNodeBounds(params.anchorNode); + const placement = params.plan.placement || "right"; + const strategy = params.plan.placementStrategy || "single"; + const placementIndex = + strategy === "beside_each_source" ? params.groupIndex : params.index; + + if (strategy === "overlay" && anchorBounds) { + return { x: anchorBounds.x, y: anchorBounds.y }; + } + + if (placement === "viewport-center") { + return getViewportCenteredPosition({ + index: params.index, + itemCount: params.itemCount, + groupIndex: params.groupIndex, + size: params.size, + strategy, + viewport: params.viewport, + }); + } + + if (strategy === "grid") { + const cols = Math.max(1, Math.ceil(Math.sqrt(params.itemCount))); + const col = placementIndex % cols; + const row = Math.floor(placementIndex / cols); + if (anchorBounds) { + if (placement === "left") { + return { + x: anchorBounds.x - gap - (col + 1) * params.size.width - col * stepGap, + y: anchorBounds.y + row * (params.size.height + stepGap), + }; + } + if (placement === "above") { + return { + x: anchorBounds.x + col * (params.size.width + stepGap), + y: anchorBounds.y - gap - (row + 1) * params.size.height - row * stepGap, + }; + } + if (placement === "below") { + return { + x: anchorBounds.x + col * (params.size.width + stepGap), + y: anchorBounds.y + anchorBounds.height + gap + row * (params.size.height + stepGap), + }; + } + return { + x: anchorBounds.x + anchorBounds.width + gap + col * (params.size.width + stepGap), + y: anchorBounds.y + row * (params.size.height + stepGap), + }; + } + + const center = getViewportCenter(params.viewport); + return { + x: center.x + col * (params.size.width + stepGap), + y: center.y + row * (params.size.height + stepGap), + }; + } + + if (anchorBounds) { + if (placement === "left") { + return { + x: anchorBounds.x - gap - params.size.width - placementIndex * (params.size.width + stepGap), + y: anchorBounds.y, + }; + } + if (placement === "above") { + return { + x: anchorBounds.x + placementIndex * (params.size.width + stepGap), + y: anchorBounds.y - gap - params.size.height, + }; + } + if (placement === "below") { + return { + x: anchorBounds.x + placementIndex * (params.size.width + stepGap), + y: anchorBounds.y + anchorBounds.height + gap, + }; + } + return { + x: anchorBounds.x + anchorBounds.width + gap + placementIndex * (params.size.width + stepGap), + y: anchorBounds.y, + }; + } + + const center = getViewportCenter(params.viewport); + return { + x: center.x + placementIndex * (params.size.width + stepGap), + y: center.y, + }; +} + +export function resolveCanvasAgentMediaPlacements(params: { + plan: CanvasOperationPlan; + mediaItems: CanvasMediaItem[]; + nodes: CanvasAgentPlacementNode[]; + targetSlot?: CanvasAgentPlacementNode | null; + viewport?: { x: number; y: number; zoom: number }; +}): ResolvedCanvasAgentMediaPlacement[] { + const groupCounts = new Map(); + + return params.mediaItems.map((item, index) => { + const sourceNodeIds = getItemSourceNodeIds(item, params.plan); + const anchorNode = getAnchorNode({ + plan: params.plan, + item, + nodes: params.nodes, + targetSlot: params.targetSlot, + sourceNodeIds, + }); + const anchorKey = anchorNode?.id || "__canvas"; + const groupIndex = groupCounts.get(anchorKey) || 0; + groupCounts.set(anchorKey, groupIndex + 1); + const isSlotFill = Boolean(params.targetSlot && params.plan.operation === "fill_slot"); + const size = resolveSize({ + item, + anchorNode, + targetSlot: params.targetSlot, + isSlotFill, + }); + const position = resolvePosition({ + plan: params.plan, + index, + itemCount: params.mediaItems.length, + groupIndex, + size, + anchorNode, + targetSlot: params.targetSlot, + viewport: params.viewport, + }); + + return { + item, + position, + size, + sourceNodeIds, + edgeSourceId: + params.targetSlot?.id || + anchorNode?.id || + sourceNodeIds[0] || + params.plan.anchorNodeId, + }; + }); +} diff --git a/services/canvas/index.ts b/services/canvas/index.ts index f5e264627..6d5e0a20c 100644 --- a/services/canvas/index.ts +++ b/services/canvas/index.ts @@ -32,6 +32,7 @@ interface ApiResponse { success: boolean; data?: T; error?: string; + status?: number; pagination?: { page: number; limit: number; @@ -194,11 +195,17 @@ export const canvasService = { async saveCanvasState( id: string, canvasData: CanvasState, - workspaceType?: CanvasWorkspaceType + workspaceType?: CanvasWorkspaceType, + options?: { + baseCanvasRevision?: string | null; + baseCanvasUpdatedAt?: string | null; + } ): Promise { try { const requestBody = JSON.stringify( - workspaceType ? { canvasData, workspaceType } : { canvasData } + workspaceType + ? { canvasData, workspaceType, ...options } + : { canvasData, ...options } ); const doSave = async () => fetch(`/api/canvas/${id}`, { @@ -218,7 +225,11 @@ export const canvasService = { const data = await response.json().catch(() => ({})); if (!response.ok) { - return { success: false, error: String(data?.error || data?.message || '保存画布状态失败') }; + return { + success: false, + error: String(data?.error || data?.message || '保存画布状态失败'), + status: response.status, + }; } return data as ApiResponse; } catch (error) { @@ -329,6 +340,7 @@ export const imageGenerationService = { color: string; prompt: string; rect: NormalizedCropRect; + targetRect?: NormalizedCropRect | null; referenceImages?: Array<{ id?: string; url: string; @@ -340,7 +352,7 @@ export const imageGenerationService = { imageSize?: string; aspectRatio?: string; operation?: "scale-adjust"; - scalePercent?: -30 | -20 | -10 | 10 | 20 | 30 | 50 | 100; + scalePercent?: number; }): Promise< ApiResponse<{ annotatedReferenceUrl: string; diff --git a/store/canvas/index.ts b/store/canvas/index.ts index fd3497ae6..70658fa4d 100644 --- a/store/canvas/index.ts +++ b/store/canvas/index.ts @@ -81,17 +81,23 @@ export interface ImageNodeActionRequest { requestId: string; } +type CanvasViewportSource = 'user' | 'agent-transient'; + // Store 状态接口 interface CanvasStore { // 项目信息 projectId: string | null; projectTitle: string; + canvasRevision: string | null; + canvasUpdatedAt: string | null; // 画布状态 nodes: CanvasNode[]; edges: CanvasEdge[]; selectedNodeIds: string[]; viewport: Viewport; + viewportSource: CanvasViewportSource; + lastPersistentViewport: Viewport; tool: ToolType; suppressedMediaKeys: string[]; styleProState: StyleProPersistedState; @@ -121,6 +127,7 @@ interface CanvasStore { // 项目操作 setProject: (id: string | null, title?: string) => void; setProjectTitle: (title: string) => void; + setCanvasRevision: (revision?: string | null, updatedAt?: string | null) => void; // 节点操作 addNode: (node: Omit) => string; @@ -141,6 +148,7 @@ interface CanvasStore { // 视口操作 setViewport: (viewport: Partial) => void; + setTransientViewport: (viewport: Partial) => void; zoomIn: () => void; zoomOut: () => void; zoomTo: (zoom: number) => void; @@ -198,10 +206,14 @@ export const useCanvasStore = create()( // 初始状态 projectId: null, projectTitle: '未命名画布', + canvasRevision: null, + canvasUpdatedAt: null, nodes: [], edges: [], selectedNodeIds: [], viewport: defaultViewport, + viewportSource: 'user', + lastPersistentViewport: defaultViewport, tool: 'select', suppressedMediaKeys: [], styleProState: { history: [] }, @@ -230,6 +242,11 @@ export const useCanvasStore = create()( state.projectTitle = title; }), + setCanvasRevision: (revision, updatedAt) => set((state) => { + state.canvasRevision = String(revision || "").trim() || null; + state.canvasUpdatedAt = String(updatedAt || revision || "").trim() || null; + }), + // 节点操作 addNode: (nodeData) => { const imageMetadata = @@ -453,24 +470,40 @@ export const useCanvasStore = create()( // 视口操作 setViewport: (viewport) => set((state) => { + const nextViewport = { ...state.viewport, ...viewport }; + state.viewport = nextViewport; + state.viewportSource = 'user'; + state.lastPersistentViewport = nextViewport; + }), + + setTransientViewport: (viewport) => set((state) => { state.viewport = { ...state.viewport, ...viewport }; + state.viewportSource = 'agent-transient'; }), zoomIn: () => set((state) => { state.viewport.zoom = Math.min(state.viewport.zoom * 1.2, 4); + state.viewportSource = 'user'; + state.lastPersistentViewport = { ...state.viewport }; }), zoomOut: () => set((state) => { state.viewport.zoom = Math.max(state.viewport.zoom / 1.2, 0.1); + state.viewportSource = 'user'; + state.lastPersistentViewport = { ...state.viewport }; }), zoomTo: (zoom) => set((state) => { state.viewport.zoom = Math.max(0.1, Math.min(zoom, 4)); + state.viewportSource = 'user'; + state.lastPersistentViewport = { ...state.viewport }; }), fitToScreen: () => set((state) => { // TODO: 计算所有节点的边界并调整视口 state.viewport = { x: 0, y: 0, zoom: 1 }; + state.viewportSource = 'user'; + state.lastPersistentViewport = state.viewport; }), centerNode: (nodeId) => { @@ -479,6 +512,8 @@ export const useCanvasStore = create()( set((state) => { state.viewport.x = -node.position.x - node.size.width / 2; state.viewport.y = -node.position.y - node.size.height / 2; + state.viewportSource = 'user'; + state.lastPersistentViewport = { ...state.viewport }; }); } }, @@ -551,7 +586,11 @@ export const useCanvasStore = create()( if (canvasState.edges) state.edges = canvasState.edges; rebuildNodeConnections(state.nodes, state.edges); if (canvasState.selectedNodeIds) state.selectedNodeIds = canvasState.selectedNodeIds; - if (canvasState.viewport) state.viewport = canvasState.viewport; + if (canvasState.viewport) { + state.viewport = canvasState.viewport; + state.lastPersistentViewport = canvasState.viewport; + state.viewportSource = 'user'; + } if (canvasState.tool) state.tool = canvasState.tool; state.suppressedMediaKeys = Array.isArray(canvasState.suppressedMediaKeys) ? canvasState.suppressedMediaKeys.map((item) => String(item || "").trim()).filter(Boolean) @@ -564,6 +603,10 @@ export const useCanvasStore = create()( state.edges = []; state.selectedNodeIds = []; state.viewport = defaultViewport; + state.viewportSource = 'user'; + state.lastPersistentViewport = defaultViewport; + state.canvasRevision = null; + state.canvasUpdatedAt = null; state.suppressedMediaKeys = []; state.styleProState = { history: [] }; state.history = []; @@ -572,11 +615,15 @@ export const useCanvasStore = create()( getCanvasState: () => { const state = get(); + const persistedViewport = + state.viewportSource === 'agent-transient' + ? state.lastPersistentViewport + : state.viewport; return { nodes: state.nodes, edges: state.edges, selectedNodeIds: state.selectedNodeIds, - viewport: state.viewport, + viewport: persistedViewport, tool: state.tool, suppressedMediaKeys: state.suppressedMediaKeys, styleProState: normalizeStyleProPersistedState(state.styleProState), diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 97c4e76e1..75483f0fd 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -7,6 +7,19 @@ import { stripMediaMarkup, } from "@/components/canvas/chat-panel/canvas-chat-media"; import { normalizeCanvasAgentContext } from "@/lib/canvas/agent-context"; +import { + buildPendingCanvasOperation, + extractPendingCanvasOperationFromWorkingState, + isCanvasOperationCancelText, + isCanvasOperationConfirmText, + withConfirmedCanvasOperationPlan, +} from "@/lib/canvas/agent-confirmation"; +import { + normalizeCanvasAgentModelPlannerDecision, + shouldConsiderCanvasAgentModelPlanner, +} from "@/lib/canvas/agent-model-planner"; +import { resolveCanvasAgentMediaPlacements } from "@/lib/canvas/agent-placement"; +import { useCanvasStore } from "@/store/canvas"; import { buildDetailResultMarkdown, buildDetailReviewAppendMarkdown, @@ -47,9 +60,366 @@ import { validateCanvasImageUploadFile, } from "@/components/canvas/chat-panel/canvas-upload-helpers"; import type { PickedObjectAttachment } from "@/components/canvas/chat-panel/canvas-chat-session"; +import type { CanvasAgentContext } from "@/types/canvas/agent-protocol"; import type { ChatMessage } from "@/lib/types"; test.describe("canvas chat pure helpers", () => { + const plannerContext: CanvasAgentContext = { + version: 1, + source: "canvas", + projectId: "11111111-1111-4111-8111-111111111111", + canvasRevision: "2026-06-22T00:00:00.000Z", + canvasUpdatedAt: "2026-06-22T00:00:00.000Z", + viewport: { x: 0, y: 0, zoom: 1 }, + selectedNodeIds: ["image-1", "text-1"], + selectedNodes: [ + { + nodeId: "image-1", + type: "image", + title: "主图", + x: 10, + y: 20, + width: 300, + height: 240, + isSelected: true, + }, + { + nodeId: "text-1", + type: "text", + title: "旧标题", + x: 40, + y: 80, + width: 240, + height: 80, + isSelected: true, + }, + ], + candidateNodes: [], + slots: [], + annotations: [], + focusPolicy: "selected", + }; + + test("keeps canvas model planner scoped to low-risk canvas operations", () => { + expect( + shouldConsiderCanvasAgentModelPlanner({ + userText: "把选中的文案润色得更高级", + context: plannerContext, + }) + ).toBe(true); + expect( + shouldConsiderCanvasAgentModelPlanner({ + userText: "给这张图换成夏天户外背景,新图放右边", + context: plannerContext, + }) + ).toBe(false); + }); + + test("normalizes model-planned focus to known canvas nodes only", () => { + const outcome = normalizeCanvasAgentModelPlannerDecision({ + decision: { + action: "focus_nodes", + nodeIds: ["missing-node", "image-1"], + reply: "已聚焦主图。", + }, + context: plannerContext, + projectId: plannerContext.projectId || "", + messageId: "message-1", + chatId: "chat-1", + userText: "帮我定位到主图", + }); + + expect(outcome.kind).toBe("view"); + expect(outcome.kind === "view" ? outcome.viewPlan.nodeIds : []).toEqual([ + "image-1", + ]); + }); + + test("normalizes model-planned text updates to selected text nodes", () => { + const outcome = normalizeCanvasAgentModelPlannerDecision({ + decision: { + action: "update_text", + nodeIds: ["image-1", "text-1"], + textContent: "轻盈强吸力,家务更省心", + }, + context: plannerContext, + projectId: plannerContext.projectId || "", + messageId: "message-1", + chatId: "chat-1", + userText: "把选中文案改得更高级", + }); + + expect(outcome.kind).toBe("operation"); + expect(outcome.kind === "operation" ? outcome.operationPlan : null).toMatchObject({ + operation: "update_text", + targetNodeIds: ["text-1"], + textContent: "轻盈强吸力,家务更省心", + }); + }); + + test("turns destructive model-planned canvas requests into confirmation", () => { + const outcome = normalizeCanvasAgentModelPlannerDecision({ + decision: { + action: "none", + }, + context: plannerContext, + projectId: plannerContext.projectId || "", + messageId: "message-1", + chatId: "chat-1", + userText: "删除选中的这两项", + }); + + expect(outcome).toMatchObject({ + kind: "confirmation_required", + riskOperation: "delete_nodes", + targetNodeIds: ["image-1", "text-1"], + }); + }); + + test("builds pending move-node operation from model-planned high-risk layout", () => { + const outcome = normalizeCanvasAgentModelPlannerDecision({ + decision: { + action: "confirmation_required", + riskOperation: "move_nodes", + nodeIds: ["image-1", "text-1"], + }, + context: plannerContext, + projectId: plannerContext.projectId || "", + messageId: "message-1", + chatId: "chat-1", + userText: "把选中的两项整理成一行", + }); + + expect(outcome.kind).toBe("confirmation_required"); + expect( + outcome.kind === "confirmation_required" ? outcome.operationPlan : null + ).toMatchObject({ + operation: "move_nodes", + targetNodeIds: ["image-1", "text-1"], + layoutStrategy: "row", + }); + expect( + outcome.kind === "confirmation_required" ? outcome.operationPlan?.confirmed : true + ).toBeUndefined(); + }); + + test("extracts and confirms pending canvas operations from working state", () => { + const pending = buildPendingCanvasOperation({ + projectId: plannerContext.projectId || "", + messageId: "message-1", + userText: "把选中的两项整理成一行", + riskOperation: "move_nodes", + targetNodeIds: ["image-1", "text-1"], + reply: "移动节点需要确认。", + operationPlan: { + version: 1, + operationId: "message-1:canvas-move", + operation: "move_nodes", + projectId: plannerContext.projectId || "", + targetNodeIds: ["image-1", "text-1"], + layoutStrategy: "row", + metadata: { + pendingConfirmation: true, + }, + }, + }); + + const extracted = extractPendingCanvasOperationFromWorkingState({ + lastTurnSummary: { + pendingCanvasOperation: pending, + }, + }); + expect(extracted?.operationPlan?.operation).toBe("move_nodes"); + expect(isCanvasOperationConfirmText("确认执行")).toBe(true); + expect(isCanvasOperationCancelText("先不执行了")).toBe(true); + + const confirmedPlan = extracted + ? withConfirmedCanvasOperationPlan(extracted, "message-2") + : null; + expect(confirmedPlan).toMatchObject({ + operation: "move_nodes", + confirmed: true, + metadata: { + confirmedByUser: true, + confirmedByMessageId: "message-2", + }, + }); + }); + + test("keeps agent transient viewport out of persisted canvas state", () => { + const store = useCanvasStore.getState(); + store.clearCanvas(); + store.setViewport({ x: 10, y: 20, zoom: 2 }); + store.setTransientViewport({ x: 100, y: 200, zoom: 3 }); + + expect(useCanvasStore.getState().viewport).toEqual({ x: 100, y: 200, zoom: 3 }); + expect(useCanvasStore.getState().getCanvasState().viewport).toEqual({ + x: 10, + y: 20, + zoom: 2, + }); + + store.clearCanvas(); + }); + + test("resolves grid media placements to the right of an anchor node", () => { + const placements = resolveCanvasAgentMediaPlacements({ + plan: { + version: 1, + operationId: "op-grid", + operation: "insert_beside", + projectId: "project-1", + anchorNodeId: "anchor-1", + placement: "right", + placementStrategy: "grid", + }, + mediaItems: [ + { mediaItemId: "media-1", type: "image", url: "https://cdn.example.com/1.png", width: 100, height: 80 }, + { mediaItemId: "media-2", type: "image", url: "https://cdn.example.com/2.png", width: 100, height: 80 }, + { mediaItemId: "media-3", type: "image", url: "https://cdn.example.com/3.png", width: 100, height: 80 }, + ], + nodes: [ + { + id: "anchor-1", + position: { x: 10, y: 20 }, + size: { width: 300, height: 200 }, + }, + ], + }); + + expect(placements.map((placement) => placement.position)).toEqual([ + { x: 350, y: 20 }, + { x: 474, y: 20 }, + { x: 350, y: 124 }, + ]); + expect(placements.map((placement) => placement.edgeSourceId)).toEqual([ + "anchor-1", + "anchor-1", + "anchor-1", + ]); + }); + + test("resolves batch media beside each item source node", () => { + const placements = resolveCanvasAgentMediaPlacements({ + plan: { + version: 1, + operationId: "op-source-batch", + operation: "insert_beside", + projectId: "project-1", + placement: "right", + placementStrategy: "beside_each_source", + }, + mediaItems: [ + { + mediaItemId: "media-a-1", + type: "image", + url: "https://cdn.example.com/a1.png", + width: 20, + height: 20, + sourceNodeIds: ["source-a"], + }, + { + mediaItemId: "media-b-1", + type: "image", + url: "https://cdn.example.com/b1.png", + width: 20, + height: 20, + sourceNodeIds: ["source-b"], + }, + { + mediaItemId: "media-a-2", + type: "image", + url: "https://cdn.example.com/a2.png", + width: 20, + height: 20, + sourceNodeIds: ["source-a"], + }, + ], + nodes: [ + { + id: "source-a", + position: { x: 0, y: 0 }, + size: { width: 50, height: 50 }, + }, + { + id: "source-b", + position: { x: 1000, y: 10 }, + size: { width: 50, height: 50 }, + }, + ], + }); + + expect(placements.map((placement) => placement.position)).toEqual([ + { x: 90, y: 0 }, + { x: 1090, y: 10 }, + { x: 134, y: 0 }, + ]); + expect(placements.map((placement) => placement.edgeSourceId)).toEqual([ + "source-a", + "source-b", + "source-a", + ]); + expect(placements[0]?.sourceNodeIds).toEqual(["source-a"]); + }); + + test("resolves overlay and viewport-centered media placements", () => { + const [overlay] = resolveCanvasAgentMediaPlacements({ + plan: { + version: 1, + operationId: "op-overlay", + operation: "append_revision", + projectId: "project-1", + anchorNodeId: "source-1", + placementStrategy: "overlay", + }, + mediaItems: [ + { + mediaItemId: "media-overlay", + type: "image", + url: "https://cdn.example.com/overlay.png", + }, + ], + nodes: [ + { + id: "source-1", + position: { x: 120, y: 80 }, + size: { width: 160, height: 90 }, + }, + ], + }); + expect(overlay?.position).toEqual({ x: 120, y: 80 }); + expect(overlay?.size).toEqual({ width: 160, height: 90 }); + + const centeredPlacements = resolveCanvasAgentMediaPlacements({ + plan: { + version: 1, + operationId: "op-center", + operation: "insert_beside", + projectId: "project-1", + anchorNodeId: "source-1", + placement: "viewport-center", + placementStrategy: "grid", + }, + mediaItems: [ + { mediaItemId: "media-1", type: "image", url: "https://cdn.example.com/1.png", width: 100, height: 80 }, + { mediaItemId: "media-2", type: "image", url: "https://cdn.example.com/2.png", width: 100, height: 80 }, + ], + nodes: [ + { + id: "source-1", + position: { x: 1000, y: 1000 }, + size: { width: 200, height: 200 }, + }, + ], + viewport: { x: -200, y: -100, zoom: 1 }, + }); + + expect(centeredPlacements.map((placement) => placement.position)).toEqual([ + { x: 88, y: 60 }, + { x: 212, y: 60 }, + ]); + }); + test("normalizes slot-only canvas agent context without image nodes", () => { const context = normalizeCanvasAgentContext({ source: "canvas", @@ -98,6 +468,46 @@ test.describe("canvas chat pure helpers", () => { expect(context?.focusPolicy).toBe("slot"); }); + test("normalizes selected text-only canvas agent context", () => { + const context = normalizeCanvasAgentContext({ + source: "canvas", + projectId: "11111111-1111-4111-8111-111111111111", + selectedNodeIds: ["text-1"], + canvasImageNodes: [], + canvasAgentContext: { + version: 1, + source: "canvas", + projectId: "11111111-1111-4111-8111-111111111111", + selectedNodeIds: ["text-1"], + selectedNodes: [ + { + nodeId: "text-1", + type: "text", + title: "旧标题", + x: 40, + y: 80, + width: 240, + height: 80, + isSelected: true, + }, + ], + candidateNodes: [], + slots: [], + annotations: [], + viewport: { x: 0, y: 0, zoom: 1 }, + focusPolicy: "selected", + }, + }); + + expect(context?.selectedNodeIds).toEqual(["text-1"]); + expect(context?.selectedNodes[0]).toMatchObject({ + nodeId: "text-1", + type: "text", + title: "旧标题", + }); + expect(context?.focusPolicy).toBe("selected"); + }); + test("builds canvas agent context from legacy canvas image nodes", () => { const context = normalizeCanvasAgentContext({ source: "canvas", diff --git a/types/agent.ts b/types/agent.ts index 38cfa379b..947aa02a8 100644 --- a/types/agent.ts +++ b/types/agent.ts @@ -206,10 +206,18 @@ export interface AgentWorkflowBrandStoreEntry { sortOrder?: number; } +export type AgentWorkflowMaterialResourceType = "white-background" | "task-detail"; + export interface AgentWorkflowMaterialApiConfig { enabled: boolean; baseUrl: string; + endpointPath?: string; apiKey: string; + whiteBackgroundEnabled?: boolean; + taskDetailEnabled?: boolean; + resourceBaseUrl?: string; + fallbackResourceBaseUrl?: string; + requestTimeoutMs?: number; productDetailEnabled?: boolean; productDetailBaseUrl?: string; productDetailApiKey?: string; diff --git a/types/canvas/agent-protocol.ts b/types/canvas/agent-protocol.ts index 149dac628..0cc696406 100644 --- a/types/canvas/agent-protocol.ts +++ b/types/canvas/agent-protocol.ts @@ -121,8 +121,18 @@ export interface CanvasOperationPlan { baseCanvasRevision?: string; baseCanvasUpdatedAt?: string; sourceNodeIds?: string[]; + targetNodeIds?: string[]; targetSlotId?: string; anchorNodeId?: string; + edgeType?: "default" | "reference" | "conversation"; + textContent?: string; + confirmed?: boolean; + layoutStrategy?: "row" | "column" | "grid"; + spacing?: number; + targetPosition?: { + x: number; + y: number; + }; placement?: "right" | "left" | "below" | "above" | "viewport-center"; placementStrategy?: "single" | "row" | "grid" | "overlay" | "beside_each_source"; preserveOriginal?: boolean; diff --git a/types/canvas/index.ts b/types/canvas/index.ts index 5c1f172ca..46b6819f4 100644 --- a/types/canvas/index.ts +++ b/types/canvas/index.ts @@ -15,8 +15,10 @@ export type NodeType = | 'workflow' | 'scene-fusion' | 'model3d' - | 'world3d'; + | 'world3d' + | 'agent-slot'; export type NodeStatus = 'pending' | 'generating' | 'completed' | 'failed'; +export type AgentSlotStatus = 'empty' | 'generating' | 'completed' | 'failed'; export type ToolType = 'select' | 'pan' | 'draw' | 'text'; // 画布节点位置 @@ -137,6 +139,17 @@ export interface UiScreenNodeData { metadata?: Record; } +export interface AgentSlotNodeData { + slotType: "image" | "video" | "ui-screen"; + label?: string; + status: AgentSlotStatus; + acceptedSources?: Array<"text" | "image" | "selection" | "annotation">; + fillPolicy?: "replace-empty" | "append-version" | "replace-existing"; + filledNodeIds?: string[]; + aspectRatio?: string; + metadata?: Record; +} + // 工作流节点数据 export interface WorkflowNodeData { templateType: string; @@ -217,7 +230,8 @@ export interface CanvasNode { | WorkflowNodeData | SceneFusionNodeData | Model3DNodeData - | World3DNodeData; + | World3DNodeData + | AgentSlotNodeData; connections: string[]; // 连接的节点 ID parentId?: string; // 父节点 ID(用于对话上下文) createdAt: Date;