feat(canvas): implement retry and delete actions for failed multi-image slots and maintain node scale during primary image selection

This commit is contained in:
HouYunFei
2026-08-06 12:09:30 +08:00
parent a10f16d983
commit ae327e9315
5 changed files with 84 additions and 28 deletions
+3
View File
@@ -4,6 +4,9 @@
+ [优化] 画布多图生成结果合并为单个图片节点,生成期间可展开查看每张图片的实时状态,优先展示最先成功的结果,并以紧凑堆叠展示已有图片。 + [优化] 画布多图生成结果合并为单个图片节点,生成期间可展开查看每张图片的实时状态,优先展示最先成功的结果,并以紧凑堆叠展示已有图片。
+ [优化] 从左侧元素列表定位画布节点时,自动缩放上限调整为 100%。 + [优化] 从左侧元素列表定位画布节点时,自动缩放上限调整为 100%。
+ [修复] 画布多图切换主图时保持节点当前视觉尺度和中心位置,避免节点突然放大造成视口缩放错觉。
+ [新增] 画布多图展开后支持单独重新生成或删除失败的图片槽位。
+ [修复] 画布多图失败槽位重试时正确传递图片标识,避免触发未定义变量错误。
## v0.14.0 - 2026-08-05 ## v0.14.0 - 2026-08-05
@@ -12,6 +12,8 @@ The current release needs manual verification in these areas:
- Canvas node resize stability, prompt scrolling, generated-prompt restoration, image editing, drag-and-drop references, and generation configuration. - Canvas node resize stability, prompt scrolling, generated-prompt restoration, image editing, drag-and-drop references, and generation configuration.
- Canvas side panel: clicking an element row should smoothly center and select its node without zooming above 100%; image elements with content should still open the large preview without triggering canvas focus. - Canvas side panel: clicking an element row should smoothly center and select its node without zooming above 100%; image elements with content should still open the large preview without triggering canvas focus.
- Multi-image canvas generation: starting N images should immediately create one image node with N expandable slots and one incoming connection; each empty slot should independently show generating or failure while requests are running, every slot with image content should always display that image, and the first successful image should appear as the primary image immediately. The collapsed state should use a compact stack with at most three closely spaced backplates and a clearly legible count control; clicking the control should hide the floating toolbar and expand the slots upward and to the right. Every completed secondary image should keep a clearly legible primary-image action visible in both themes, and clicking the empty canvas should collapse the group. - Multi-image canvas generation: starting N images should immediately create one image node with N expandable slots and one incoming connection; each empty slot should independently show generating or failure while requests are running, every slot with image content should always display that image, and the first successful image should appear as the primary image immediately. The collapsed state should use a compact stack with at most three closely spaced backplates and a clearly legible count control; clicking the control should hide the floating toolbar and expand the slots upward and to the right. Every completed secondary image should keep a clearly legible primary-image action visible in both themes, and clicking the empty canvas should collapse the group.
- Multi-image primary selection: choosing another primary image should preserve the node center and current maximum edge while adapting to the new image ratio; free-resize nodes should keep their exact dimensions, and the viewport zoom indicator must not change.
- Failed multi-image slots: every failed slot, including the current primary slot, should expose retry and delete actions. Retry should update only that slot without replacing an existing successful primary image; delete should remove only that failed slot and update the displayed count.
- Canvas Agent startup, MCP status, model and permission controls, approvals, version reporting, diagnostics, streaming responses, history consistency, multi-tab isolation, and local Skill management. - Canvas Agent startup, MCP status, model and permission controls, approvals, version reporting, diagnostics, streaming responses, history consistency, multi-tab isolation, and local Skill management.
- Agent header tabs in English and Chinese at different panel widths; labels should collapse to icons and counts before either edge is clipped. - Agent header tabs in English and Chinese at different panel widths; labels should collapse to icons and counts before either edge is clipped.
- Agent message layout, Markdown, code blocks, attachments, token statistics, progress timelines, command groups, and scroll-follow behavior. - Agent message layout, Markdown, code blocks, attachments, token statistics, progress timelines, command groups, and scroll-follow behavior.
@@ -49,6 +49,8 @@ description: 当前版本已实现但仍需人工验证的变更项
- 画布节点提示词回显:单图或多图生成完成后,切换选择生成结果的根节点及任一子节点,下方输入区域都应继续显示生成时的提示词;切换到其他节点再选回来后也不应清空。 - 画布节点提示词回显:单图或多图生成完成后,切换选择生成结果的根节点及任一子节点,下方输入区域都应继续显示生成时的提示词;切换到其他节点再选回来后也不应清空。
- 画布生成配置:连接文本节点后连续点击生成(包含失败重试),组装提示词不应重复追加上游文本;在生图、文本、视频、音频之间切换时,实际请求应始终使用当前面板展示的对应类型模型。 - 画布生成配置:连接文本节点后连续点击生成(包含失败重试),组装提示词不应重复追加上游文本;在生图、文本、视频、音频之间切换时,实际请求应始终使用当前面板展示的对应类型模型。
- 画布多图生成:开始生成 N 张图片后应立即创建一个包含 N 个可展开槽位的图片节点,并且来源到结果只保留一条连线;生成期间空槽位应独立显示生成中或失败,任何已有图片内容的槽位都必须始终显示图片,任意图片率先成功后应立即成为主图并展示,不等待其他请求完成;收起状态应最多显示三层紧密错开的背板,避免占用过多画布空间,右上角应显示醒目的「N 张」数量提示;点击数量后,浮动工具条应暂时隐藏,各图片槽位应从当前节点向右上方整齐展开;已成功图片的「设为主图」应常驻显示,并在亮色、深色主题及不同图片背景上保持清晰;点击画布空白处应自动收起并恢复工具条。 - 画布多图生成:开始生成 N 张图片后应立即创建一个包含 N 个可展开槽位的图片节点,并且来源到结果只保留一条连线;生成期间空槽位应独立显示生成中或失败,任何已有图片内容的槽位都必须始终显示图片,任意图片率先成功后应立即成为主图并展示,不等待其他请求完成;收起状态应最多显示三层紧密错开的背板,避免占用过多画布空间,右上角应显示醒目的「N 张」数量提示;点击数量后,浮动工具条应暂时隐藏,各图片槽位应从当前节点向右上方整齐展开;已成功图片的「设为主图」应常驻显示,并在亮色、深色主题及不同图片背景上保持清晰;点击画布空白处应自动收起并恢复工具条。
- 画布多图主图切换:点击「设为主图」后,节点中心和当前最长边应保持不变,仅按新图片比例调整另一条边;自由变形节点应完全保持现有宽高,画布视口及左下角缩放值不应变化。
- 画布多图失败槽位:包括当前主槽位在内的每个失败槽位都应显示「重试」和「删除」;重试只更新当前槽位,已有成功主图时不得抢占主图;删除只移除当前失败槽位并同步更新图片数量。
- 画布左侧元素列表:点击元素整行应平滑定位并选中对应节点,自动缩放不得超过 100%;有内容的图片元素应显示预览按钮,点击后打开大图弹窗且不触发画布定位。 - 画布左侧元素列表:点击元素整行应平滑定位并选中对应节点,自动缩放不得超过 100%;有内容的图片元素应显示预览按钮,点击后打开大图弹窗且不触发画布定位。
- 配置与用户偏好:导出 JSON 后应包含渠道、默认模型、生成偏好、提示词来源和 WebDAV 配置;在修改当前配置后重新导入该文件,应恢复导出时的设置,错误 JSON 文件应提示格式不正确。配置文件包含 API Key 和 WebDAV 凭据,不应公开分享。 - 配置与用户偏好:导出 JSON 后应包含渠道、默认模型、生成偏好、提示词来源和 WebDAV 配置;在修改当前配置后重新导入该文件,应恢复导出时的设置,错误 JSON 文件应提示格式不正确。配置文件包含 API Key 和 WebDAV 凭据,不应公开分享。
- 模型渠道协议:渠道编辑可选择「火山方舟」并自动填入方舟接口地址;任意名称的生图模型应按方舟 JSON 格式提交参考图,任意名称的视频模型应按方舟任务格式提交和查询,不再依赖模型名包含 `doubao`、`seedream` 或 `seedance`1080p 不应再因模型名包含 `fast` 被禁用,参考视频应允许最大 200MB、总像素 409600-8295044,并继续校验官方宽高、比例和时长限制。 - 模型渠道协议:渠道编辑可选择「火山方舟」并自动填入方舟接口地址;任意名称的生图模型应按方舟 JSON 格式提交参考图,任意名称的视频模型应按方舟任务格式提交和查询,不再依赖模型名包含 `doubao`、`seedream` 或 `seedance`1080p 不应再因模型名包含 `fast` 被禁用,参考视频应允许最大 200MB、总像素 409600-8295044,并继续校验官方宽高、比例和时长限制。
+35 -3
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { ChevronRight, Group, Image as ImageIcon, Music2, Puzzle, RefreshCw, Star, Video } from "lucide-react"; import { ChevronRight, Group, Image as ImageIcon, Music2, Puzzle, RefreshCw, Star, Trash2, Video } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
import { formatBytes } from "@/lib/image-utils"; import { formatBytes } from "@/lib/image-utils";
@@ -47,6 +47,8 @@ type CanvasNodeProps = {
onTitleChange: (nodeId: string, title: string) => void; onTitleChange: (nodeId: string, title: string) => void;
onToggleBatch?: (nodeId: string) => void; onToggleBatch?: (nodeId: string) => void;
onSetBatchPrimary?: (nodeId: string, imageId: string) => void; onSetBatchPrimary?: (nodeId: string, imageId: string) => void;
onRetryBatchImage?: (node: CanvasNodeData, imageId: string) => void;
onDeleteBatchImage?: (nodeId: string, imageId: string) => void;
onRetry?: (node: CanvasNodeData) => void; onRetry?: (node: CanvasNodeData) => void;
onGenerateImage?: (node: CanvasNodeData) => void; onGenerateImage?: (node: CanvasNodeData) => void;
onViewImage?: (node: CanvasNodeData) => void; onViewImage?: (node: CanvasNodeData) => void;
@@ -70,6 +72,8 @@ type NodeContentRendererProps = {
onGenerateImage?: (node: CanvasNodeData) => void; onGenerateImage?: (node: CanvasNodeData) => void;
onToggleBatch?: () => void; onToggleBatch?: () => void;
onSetBatchPrimary?: (imageId: string) => void; onSetBatchPrimary?: (imageId: string) => void;
onRetryBatchImage?: (imageId: string) => void;
onDeleteBatchImage?: (imageId: string) => void;
groupChildCount: number; groupChildCount: number;
}; };
@@ -103,6 +107,8 @@ export const CanvasNode = React.memo(function CanvasNode({
onTitleChange, onTitleChange,
onToggleBatch, onToggleBatch,
onSetBatchPrimary, onSetBatchPrimary,
onRetryBatchImage,
onDeleteBatchImage,
onRetry, onRetry,
onGenerateImage, onGenerateImage,
onViewImage, onViewImage,
@@ -399,6 +405,8 @@ export const CanvasNode = React.memo(function CanvasNode({
onGenerateImage={onGenerateImage} onGenerateImage={onGenerateImage}
onToggleBatch={() => onToggleBatch?.(data.id)} onToggleBatch={() => onToggleBatch?.(data.id)}
onSetBatchPrimary={(imageId) => onSetBatchPrimary?.(data.id, imageId)} onSetBatchPrimary={(imageId) => onSetBatchPrimary?.(data.id, imageId)}
onRetryBatchImage={(imageId) => onRetryBatchImage?.(data, imageId)}
onDeleteBatchImage={(imageId) => onDeleteBatchImage?.(data.id, imageId)}
groupChildCount={groupChildCount} groupChildCount={groupChildCount}
/> />
</div> </div>
@@ -571,6 +579,8 @@ function ImageNodeContent(props: NodeContentRendererProps) {
batchExpanded={props.batchExpanded} batchExpanded={props.batchExpanded}
onToggleBatch={props.onToggleBatch} onToggleBatch={props.onToggleBatch}
onSetBatchPrimary={props.onSetBatchPrimary} onSetBatchPrimary={props.onSetBatchPrimary}
onRetryBatchImage={props.onRetryBatchImage}
onDeleteBatchImage={props.onDeleteBatchImage}
/> />
); );
} }
@@ -624,11 +634,15 @@ function ImageContent({
batchExpanded, batchExpanded,
onToggleBatch, onToggleBatch,
onSetBatchPrimary, onSetBatchPrimary,
onRetryBatchImage,
onDeleteBatchImage,
}: { }: {
node: CanvasNodeData; node: CanvasNodeData;
batchExpanded: boolean; batchExpanded: boolean;
onToggleBatch?: () => void; onToggleBatch?: () => void;
onSetBatchPrimary?: (imageId: string) => void; onSetBatchPrimary?: (imageId: string) => void;
onRetryBatchImage?: (imageId: string) => void;
onDeleteBatchImage?: (imageId: string) => void;
}) { }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const { t } = useTranslation(); const { t } = useTranslation();
@@ -644,7 +658,7 @@ function ImageContent({
{batchExpanded {batchExpanded
? images ? images
.filter((image) => image.id !== primaryImageId) .filter((image) => image.id !== primaryImageId)
.map((image, index) => <ExpandedImageCard key={image.id} node={node} image={image} index={index} onSetPrimary={() => onSetBatchPrimary?.(image.id)} />) .map((image, index) => <ExpandedImageCard key={image.id} node={node} image={image} index={index} onSetPrimary={() => onSetBatchPrimary?.(image.id)} onRetry={() => onRetryBatchImage?.(image.id)} onDelete={() => onDeleteBatchImage?.(image.id)} />)
: null} : null}
<div className="h-full w-full overflow-hidden rounded-3xl"> <div className="h-full w-full overflow-hidden rounded-3xl">
{primaryContent ? ( {primaryContent ? (
@@ -659,6 +673,7 @@ function ImageContent({
<ImageSlotStatus image={primaryImage} /> <ImageSlotStatus image={primaryImage} />
)} )}
</div> </div>
{primaryImage?.status === "error" ? <BatchImageFailureActions placement="left" onRetry={() => onRetryBatchImage?.(primaryImage.id)} onDelete={() => onDeleteBatchImage?.(primaryImage.id)} /> : null}
{isBatchRoot ? ( {isBatchRoot ? (
<button <button
type="button" type="button"
@@ -680,7 +695,7 @@ function ImageContent({
); );
} }
function ExpandedImageCard({ node, image, index, onSetPrimary }: { node: CanvasNodeData; image: CanvasNodeImage; index: number; onSetPrimary: () => void }) { function ExpandedImageCard({ node, image, index, onSetPrimary, onRetry, onDelete }: { node: CanvasNodeData; image: CanvasNodeImage; index: number; onSetPrimary: () => void; onRetry: () => void; onDelete: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const { t } = useTranslation(); const { t } = useTranslation();
const count = node.metadata?.images?.length || 0; const count = node.metadata?.images?.length || 0;
@@ -728,6 +743,23 @@ function ExpandedImageCard({ node, image, index, onSetPrimary }: { node: CanvasN
{t("canvas.node.setPrimary")} {t("canvas.node.setPrimary")}
</button> </button>
) : null} ) : null}
{image.status === "error" ? <BatchImageFailureActions placement="right" onRetry={onRetry} onDelete={onDelete} /> : null}
</div>
);
}
function BatchImageFailureActions({ placement, onRetry, onDelete }: { placement: "left" | "right"; onRetry: () => void; onDelete: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const { t } = useTranslation();
return (
<div className={`absolute top-3 z-30 flex items-center gap-1.5 ${placement === "left" ? "left-3" : "right-3"}`}>
<button type="button" className="flex h-8 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium shadow-sm transition hover:scale-[1.02]" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }} onClick={(event) => (event.stopPropagation(), onRetry())}>
<RefreshCw className="size-3.5" />
{t("canvas.node.retry")}
</button>
<button type="button" className="grid size-8 place-items-center rounded-lg border shadow-sm transition hover:scale-[1.02]" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }} onClick={(event) => (event.stopPropagation(), onDelete())} aria-label={t("common.delete")} title={t("common.delete")}>
<Trash2 className="size-3.5" />
</button>
</div> </div>
); );
} }
+42 -25
View File
@@ -1461,7 +1461,8 @@ function InfiniteCanvasPage() {
if (node.id !== nodeId) return node; if (node.id !== nodeId) return node;
const image = node.metadata?.images?.find((item) => item.id === imageId); const image = node.metadata?.images?.find((item) => item.id === imageId);
if (!image?.content) return node; if (!image?.content) return node;
const size = fitNodeSize(image.naturalWidth, image.naturalHeight); const edge = Math.max(node.width, node.height);
const size = node.metadata?.freeResize ? { width: node.width, height: node.height } : fitNodeSize(image.naturalWidth, image.naturalHeight, edge, edge);
return { return {
...node, ...node,
position: { x: node.position.x + node.width / 2 - size.width / 2, y: node.position.y + node.height / 2 - size.height / 2 }, position: { x: node.position.x + node.width / 2 - size.width / 2, y: node.position.y + node.height / 2 - size.height / 2 },
@@ -2364,7 +2365,7 @@ function InfiniteCanvasPage() {
}, [handleGenerateNode]); }, [handleGenerateNode]);
const handleRetryNode = useCallback( const handleRetryNode = useCallback(
async (node: CanvasNodeData) => { async (node: CanvasNodeData, imageId?: string) => {
const sourceNode = findRetrySourceNode(node.id, nodesRef.current, connectionsRef.current) || node; const sourceNode = findRetrySourceNode(node.id, nodesRef.current, connectionsRef.current) || node;
const savedImageMetadata = node.type === CanvasNodeType.Image ? node.metadata : undefined; const savedImageMetadata = node.type === CanvasNodeType.Image ? node.metadata : undefined;
const hasSavedImageMetadata = Boolean(savedImageMetadata?.generationType); const hasSavedImageMetadata = Boolean(savedImageMetadata?.generationType);
@@ -2396,13 +2397,13 @@ function InfiniteCanvasPage() {
hasSavedImageMetadata && savedImageMetadata ? await resolveMetadataReferences(savedImageMetadata) : useReferenceImages ? (context?.referenceImages.length ? context.referenceImages : sourceNodeReferenceImages(sourceNode)) : []; hasSavedImageMetadata && savedImageMetadata ? await resolveMetadataReferences(savedImageMetadata) : useReferenceImages ? (context?.referenceImages.length ? context.referenceImages : sourceNodeReferenceImages(sourceNode)) : [];
if (useReferenceImages && !retryReferenceImages) { if (useReferenceImages && !retryReferenceImages) {
message.error(t("canvas.projectPage.referenceMissing")); message.error(t("canvas.projectPage.referenceMissing"));
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: t("canvas.projectPage.referenceMissing") } } : item))); setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: item.metadata?.content ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: item.metadata?.content ? undefined : t("canvas.projectPage.referenceMissing"), images: item.metadata?.images?.map((image) => (image.id === imageId ? { ...image, status: NODE_STATUS_ERROR, errorDetails: t("canvas.projectPage.referenceMissing") } : image)) } } : item)));
return; return;
} }
const retryImages = retryReferenceImages || []; const retryImages = retryReferenceImages || [];
setRunningNodeId(node.id); setRunningNodeId(node.id);
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_LOADING, errorDetails: undefined } } : item))); setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_LOADING, errorDetails: undefined, images: item.metadata?.images?.map((image) => (image.id === imageId ? { ...image, status: NODE_STATUS_LOADING, errorDetails: undefined } : image)) } } : item)));
const controller = startGenerationRequest(node.id, sourceNode.id, node.id); const controller = startGenerationRequest(node.id, sourceNode.id, node.id);
try { try {
@@ -2460,9 +2461,8 @@ function InfiniteCanvasPage() {
: await requestGeneration(generationConfig, prompt, { signal: controller.signal }).then((items) => items[0]); : await requestGeneration(generationConfig, prompt, { signal: controller.signal }).then((items) => items[0]);
const uploadedImage = await uploadImage(image.dataUrl); const uploadedImage = await uploadImage(image.dataUrl);
const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image]; const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
const imageSize = fitNodeSize(uploadedImage.width, uploadedImage.height, imageConfig.width, imageConfig.height);
const retryImage: CanvasNodeImage = { const retryImage: CanvasNodeImage = {
id: node.metadata?.primaryImageId || nanoid(), id: imageId || node.metadata?.primaryImageId || nanoid(),
status: NODE_STATUS_SUCCESS, status: NODE_STATUS_SUCCESS,
content: uploadedImage.url, content: uploadedImage.url,
storageKey: uploadedImage.storageKey, storageKey: uploadedImage.storageKey,
@@ -2483,30 +2483,31 @@ function InfiniteCanvasPage() {
} }
: buildImageGenerationMetadata(useReferenceImages ? "edit" : "generation", generationConfig, 1, retryImages); : buildImageGenerationMetadata(useReferenceImages ? "edit" : "generation", generationConfig, 1, retryImages);
setNodes((prev) => setNodes((prev) =>
prev.map((item) => prev.map((item) => {
item.id === node.id if (item.id !== node.id) return item;
? { const makePrimary = !imageId || !item.metadata?.content;
...item, const edge = imageId ? Math.max(item.width, item.height) : 0;
type: CanvasNodeType.Image, const imageSize = imageId && item.metadata?.freeResize ? { width: item.width, height: item.height } : imageId ? fitNodeSize(uploadedImage.width, uploadedImage.height, edge, edge) : fitNodeSize(uploadedImage.width, uploadedImage.height, imageConfig.width, imageConfig.height);
width: imageSize.width, return {
height: imageSize.height, ...item,
metadata: { type: CanvasNodeType.Image,
...item.metadata, ...(makePrimary ? { width: imageSize.width, height: imageSize.height, ...(imageId ? { position: { x: item.position.x + item.width / 2 - imageSize.width / 2, y: item.position.y + item.height / 2 - imageSize.height / 2 } } : {}) } : {}),
...imageMetadata(uploadedImage), metadata: {
images: item.metadata?.images?.map((current) => (current.id === retryImage.id ? retryImage : current)), ...item.metadata,
primaryImageId: retryImage.id, ...(makePrimary ? imageMetadata(uploadedImage) : { status: NODE_STATUS_SUCCESS }),
prompt, images: item.metadata?.images?.map((current) => (current.id === retryImage.id ? retryImage : current)),
...generationMetadata, primaryImageId: makePrimary ? retryImage.id : item.metadata?.primaryImageId,
}, prompt,
} ...generationMetadata,
: item, },
), };
}),
); );
} catch (error) { } catch (error) {
if (isGenerationCanceled(error)) return; if (isGenerationCanceled(error)) return;
const errorDetails = error instanceof Error ? error.message : t("canvas.projectPage.generationFailed"); const errorDetails = error instanceof Error ? error.message : t("canvas.projectPage.generationFailed");
message.error(errorDetails); message.error(errorDetails);
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails } } : item))); setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: item.metadata?.content ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: item.metadata?.content ? undefined : errorDetails, images: item.metadata?.images?.map((image) => (image.id === imageId ? { ...image, status: NODE_STATUS_ERROR, errorDetails } : image)) } } : item)));
} finally { } finally {
finishGenerationRequest(node.id, controller); finishGenerationRequest(node.id, controller);
setRunningNodeId(null); setRunningNodeId(null);
@@ -2515,6 +2516,20 @@ function InfiniteCanvasPage() {
[effectiveConfig, finishGenerationRequest, isAiConfigReady, message, openConfigDialog, startGenerationRequest, t], [effectiveConfig, finishGenerationRequest, isAiConfigReady, message, openConfigDialog, startGenerationRequest, t],
); );
const deleteBatchImage = useCallback((nodeId: string, imageId: string) => {
const node = nodesRef.current.find((item) => item.id === nodeId);
if ((node?.metadata?.images?.length || 0) <= 2) setExpandedImageNodeId(null);
setNodes((prev) =>
prev.map((item) => {
if (item.id !== nodeId) return item;
const images = item.metadata?.images?.filter((image) => image.id !== imageId) || [];
return { ...item, metadata: { ...item.metadata, images, count: images.length, primaryImageId: item.metadata?.primaryImageId === imageId ? images[0]?.id : item.metadata?.primaryImageId } };
}),
);
}, []);
const retryBatchImage = useCallback((node: CanvasNodeData, imageId: string) => void handleRetryNode(node, imageId), [handleRetryNode]);
const generateImageFromTextNode = useCallback( const generateImageFromTextNode = useCallback(
(node: CanvasNodeData) => { (node: CanvasNodeData) => {
const prompt = (node.metadata?.content || node.metadata?.prompt || "").trim(); const prompt = (node.metadata?.content || node.metadata?.prompt || "").trim();
@@ -2799,6 +2814,8 @@ function InfiniteCanvasPage() {
onTitleChange={handleNodeTitleChange} onTitleChange={handleNodeTitleChange}
onToggleBatch={toggleBatchExpanded} onToggleBatch={toggleBatchExpanded}
onSetBatchPrimary={setBatchPrimary} onSetBatchPrimary={setBatchPrimary}
onRetryBatchImage={retryBatchImage}
onDeleteBatchImage={deleteBatchImage}
onRetry={handleNodeRetry} onRetry={handleNodeRetry}
onGenerateImage={generateImageFromTextNode} onGenerateImage={generateImageFromTextNode}
onViewImage={handleNodeViewImage} onViewImage={handleNodeViewImage}