diff --git a/web/src/components/canvas/canvas-assistant-panel.tsx b/web/src/components/canvas/canvas-assistant-panel.tsx index c4aae40ad..9d7d11358 100644 --- a/web/src/components/canvas/canvas-assistant-panel.tsx +++ b/web/src/components/canvas/canvas-assistant-panel.tsx @@ -936,13 +936,25 @@ function AgentModelIcon({ model }: { model: string }) { } function resolveModelIcon(model: string) { + // 与 model-picker 保持同一套厂商图标规则。 const name = model.toLowerCase(); if (name.includes("claude") || name.includes("anthropic")) return "/icons/claude.svg"; - if (name.includes("gemini") || name.includes("google")) return "/icons/gemini.svg"; - if (name.includes("gpt") || name.includes("openai")) return "/icons/openai.svg"; + if ( + name.includes("gemini") || + name.includes("google") || + name.includes("nano banana") || + name.includes("nanobanana") || + name.includes("imagen") || + name.includes("veo") || + name.includes("omni flash") || + name.includes("omni-flash") + ) { + return "/icons/gemini.svg"; + } + if (name.includes("gpt") || name.includes("openai") || name.includes("dall-e") || name.includes("dalle")) return "/icons/openai.svg"; if (name.includes("grok")) return "/icons/grok.svg"; if (name.includes("deepseek")) return "/icons/deepseek.svg"; - if (name.includes("glm")) return "/icons/glm.svg"; + if (name.includes("glm") || name.includes("chatglm")) return "/icons/glm.svg"; return ""; } diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index 3695dfb10..f77d99055 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -136,13 +136,15 @@ function ModelPrice({ price, compact = false }: { price: { value: number; unit: function modelMenuMeta(model: string, capability?: ModelCapability): { description: string; time?: string } { const name = modelOptionName(model).toLowerCase(); if (capability === "image") { + if (name.includes("nano banana") || name.includes("nanobanana") || name.includes("imagen")) return { description: "Gemini 高质量图片生成,适合角色和商业成片" }; if (name.includes("nano") || name.includes("pro")) return { description: "高质量图片生成,适合角色和商业成片" }; if (name.includes("seedream")) return { description: "快速出图,适合批量探索风格" }; if (name.includes("gpt") || name.includes("image")) return { description: "通用图片模型,提示词理解稳定" }; return { description: "图片生成模型" }; } if (capability === "video") { - if (name.includes("seedance") || name.includes("veo") || name.includes("sora")) return { description: "镜头生成与图生视频,适合成片流程", time: "3m" }; + if (name.includes("veo") || name.includes("omni flash") || name.includes("omni-flash")) return { description: "Gemini 镜头生成与图生视频,适合成片流程", time: "3m" }; + if (name.includes("seedance") || name.includes("sora")) return { description: "镜头生成与图生视频,适合成片流程", time: "3m" }; return { description: "视频生成模型", time: "3m" }; } if (capability === "audio") return { description: "语音、音效或音乐生成", time: "20s" }; @@ -157,13 +159,25 @@ export function ModelIcon({ model }: { model: string }) { return icon ? : ; } -function resolveModelIcon(model: string) { +export function resolveModelIcon(model: string) { const name = model.toLowerCase(); if (name.includes("claude") || name.includes("anthropic")) return "/icons/claude.svg"; - if (name.includes("gemini") || name.includes("google")) return "/icons/gemini.svg"; - if (name.includes("gpt") || name.includes("openai")) return "/icons/openai.svg"; - if (name.includes("grok") || name.includes("grok")) return "/icons/grok.svg"; - if (name.includes("deepseek") || name.includes("deepseek")) return "/icons/deepseek.svg"; - if (name.includes("glm") || name.includes("glm")) return "/icons/glm.svg"; + // Flow2API 短名:Nano Banana / Imagen / Veo / Omni 均属 Google Gemini 系。 + if ( + name.includes("gemini") || + name.includes("google") || + name.includes("nano banana") || + name.includes("nanobanana") || + name.includes("imagen") || + name.includes("veo") || + name.includes("omni flash") || + name.includes("omni-flash") + ) { + return "/icons/gemini.svg"; + } + if (name.includes("gpt") || name.includes("openai") || name.includes("dall-e") || name.includes("dalle")) return "/icons/openai.svg"; + if (name.includes("grok")) return "/icons/grok.svg"; + if (name.includes("deepseek")) return "/icons/deepseek.svg"; + if (name.includes("glm") || name.includes("chatglm")) return "/icons/glm.svg"; return ""; } diff --git a/web/src/lib/canvas/canvas-node-size.ts b/web/src/lib/canvas/canvas-node-size.ts index b43bb8e7e..e67613c6d 100644 --- a/web/src/lib/canvas/canvas-node-size.ts +++ b/web/src/lib/canvas/canvas-node-size.ts @@ -6,10 +6,14 @@ export function fitNodeSize(width: number, height: number, maxWidth = 640, maxHe } export function nodeSizeFromRatio(size: string, baseWidth: number, baseHeight: number) { - const match = size?.match(/^(\d+)(?:x|:)(\d+)/); + const raw = String(size || "").trim(); + if (!raw || raw.toLowerCase() === "auto") return null; + // 支持 16:9 / 1024x576 / 16:9-2k + const match = raw.match(/^(\d+(?:\.\d+)?)(?:x|:)(\d+(?:\.\d+)?)/i); if (!match) return null; const width = Number(match[1]); const height = Number(match[2]); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null; const ratio = width / Math.max(1, height); if (ratio < 0.25 || ratio > 4) return { width: baseWidth, height: baseHeight }; return ratio >= baseWidth / baseHeight ? { width: baseWidth, height: baseWidth / ratio } : { width: baseHeight * ratio, height: baseHeight }; diff --git a/web/src/pages/canvas/canvas-image-generation-executor.ts b/web/src/pages/canvas/canvas-image-generation-executor.ts index 32e2e0c65..2a5e16466 100644 --- a/web/src/pages/canvas/canvas-image-generation-executor.ts +++ b/web/src/pages/canvas/canvas-image-generation-executor.ts @@ -2,7 +2,7 @@ import { nanoid } from "nanoid"; import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync"; -import { fitNodeSize } from "@/lib/canvas/canvas-node-size"; +import { fitNodeSize, nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size"; import { prepareInPlaceMediaVersion } from "@/lib/canvas/canvas-media-versions"; import { buildImageGenerationMetadata, getGenerationCount, isGenerationCanceled, runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-generation"; import { CONTENT_MODERATION_ERROR_CODE, generationFailureMetadata, type GenerationFailureMetadata } from "@/lib/generation-error"; @@ -45,12 +45,17 @@ export async function executeImageGeneration({ const generationType = referenceImages.length ? ("edit" as const) : ("generation" as const); const generationMetadata = buildImageGenerationMetadata(generationType, generationConfig, count, referenceImages); const parentConfig = NODE_DEFAULT_SIZE[isConfigNode ? CanvasNodeType.Config : isImageNode ? CanvasNodeType.Image : CanvasNodeType.Text]; - const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image]; + const imageDefaults = NODE_DEFAULT_SIZE[CanvasNodeType.Image]; + // 生成中占位框按设置比例显示,避免 16:9 任务显示成默认 340x240。 + const imageConfig = nodeSizeFromRatio(generationConfig.size || "auto", imageDefaults.width, imageDefaults.height) || imageDefaults; const parentPosition = sourceNode?.position || { x: 0, y: 0 }; const rootId = isImageNode ? nodeId : nanoid(); const childIds = count > 1 ? Array.from({ length: count }, () => nanoid()) : []; const targetIds = count > 1 ? childIds : [rootId]; registerPendingNodeIds(isEmptyImageNode ? childIds : [rootId, ...childIds]); + // 空节点可直接按目标比例改尺寸;已有内容的原位重生保留原尺寸,避免布局跳动。 + const rootWidth = isImageNode && !isEmptyImageNode ? sourceNode?.width || imageConfig.width : imageConfig.width; + const rootHeight = isImageNode && !isEmptyImageNode ? sourceNode?.height || imageConfig.height : imageConfig.height; const rootNode: CanvasNodeData = { id: rootId, @@ -60,13 +65,14 @@ export async function executeImageGeneration({ ? parentPosition : { x: parentPosition.x + parentConfig.width + 96, - y: parentPosition.y + parentConfig.height / 2 - imageConfig.height / 2, + y: parentPosition.y + parentConfig.height / 2 - rootHeight / 2, }, - width: isImageNode ? sourceNode?.width || imageConfig.width : imageConfig.width, - height: isImageNode ? sourceNode?.height || imageConfig.height : imageConfig.height, + width: rootWidth, + height: rootHeight, metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, + size: generationConfig.size, isBatchRoot: count > 1, batchChildIds: count > 1 ? childIds : undefined, batchUsesReferenceImages: referenceImages.length > 0, @@ -87,7 +93,7 @@ export async function executeImageGeneration({ }, width: imageConfig.width, height: imageConfig.height, - metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, batchRootId: count > 1 ? rootId : undefined, ...generationMetadata, generationErrorCode: undefined, failedPromptFingerprint: undefined }, + metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, size: generationConfig.size, batchRootId: count > 1 ? rootId : undefined, ...generationMetadata, generationErrorCode: undefined, failedPromptFingerprint: undefined }, })); const batchConnections = [...(isImageNode ? [] : [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]), ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId }))]; diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 91391872c..8afd9f4b8 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -977,8 +977,15 @@ function InfiniteCanvasPage() { const pasteAtPosition = useCallback( (position: Position) => { - if (pasteCopiedNodes(position)) return; - void pasteSystemClipboard(position).catch(() => message.warning("无法读取剪贴板内容")); + void (async () => { + try { + // 右键粘贴同样优先系统剪贴板图片,再回退内部节点。 + const handled = await pasteSystemClipboard(position); + if (!handled) pasteCopiedNodes(position); + } catch { + if (!pasteCopiedNodes(position)) message.warning("无法读取剪贴板内容"); + } + })(); }, [message, pasteCopiedNodes, pasteSystemClipboard], ); diff --git a/web/src/pages/canvas/use-canvas-keyboard.ts b/web/src/pages/canvas/use-canvas-keyboard.ts index 4ecc4e738..0133f9b18 100644 --- a/web/src/pages/canvas/use-canvas-keyboard.ts +++ b/web/src/pages/canvas/use-canvas-keyboard.ts @@ -23,7 +23,7 @@ type UseCanvasKeyboardOptions = { cancelSelectionBox: () => void; copySelectedNodes: () => void; pasteCopiedNodes: () => boolean; - pasteSystemClipboard: () => unknown; + pasteSystemClipboard: (position?: undefined, clipboardEvent?: ClipboardEvent | null) => Promise | boolean; deleteNodes: (ids: Set) => void; deleteConnection: (connectionId: string) => void; deselectCanvas: () => void; @@ -105,8 +105,8 @@ export function useCanvasKeyboard({ return; } if (isModifierShortcut && !event.altKey && key === "v") { - event.preventDefault(); - if (!pasteCopiedNodes()) void pasteSystemClipboard(); + // 不在 keydown 直接粘贴节点:交给 paste 事件,优先系统剪贴板图片。 + // 这里只做标记,真正逻辑在 paste 监听器里。 return; } if (event.key === "Delete" || event.key === "Backspace") { @@ -122,7 +122,22 @@ export function useCanvasKeyboard({ } }; + const handlePaste = (event: ClipboardEvent) => { + const target = event.target instanceof Element ? event.target : null; + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement || target?.closest("[contenteditable='true'],[data-canvas-no-zoom]")) return; + // 系统剪贴板有图片时优先粘贴图片;否则再粘贴画布内部复制的节点。 + event.preventDefault(); + void (async () => { + const handled = await pasteSystemClipboard(undefined, event); + if (!handled) pasteCopiedNodes(); + })(); + }; + window.addEventListener("keydown", handleKeyDown, true); - return () => window.removeEventListener("keydown", handleKeyDown, true); + window.addEventListener("paste", handlePaste, true); + return () => { + window.removeEventListener("keydown", handleKeyDown, true); + window.removeEventListener("paste", handlePaste, true); + }; }, [cancelSelectionBox, copySelectedNodes, deleteConnection, deleteNodes, deselectCanvas, fitCanvasContent, fitCanvasSelection, nodesRef, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, saveCanvasProject, selectedConnectionId, selectedNodeIdsRef, setAnnotationNodeId, setContextMenu, setCropNodeId, setInfoNodeId, setMaskEditNodeId, setSelectedConnectionId, setSelectedNodeIds, setShortcutRequestNonce, undoCanvas, zoomToActualSize]); } diff --git a/web/src/pages/canvas/use-canvas-node-operations.ts b/web/src/pages/canvas/use-canvas-node-operations.ts index e4141cf48..f052a0bcc 100644 --- a/web/src/pages/canvas/use-canvas-node-operations.ts +++ b/web/src/pages/canvas/use-canvas-node-operations.ts @@ -272,6 +272,12 @@ export function useCanvasNodeOperations({ delete metadata.taskCreatedAt; delete metadata.taskUpdatedAt; delete metadata.errorDetails; + delete metadata.batchRootId; + delete metadata.batchChildIds; + delete metadata.isBatchRoot; + delete metadata.primaryImageId; + delete metadata.imageBatchExpanded; + delete metadata.batchUsesReferenceImages; metadata.status = metadata.content ? NODE_STATUS_SUCCESS : NODE_STATUS_IDLE; metadata.versionOfNodeId = versionRootId; metadata.versionLabel = versionLabel; @@ -332,6 +338,17 @@ export function useCanvasNodeOperations({ connections: connectionsRef.current.filter((connection) => copyIds.has(connection.fromNodeId) && copyIds.has(connection.toNodeId)).map((connection) => ({ ...connection })), }; setHasCopiedNodes(true); + // 写入系统剪贴板标记,覆盖旧图片,避免 Ctrl+V 仍优先粘贴系统图片。 + const marker = `open-ai-canvas-nodes:${Date.now()}:${copiedNodes.length}`; + try { + if (typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) { + void navigator.clipboard.write([new ClipboardItem({ "text/plain": new Blob([marker], { type: "text/plain" }) })]); + } else if (navigator.clipboard?.writeText) { + void navigator.clipboard.writeText(marker); + } + } catch { + // 忽略剪贴板权限失败,内部剪贴板仍可用。 + } }, [connectionsRef, nodesRef]); const copySelectedNodes = useCallback(() => { @@ -351,21 +368,61 @@ export function useCanvasNodeOperations({ const dx = center.x - (bounds.left + bounds.right) / 2; const dy = center.y - (bounds.top + bounds.bottom) / 2; const idMap = new Map(clipboard.nodes.map((node, index) => [node.id, `${node.type}-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 7)}`])); - const nextNodes = clipboard.nodes.map((node) => ({ - ...node, - id: idMap.get(node.id)!, - title: node.title.endsWith(" Copy") ? node.title : `${node.title} Copy`, - position: { x: node.position.x + dx, y: node.position.y + dy }, - parentId: node.parentId ? idMap.get(node.parentId) : undefined, - metadata: node.type === CanvasNodeType.Drawing - ? { ...node.metadata, frame: node.metadata?.frame ? { ...node.metadata.frame } : undefined, drawingId: `${idMap.get(node.id)}-document`, drawingRevision: 0, drawingUpdatedAt: undefined, drawingShapeCount: 0, drawingPageCount: 1 } - : node.metadata ? { ...node.metadata, frame: node.metadata.frame ? { ...node.metadata.frame } : undefined } : undefined, - })); + const copiedSourceIds = new Set(clipboard.nodes.map((node) => node.id)); + const nextNodes = clipboard.nodes.map((node) => { + const metadata = node.metadata ? { ...node.metadata, frame: node.metadata.frame ? { ...node.metadata.frame } : undefined } : undefined; + if (metadata) { + // 粘贴必须切断与源批次/任务的绑定,否则拖拽会通过 batchChildIds 带动旧结果。 + delete metadata.taskId; + delete metadata.taskStatus; + delete metadata.taskProgress; + delete metadata.taskStage; + delete metadata.taskCreatedAt; + delete metadata.taskUpdatedAt; + delete metadata.errorDetails; + delete metadata.generationErrorCode; + delete metadata.failedPromptFingerprint; + delete metadata.batchRootId; + delete metadata.batchChildIds; + delete metadata.isBatchRoot; + delete metadata.primaryImageId; + delete metadata.imageBatchExpanded; + delete metadata.batchUsesReferenceImages; + delete metadata.versionOfNodeId; + delete metadata.versionLabel; + delete metadata.versionPrimary; + metadata.status = metadata.content ? NODE_STATUS_SUCCESS : NODE_STATUS_IDLE; + } + if (node.type === CanvasNodeType.Drawing && metadata) { + metadata.drawingId = `${idMap.get(node.id)}-document`; + metadata.drawingRevision = 0; + metadata.drawingUpdatedAt = undefined; + metadata.drawingShapeCount = 0; + metadata.drawingPageCount = 1; + } + return { + ...node, + id: idMap.get(node.id)!, + title: node.title.endsWith(" Copy") ? node.title : `${node.title} Copy`, + position: { x: node.position.x + dx, y: node.position.y + dy }, + parentId: node.parentId ? idMap.get(node.parentId) : undefined, + metadata, + }; + }); + // 1) 剪贴板内部连线;2) 仍保留到画布上未复制参考节点的入边(只复制结果节点时常见)。 const nextConnections = clipboard.connections.flatMap((connection, index) => { const fromNodeId = idMap.get(connection.fromNodeId); const toNodeId = idMap.get(connection.toNodeId); return fromNodeId && toNodeId ? [{ ...connection, id: `conn-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 7)}`, fromNodeId, toNodeId }] : []; }); + connectionsRef.current.forEach((connection) => { + if (!copiedSourceIds.has(connection.toNodeId) || copiedSourceIds.has(connection.fromNodeId)) return; + const toNodeId = idMap.get(connection.toNodeId); + if (!toNodeId) return; + // 参考节点仍在画布上时,粘贴结果应重新挂上同一入边。 + if (!nodesRef.current.some((node) => node.id === connection.fromNodeId)) return; + nextConnections.push({ ...connection, id: nanoid(), toNodeId }); + }); commitNodes([...nodesRef.current, ...nextNodes]); commitConnections([...connectionsRef.current, ...nextConnections]); const sourceByTargetId = new Map(clipboard.nodes.map((sourceNode) => [idMap.get(sourceNode.id), sourceNode])); diff --git a/web/src/pages/canvas/use-canvas-upload.ts b/web/src/pages/canvas/use-canvas-upload.ts index 13e9a1816..80d0a4904 100644 --- a/web/src/pages/canvas/use-canvas-upload.ts +++ b/web/src/pages/canvas/use-canvas-upload.ts @@ -367,28 +367,65 @@ export function useCanvasUpload({ } }, [message, nodesRef, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]); - const pasteSystemClipboard = useCallback(async (position?: Position) => { - if (!navigator.clipboard) return; - if (navigator.clipboard.read) { - const items = await navigator.clipboard.read(); - const imageItem = items.find((item) => item.types.some((type) => type.startsWith("image/"))); - if (imageItem) { - const imageType = imageItem.types.find((type) => type.startsWith("image/")); - if (!imageType) return; - const blob = await imageItem.getType(imageType); - const file = new File([blob], "clipboard-image.png", { type: imageType }); - const selected = nodesRef.current.filter((node) => selectedNodeIdsRef.current.has(node.id)); - if (selected.length === 1 && selected[0].type === CanvasNodeType.Image) { - if (await replaceNodeMedia(selected[0].id, file)) message.success("已用剪切板图片替换,可撤销恢复"); - return; + const pasteSystemClipboard = useCallback(async (position?: Position, clipboardEvent?: ClipboardEvent | null) => { + const isNodeMarker = (value: string) => value.trim().startsWith("open-ai-canvas-nodes:"); + const pasteImageFile = async (file: File) => { + const selected = nodesRef.current.filter((node) => selectedNodeIdsRef.current.has(node.id)); + if (selected.length === 1 && selected[0].type === CanvasNodeType.Image) { + if (await replaceNodeMedia(selected[0].id, file)) message.success("已用剪切板图片替换,可撤销恢复"); + return true; + } + const inserted = await createImageFileNode(file, position || getCanvasCenter()); + if (inserted) message.success("已从剪切板添加图片"); + return Boolean(inserted); + }; + + // 1) paste 事件里的图片文件(截图/资源管理器复制)优先。 + const filesFromEvent = clipboardEvent + ? Array.from(clipboardEvent.clipboardData?.items || []) + .filter((item) => item.kind === "file" && item.type.startsWith("image/")) + .map((item) => item.getAsFile()) + .filter((file): file is File => Boolean(file)) + : []; + if (filesFromEvent.length) return pasteImageFile(filesFromEvent[0]); + + // 2) 若系统文本是节点标记,说明最近一次复制是画布节点,不要再读旧图片。 + const eventText = clipboardEvent?.clipboardData?.getData("text/plain") || ""; + if (isNodeMarker(eventText)) return false; + + // 3) 异步读系统剪贴板:有图片才导入;若文本是节点标记则让位给节点粘贴。 + if (navigator.clipboard?.read) { + try { + const items = await navigator.clipboard.read(); + const textItem = items.find((item) => item.types.includes("text/plain")); + if (textItem) { + const textBlob = await textItem.getType("text/plain"); + const text = await textBlob.text(); + if (isNodeMarker(text)) return false; } - const inserted = await createImageFileNode(file, position || getCanvasCenter()); - if (inserted) message.success("已从剪切板添加图片"); - return; + const imageItem = items.find((item) => item.types.some((type) => type.startsWith("image/"))); + if (imageItem) { + const imageType = imageItem.types.find((type) => type.startsWith("image/")); + if (!imageType) return false; + const blob = await imageItem.getType(imageType); + return pasteImageFile(new File([blob], "clipboard-image.png", { type: imageType })); + } + } catch { + // 无权限时继续文本分支。 } } - const text = await navigator.clipboard.readText(); - if (createTextNodeFromClipboard(text, position)) message.success("已从剪切板添加文本"); + + try { + const text = eventText || (navigator.clipboard?.readText ? await navigator.clipboard.readText() : ""); + if (isNodeMarker(text)) return false; + if (createTextNodeFromClipboard(text, position)) { + message.success("已从剪切板添加文本"); + return true; + } + } catch { + // ignore + } + return false; }, [createImageFileNode, createTextNodeFromClipboard, getCanvasCenter, message, nodesRef, replaceNodeMedia, selectedNodeIdsRef]); const handleImageInputChange = useCallback(async (event: ChangeEvent) => {