improve canvas generation placement

This commit is contained in:
134 2026-06-12 14:28:56 +08:00
parent 016d588559
commit 03aee55cb6
5 changed files with 323 additions and 74 deletions

View File

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

View File

@ -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<CanvasNode[]>([]);
useEffect(() => {
selectedImageNodesRef.current = selectedImageNodes;
}, [selectedImageNodes]);
const focusCanvasOnNode = useCallback(
(node: Pick<CanvasNode, "position" | "size">) => {
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<string, unknown>;
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<Array<{ url: string; sourceImageKey: string }>>(
(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,
]);

View File

@ -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<string, unknown>).flipRotateOriginalSrc || "").trim()
@ -7521,17 +7530,25 @@ export function ImageNode({
<div
className={cn(
"relative h-full w-full rounded-none border border-[#dbe2ee] bg-white shadow-[0_0_0_1px_rgba(219,226,238,0.34),0_16px_34px_rgba(22,24,29,0.08)] transition-all dark:border-zinc-800 dark:bg-zinc-950",
localEditMode ? "overflow-visible" : "overflow-hidden",
isSelected
? "border-[#5b76fe] ring-2 ring-[#5b76fe]/14 shadow-[0_0_0_1px_rgba(91,118,254,0.14),0_20px_38px_rgba(91,118,254,0.12)]"
: "hover:border-[#cfd9ea]"
)}
"relative h-full w-full rounded-none border border-[#dbe2ee] bg-white shadow-[0_0_0_1px_rgba(219,226,238,0.34),0_16px_34px_rgba(22,24,29,0.08)] transition-all dark:border-zinc-800 dark:bg-zinc-950",
localEditMode ? "overflow-visible" : "overflow-hidden",
isRecentlyGenerated &&
"border-[#7c9cff] shadow-[0_0_0_1px_rgba(124,156,255,0.42),0_0_0_8px_rgba(124,156,255,0.12),0_18px_42px_rgba(91,118,254,0.22)]",
isSelected
? "border-[#5b76fe] ring-2 ring-[#5b76fe]/14 shadow-[0_0_0_1px_rgba(91,118,254,0.14),0_20px_38px_rgba(91,118,254,0.12)]"
: "hover:border-[#cfd9ea]"
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
ref={containerRef}
>
<div className="relative h-full w-full" style={scaledContentStyle}>
>
{isRecentlyGenerated ? (
<div
className="pointer-events-none absolute -inset-2 z-[3] rounded-[inherit] border border-[#8fb0ff]/55"
style={{ animation: "canvas-generated-pulse 1.35s ease-out 0s 4" }}
/>
) : null}
<div className="relative h-full w-full" style={scaledContentStyle}>
{/* 图片内容 - 使用 object-contain 保持原始比例 */}
{data.src ? (
<div className="relative h-full w-full">

View File

@ -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}%` }),

View File

@ -1869,7 +1869,7 @@ function PureModelSelectorCompact({
</Button>
)}
</Trigger>
<PromptInputModelSelectContent className="!w-[344px] max-h-[426px] overflow-hidden rounded-[22px] border border-[#bfc6cf] bg-[#f2f3f5]/90 p-2 shadow-[0_10px_24px_rgba(15,23,42,0.12)]">
<PromptInputModelSelectContent className="!w-[min(430px,calc(100vw-24px))] max-h-[426px] overflow-hidden rounded-[22px] border border-[#bfc6cf] bg-[#f2f3f5]/90 p-2 shadow-[0_10px_24px_rgba(15,23,42,0.12)]">
<div className="max-h-[392px] space-y-1 overflow-y-auto pr-1">
{displayModels.map((model) => (
<SelectItem
@ -1884,8 +1884,8 @@ function PureModelSelectorCompact({
model.selectable === false && "opacity-55"
)}
>
<span className="flex w-full items-center justify-between gap-3">
<span className="flex min-w-0 flex-1 items-center gap-3">
<span className="flex w-full items-center justify-between gap-2">
<span className="flex min-w-0 flex-1 items-center gap-3 overflow-visible">
<span className="flex h-6 w-6 shrink-0 items-center justify-center text-[#2f3640]">
{model.avatarUrl ? (
<img
@ -1910,7 +1910,7 @@ function PureModelSelectorCompact({
</span>
)}
</span>
<span className="min-w-0 whitespace-nowrap text-[17px] font-medium leading-none tracking-[-0.01em]">
<span className="min-w-max whitespace-nowrap text-[17px] font-medium leading-none tracking-[-0.01em]">
{model.isSubModel ? `└ ${model.displayName}` : model.displayName}
</span>
</span>