From 03aee55cb6011ac3527bc276d693f840f4650b0d Mon Sep 17 00:00:00 2001 From: 134 Date: Fri, 12 Jun 2026 14:28:56 +0800 Subject: [PATCH] improve canvas generation placement --- app/globals.css | 24 ++ components/canvas/CanvasChatPanel.tsx | 302 ++++++++++++++++++---- components/canvas/nodes/ImageNode.tsx | 33 ++- components/generation-dot-placeholder.tsx | 30 ++- components/multimodal-input.tsx | 8 +- 5 files changed, 323 insertions(+), 74 deletions(-) diff --git a/app/globals.css b/app/globals.css index 6c866e127..3b15af669 100644 --- a/app/globals.css +++ b/app/globals.css @@ -210,6 +210,30 @@ } @layer utilities { + @keyframes canvas-generated-pulse { + 0% { + opacity: 0.9; + transform: scale(0.985); + box-shadow: + 0 0 0 0 rgba(124, 156, 255, 0.3), + 0 0 26px rgba(124, 156, 255, 0.2); + } + 55% { + opacity: 0.35; + transform: scale(1.03); + box-shadow: + 0 0 0 12px rgba(124, 156, 255, 0.02), + 0 0 42px rgba(124, 156, 255, 0.1); + } + 100% { + opacity: 0; + transform: scale(1.055); + box-shadow: + 0 0 0 18px rgba(124, 156, 255, 0), + 0 0 52px rgba(124, 156, 255, 0); + } + } + .agent-dark-scrollbar { scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.18) rgba(255, 255, 255, 0.04); diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 0b5d9cf4b..a1f52100f 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -436,6 +436,72 @@ const CANVAS_AGENT_SECTION_TITLES = new Set([ ]); const CANVAS_IMAGE_TASK_POLL_INTERVAL_MS = 1600; const CANVAS_IMAGE_TASK_TIMEOUT_MS = 4 * 60 * 1000; +const CANVAS_GENERATION_NODE_GAP = 56; +const CANVAS_GENERATION_STACK_GAP = 28; + +function findCanvasImageAnchorNode( + nodes: CanvasNode[], + params: { + selectedNodes?: CanvasNode[]; + sourceImageUrls?: string[]; + } = {} +) { + const sourceKeys = new Set( + (params.sourceImageUrls || []) + .map((url) => normalizeImageIdentityUrl(url)) + .filter(Boolean) + ); + + if (sourceKeys.size > 0) { + const matchedNode = nodes.find((node) => { + if (node.type !== "image") return false; + const src = String((node.data as any)?.src || "").trim(); + return sourceKeys.has(normalizeImageIdentityUrl(src)); + }); + if (matchedNode) return matchedNode; + } + + return params.selectedNodes?.find((node) => node.type === "image") || null; +} + +function getAnchoredGenerationPosition(params: { + anchorNode?: CanvasNode | null; + size: { width: number; height: number }; + index?: number; + fallbackNodeCount?: number; + viewport?: { x: number; y: number; zoom: number }; +}) { + const index = params.index || 0; + const anchorNode = params.anchorNode || null; + + if (anchorNode) { + const anchorWidth = Number(anchorNode.size?.width || 300); + const anchorHeight = Number(anchorNode.size?.height || 300); + const stackStep = Math.max( + 96, + Math.min(params.size.height, anchorHeight) + CANVAS_GENERATION_STACK_GAP + ); + return { + x: anchorNode.position.x + anchorWidth + CANVAS_GENERATION_NODE_GAP, + y: anchorNode.position.y + index * stackStep, + }; + } + + const viewport = params.viewport || { x: 0, y: 0, zoom: 1 }; + const zoom = Math.max(0.1, viewport.zoom || 1); + if (typeof window !== "undefined") { + return { + x: (-viewport.x + window.innerWidth * 0.52) / zoom - params.size.width / 2 + index * 28, + y: (-viewport.y + window.innerHeight * 0.46) / zoom - params.size.height / 2 + index * 28, + }; + } + + const fallbackNodeCount = params.fallbackNodeCount || 0; + return { + x: 100 + (fallbackNodeCount % 3) * (params.size.width + 20), + y: 100 + Math.floor(fallbackNodeCount / 3) * (params.size.height + 20), + }; +} type CanvasImageGenerationRequest = { prompt: string; @@ -1372,6 +1438,8 @@ export function CanvasChatPanel({ const nodes = useCanvasStore((state) => state.nodes); const projectTitle = useCanvasStore((state) => state.projectTitle); const selectedNodeIds = useCanvasStore((state) => state.selectedNodeIds); + const selectNodes = useCanvasStore((state) => state.selectNodes); + const setViewport = useCanvasStore((state) => state.setViewport); const clearSelection = useCanvasStore((state) => state.clearSelection); const pendingChatAttachments = useCanvasStore((state) => state.pendingChatAttachments); const clearPendingChatAttachments = useCanvasStore((state) => state.clearPendingChatAttachments); @@ -1445,8 +1513,44 @@ export function CanvasChatPanel({ ); // 获取选中的图片节点 - const selectedImageNodes = nodes.filter( - n => selectedNodeIds.includes(n.id) && isCanvasImageReadyForAi(n) + const selectedImageNodes = useMemo( + () => + nodes.filter( + (node) => selectedNodeIds.includes(node.id) && isCanvasImageReadyForAi(node) + ), + [nodes, selectedNodeIds] + ); + const selectedImageNodesRef = useRef([]); + + useEffect(() => { + selectedImageNodesRef.current = selectedImageNodes; + }, [selectedImageNodes]); + + const focusCanvasOnNode = useCallback( + (node: Pick) => { + if (typeof window === "undefined") return; + const { viewport } = useCanvasStore.getState(); + const width = Number(node.size?.width || 300); + const height = Number(node.size?.height || 300); + const availableWidth = Math.max(360, window.innerWidth * 0.54); + const availableHeight = Math.max(320, window.innerHeight * 0.52); + const fitZoom = Math.min( + 1.16, + availableWidth / Math.max(width, 1), + availableHeight / Math.max(height, 1) + ); + const zoom = Math.max(0.48, Math.min(fitZoom, 4)); + const centerX = node.position.x + width / 2; + const centerY = node.position.y + height / 2; + window.requestAnimationFrame(() => { + setViewport({ + x: window.innerWidth * 0.52 - centerX * zoom, + y: window.innerHeight * 0.46 - centerY * zoom, + zoom, + }); + }); + }, + [setViewport] ); const handleAssistantVote = useCallback( @@ -3283,9 +3387,15 @@ export function CanvasChatPanel({ return; } - removeCanvasGenerationPlaceholders(); - const latestNodes = useCanvasStore.getState().nodes; + const placeholderNodes = latestNodes.filter((node) => { + if (node.type !== "image") return false; + const metadata = ((node.data as any)?.metadata || {}) as Record; + return ( + metadata.canvasGenerationPlaceholder === true && + (!metadata.chatId || String(metadata.chatId) === activeChatId) + ); + }); const suppressedMediaKeys = new Set( (useCanvasStore.getState().getCanvasState().suppressedMediaKeys || []) .map((item) => String(item || "").trim()) @@ -3332,6 +3442,8 @@ export function CanvasChatPanel({ String(payload.agentExecutionSnapshotId || "").trim() || undefined; let addedImageCount = 0; let addedVideoCount = 0; + const insertedNodeIds: string[] = []; + const completedAt = Date.now(); for (let index = 0; index < imageUrls.length; index += 1) { const url = imageUrls[index]!; @@ -3356,11 +3468,6 @@ export function CanvasChatPanel({ } const dimensions = await getImageDimensions(url); - const currentNodes = useCanvasStore.getState().nodes; - const existingNodes = currentNodes.length + addedImageCount + addedVideoCount; - const spacing = 20; - const x = 100 + (existingNodes % 3) * (dimensions.width + spacing); - const y = 100 + Math.floor(existingNodes / 3) * (dimensions.height + spacing); const compareSourceUrlRaw = sourceImageUrls[Math.min(index, Math.max(0, sourceImageUrls.length - 1))] || sourceImageUrls[0] || @@ -3380,36 +3487,79 @@ export function CanvasChatPanel({ })?.id || "" : ""; - const nodeId = addNode({ - type: "image", - position: { x, y }, - size: { width: dimensions.width, height: dimensions.height }, - data: { - src: url, - status: "completed", - title: "图片", - prompt: generationPrompt, - originalSrc: canUseCompareSource ? compareSourceUrl : undefined, - metadata: { - originalWidth: dimensions.originalWidth, - originalHeight: dimensions.originalHeight, - generationPrompt, - sourceMessageId: messageId || undefined, - sourceImageKey, - compareSourceUrl: canUseCompareSource ? compareSourceUrl : undefined, - agentPresetId, - agentPresetName, - agentCategoryId, - agentCategoryName, - agentExecutionSnapshotId, - }, - }, - connections: sourceNodeId ? [sourceNodeId] : [], + const currentNodes = useCanvasStore.getState().nodes; + const anchorNode = findCanvasImageAnchorNode(currentNodes, { + selectedNodes: selectedImageNodesRef.current, + sourceImageUrls, }); + const size = { width: dimensions.width, height: dimensions.height }; + const position = getAnchoredGenerationPosition({ + anchorNode, + fallbackNodeCount: currentNodes.length + addedImageCount + addedVideoCount, + index, + size, + viewport: useCanvasStore.getState().viewport, + }); + const data = { + src: url, + status: "completed" as const, + title: "图片", + prompt: generationPrompt, + originalSrc: canUseCompareSource ? compareSourceUrl : undefined, + metadata: { + originalWidth: dimensions.originalWidth, + originalHeight: dimensions.originalHeight, + generationPrompt, + sourceMessageId: messageId || undefined, + sourceImageKey, + compareSourceUrl: canUseCompareSource ? compareSourceUrl : undefined, + agentPresetId, + agentPresetName, + agentCategoryId, + agentCategoryName, + agentExecutionSnapshotId, + sourceNodeId: sourceNodeId || anchorNode?.id || undefined, + placementMode: "anchored-generation", + allowOverlap: true, + recentlyGeneratedAt: completedAt, + }, + }; + const placeholderNode = placeholderNodes[index]; + const nodeId = placeholderNode + ? placeholderNode.id + : addNode({ + type: "image", + position, + size, + data, + connections: sourceNodeId ? [sourceNodeId] : anchorNode?.id ? [anchorNode.id] : [], + }); + if (placeholderNode) { + updateNode(nodeId, { + position, + size, + data, + connections: sourceNodeId ? [sourceNodeId] : anchorNode?.id ? [anchorNode.id] : [], + }); + } + insertedNodeIds.push(nodeId); void autoNameGeneratedImageNode(nodeId, url); addedImageCount += 1; } + placeholderNodes.slice(imageUrls.length).forEach((node) => deleteNode(node.id)); + if (insertedNodeIds.length > 0) { + selectNodes(insertedNodeIds); + const focusNode = useCanvasStore + .getState() + .nodes.find((node) => node.id === insertedNodeIds[0]); + if (focusNode) { + focusCanvasOnNode(focusNode); + } + } else if (videoUrls.length > 0) { + removeCanvasGenerationPlaceholders(activeChatId); + } + for (let index = 0; index < videoUrls.length; index += 1) { const url = videoUrls[index]!; const key = normalizeMediaUrl(url); @@ -3446,7 +3596,16 @@ export function CanvasChatPanel({ } }, - [addNode, autoNameGeneratedImageNode, removeCanvasGenerationPlaceholders] + [ + activeChatId, + addNode, + autoNameGeneratedImageNode, + deleteNode, + focusCanvasOnNode, + removeCanvasGenerationPlaceholders, + selectNodes, + updateNode, + ] ); const { @@ -4451,6 +4610,8 @@ export function CanvasChatPanel({ let addedImageCount = 0; let addedVideoCount = 0; + const insertedNodeIds: string[] = []; + const completedAt = Date.now(); const newImageEntries = imageUrls.reduce>( (acc, url) => { @@ -4505,10 +4666,6 @@ export function CanvasChatPanel({ } const dimensions = await getImageDimensions(url); - const existingNodes = nodes.length + addedImageCount + addedVideoCount; - const spacing = 20; - const x = 100 + (existingNodes % 3) * (dimensions.width + spacing); - const y = 100 + Math.floor(existingNodes / 3) * (dimensions.height + spacing); const compareSourceUrlRaw = nearestUserImageUrls[Math.min(index, Math.max(0, nearestUserImageUrls.length - 1))] || nearestUserImageUrls[0] || @@ -4528,10 +4685,24 @@ export function CanvasChatPanel({ })?.id || "" : ""; + const currentNodes = useCanvasStore.getState().nodes; + const anchorNode = findCanvasImageAnchorNode(currentNodes, { + selectedNodes: selectedImageNodesRef.current, + sourceImageUrls: nearestUserImageUrls, + }); + const size = { width: dimensions.width, height: dimensions.height }; + const position = getAnchoredGenerationPosition({ + anchorNode, + fallbackNodeCount: currentNodes.length + addedImageCount + addedVideoCount, + index, + size, + viewport: useCanvasStore.getState().viewport, + }); + const nodeId = addNode({ type: "image", - position: { x, y }, - size: { width: dimensions.width, height: dimensions.height }, + position, + size, data: { src: url, status: "completed", @@ -4545,14 +4716,29 @@ export function CanvasChatPanel({ sourceMessageId: message.id, sourceImageKey: imageEntry.sourceImageKey, compareSourceUrl: canUseCompareSource ? compareSourceUrl : undefined, + sourceNodeId: sourceNodeId || anchorNode?.id || undefined, + placementMode: "anchored-generation", + allowOverlap: true, + recentlyGeneratedAt: completedAt, }, }, - connections: sourceNodeId ? [sourceNodeId] : [], + connections: sourceNodeId ? [sourceNodeId] : anchorNode?.id ? [anchorNode.id] : [], }); + insertedNodeIds.push(nodeId); void autoNameGeneratedImageNode(nodeId, url); addedImageCount += 1; } + if (insertedNodeIds.length > 0) { + selectNodes(insertedNodeIds); + const focusNode = useCanvasStore + .getState() + .nodes.find((node) => node.id === insertedNodeIds[0]); + if (focusNode) { + focusCanvasOnNode(focusNode); + } + } + const newVideoUrls = videoUrls.filter((url) => { const key = normalizeMediaUrl(url); if (!key || existingVideoUrlKeys.has(key)) return false; @@ -4588,7 +4774,14 @@ export function CanvasChatPanel({ return () => { cancelled = true; }; - }, [messages, addNode, nodes, autoNameGeneratedImageNode]); + }, [ + messages, + addNode, + nodes, + autoNameGeneratedImageNode, + focusCanvasOnNode, + selectNodes, + ]); useEffect(() => { if (nodes.length < 2) return; @@ -7935,6 +8128,8 @@ export function CanvasChatPanel({ phase === "queued" ? 12 : phase === "finalizing" ? 96 : phase === "failed" ? 100 : undefined, modelName: streamState.modelName || currentModel?.displayName || undefined, providerType: streamState.providerType || currentModel?.providerType || undefined, + placementMode: "anchored-generation", + allowOverlap: true, }; if (placeholderId) { @@ -7962,13 +8157,18 @@ export function CanvasChatPanel({ } const currentNodes = useCanvasStore.getState().nodes; - const existingNodes = currentNodes.length; + const anchorNode = findCanvasImageAnchorNode(currentNodes, { + selectedNodes: selectedImageNodesRef.current, + }); + const position = getAnchoredGenerationPosition({ + anchorNode, + fallbackNodeCount: currentNodes.length, + size: placeholderSize, + viewport: useCanvasStore.getState().viewport, + }); const nodeId = addNode({ type: "image", - position: { - x: 100 + (existingNodes % 3) * (placeholderSize.width + 20), - y: 100 + Math.floor(existingNodes / 3) * (placeholderSize.height + 20), - }, + position, size: placeholderSize, data: { src: "", @@ -7977,16 +8177,20 @@ export function CanvasChatPanel({ prompt: label, metadata, }, - connections: [], + connections: anchorNode?.id ? [anchorNode.id] : [], }); canvasGenerationPlaceholderByChatIdRef.current[activeChatId] = nodeId; + selectNodes([nodeId]); + focusCanvasOnNode({ position, size: placeholderSize }); }, [ activeChatId, addNode, currentModel?.displayName, currentModel?.providerType, imageParams, + focusCanvasOnNode, removeCanvasGenerationPlaceholders, + selectNodes, streamImageGenerationStateByChatId, updateNode, ]); diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 7de0d510d..5bfda39a7 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -1282,6 +1282,15 @@ export function ImageNode({ Number.isFinite(data.metadata.generationProgress) ? Math.max(0, Math.min(100, Math.round(data.metadata.generationProgress))) : null; + const recentlyGeneratedAt = + typeof data.metadata?.recentlyGeneratedAt === "number" && + Number.isFinite(data.metadata.recentlyGeneratedAt) + ? data.metadata.recentlyGeneratedAt + : 0; + const isRecentlyGenerated = + Boolean(data.src) && + recentlyGeneratedAt > 0 && + Date.now() - recentlyGeneratedAt < 20_000; const flipRotateOriginalSrc = data.metadata && typeof data.metadata === "object" ? String((data.metadata as Record).flipRotateOriginalSrc || "").trim() @@ -7521,17 +7530,25 @@ export function ImageNode({
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} ref={containerRef} - > -
+ > + {isRecentlyGenerated ? ( +
+ ) : null} +
{/* 图片内容 - 使用 object-contain 保持原始比例 */} {data.src ? (
diff --git a/components/generation-dot-placeholder.tsx b/components/generation-dot-placeholder.tsx index 61b6584fe..049fa067f 100644 --- a/components/generation-dot-placeholder.tsx +++ b/components/generation-dot-placeholder.tsx @@ -80,14 +80,15 @@ uniform float uTime; uniform float uOpacity; varying float vDistance; -void main() { - float dist = distance(gl_PointCoord, vec2(0.5)); - if (dist > 0.5) discard; - float strength = pow(1.0 - dist * 2.0, 1.55); - float shimmer = 0.72 + sin(vDistance * 0.33 + uTime) * 0.22; - gl_FragColor = vec4(vec3(0.12, 0.13, 0.14), strength * shimmer * uOpacity); -} -`; + void main() { + float dist = distance(gl_PointCoord, vec2(0.5)); + if (dist > 0.5) discard; + float edge = 1.0 - smoothstep(0.42, 0.5, dist); + float core = 0.92 + (1.0 - smoothstep(0.0, 0.34, dist)) * 0.08; + float shimmer = 0.78 + sin(vDistance * 0.33 + uTime) * 0.18; + gl_FragColor = vec4(vec3(0.11, 0.12, 0.13), edge * core * shimmer * uOpacity); + } + `; function clampProgress(value: number) { if (!Number.isFinite(value)) return 0; @@ -138,9 +139,12 @@ function ScatterParticleLayer({ antialias: true, powerPreference: "high-performance", }); - renderer.setClearColor(0xffffff, 0); - renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.75)); - container.appendChild(renderer.domElement); + renderer.setClearColor(0xffffff, 0); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2.5)); + renderer.domElement.style.display = "block"; + renderer.domElement.style.height = "100%"; + renderer.domElement.style.width = "100%"; + container.appendChild(renderer.domElement); const particleCount = compact ? 7200 : 12800; const positions = new Float32Array(particleCount * 3); @@ -172,7 +176,7 @@ function ScatterParticleLayer({ blending: THREE.NormalBlending, uniforms: { uTime: { value: 0 }, - uPointSize: { value: compact ? 88 : 108 }, + uPointSize: { value: compact ? 76 : 94 }, uEffectIntensity: { value: 1 }, uOpacity: { value: opacity }, }, @@ -256,7 +260,7 @@ export function GenerationDotPlaceholder({ ); const rotatingTitle = ROTATING_TITLES[Math.floor(elapsedMs / 3200) % ROTATING_TITLES.length]; const resolvedTitle = title || (phase === "processing" ? rotatingTitle : PHASE_TITLES[phase]); - const dotOpacity = phase === "failed" ? 0.18 : 0.42; + const dotOpacity = phase === "failed" ? 0.18 : 0.56; const progressStyle = useMemo( () => ({ width: `${resolvedProgress}%` }), diff --git a/components/multimodal-input.tsx b/components/multimodal-input.tsx index 50467e8c0..962ac86f0 100644 --- a/components/multimodal-input.tsx +++ b/components/multimodal-input.tsx @@ -1869,7 +1869,7 @@ function PureModelSelectorCompact({ )} - +
{displayModels.map((model) => ( - - + + {model.avatarUrl ? ( )} - + {model.isSubModel ? `└ ${model.displayName}` : model.displayName}