diff --git a/docs/multi-image-generation-implementation.md b/docs/multi-image-generation-implementation.md new file mode 100644 index 00000000..e8b7f577 --- /dev/null +++ b/docs/multi-image-generation-implementation.md @@ -0,0 +1,199 @@ +# 多张图片生成技术实现记录 + +## 背景 + +本功能在 `TEST-s` 分支中为底部生成对话框增加类似 libTV 的图片张数选择能力。用户可以在图片生成模式下选择 `1张`、`2张` 或 `4张`,一次生成得到多张候选图。 + +产品语义采用“单节点多候选图”:一次多图生成只创建或更新一个 `nanoBanana` 图片节点,多张结果进入该节点的轮播历史,当前选中的图片作为节点输出。 + +核心文件: + +- `src/components/composer/GenerationComposer.tsx` +- `src/store/execution/nanoBananaExecutor.ts` +- `src/types/nodes.ts` +- `src/store/utils/nodeDefaults.ts` +- `src/components/__tests__/GenerationComposer.test.tsx` +- `src/store/execution/__tests__/nanoBananaExecutor.test.ts` +- `src/store/utils/__tests__/nodeDefaults.test.ts` + +## 当前功能范围 + +已实现: + +- 底部生成对话框在图片模式下显示张数下拉。 +- 张数选项固定为 `1张`、`2张`、`4张`。 +- 新建图片节点时写入 `imageCount`。 +- 编辑已有图片节点时可以修改 `imageCount`。 +- `nanoBanana` 执行器按 `imageCount` 顺序请求现有 `/api/generate`。 +- 多张结果写入同一个节点的 `imageHistory`。 +- 本次生成的第一张图写入 `outputImage`,并作为当前选中输出。 +- 每张成功结果都会进入全局图片历史。 +- 每张成功结果都会追加到下游 `outputGallery`。 +- 自动保存路径启用时,每张成功结果都会独立保存。 +- 如果批次中前几张成功、后续失败,保留已成功候选图并把错误记录在节点数据中。 + +未实现: + +- 不使用 provider 原生 `n` 参数。 +- 不自动创建多个图片节点。 +- 不把当前切换到的候选图自动作为下一次生成的参考图。 +- 不提供“展开为多个图片节点”动作。 +- 不提供“以当前图继续生成”动作。 + +## 状态模型 + +图片生成张数由 `NanoBananaNodeData.imageCount` 表示: + +```ts +export type ImageGenerationCount = 1 | 2 | 4; + +export interface NanoBananaNodeData extends BaseNodeData { + imageCount?: ImageGenerationCount; +} +``` + +字段为可选字段,旧工作流没有该字段时按 `1` 处理。 + +新建 `nanoBanana` 节点时默认写入: + +```ts +imageCount: 1 +``` + +底部对话框内部草稿 `ComposerDraft` 同步维护同名字段,避免用户在提交前切换节点或刷新草稿时丢失选择。 + +## 底部对话框 + +`GenerationComposer` 在图片能力模式下显示三个控制: + +```text +模型 / 画幅比例 / 清晰度 / 张数 +``` + +张数下拉定义为: + +```ts +const IMAGE_COUNTS: ImageGenerationCount[] = [1, 2, 4]; +``` + +提交新节点时,`buildInitialDataForNode("nanoBanana", draft)` 会把 `draft.imageCount` 写入节点数据。 + +编辑已有图片节点时,`imageComposerAdapter.readDraft()` 从节点读取 `imageCount`,`buildPatch()` 在字段变脏时回写 `imageCount`。 + +## 运行逻辑 + +`executeNanoBanana()` 不改变 provider 请求协议。执行器会根据节点的 `imageCount` 做顺序循环: + +```text +imageCount = 1 -> 请求 1 次 +imageCount = 2 -> 请求 2 次 +imageCount = 4 -> 请求 4 次 +``` + +每次请求仍然使用现有 `/api/generate` payload,包括: + +- `images` +- `prompt` +- `aspectRatio` +- `resolution` +- `selectedModel` +- `parameters` +- `dynamicInputs` + +这样可以兼容当前 PopiArt Nano Pro / NewApiWG / Gemini native 链路,因为该链路目前稳定返回单张图片。 + +## 结果写入 + +每张成功结果会生成一个历史项: + +```ts +{ + id, + image, + timestamp, + prompt, + aspectRatio, + model, + modelDisplayName, + modelProvider, +} +``` + +本次批量结果按生成顺序插入到 `imageHistory` 前面: + +```text +[本次第1张, 本次第2张, 本次第3张, 本次第4张, ...旧历史] +``` + +节点输出规则: + +- `outputImage` 使用本次第 1 张。 +- `selectedHistoryIndex` 设为 `0`。 +- 节点内已有轮播控件负责切换候选图。 +- 切换候选图时,下游读取的是当前 `outputImage`。 + +## 输出画廊 + +如果图片生成节点连接到 `outputGallery`,本次成功的每一张候选图都会调用: + +```ts +appendOutputGalleryImage(target.id, image) +``` + +因此画廊可以直接看到整批候选图,而普通下游节点仍然只读取当前选中的 `outputImage`。 + +## 失败处理 + +执行器的失败策略: + +- 第一张就失败:节点进入 `error`,如果有 fallback model,则走现有 fallback 流程。 +- 前几张成功、后续失败:节点进入 `complete`,保留成功候选图,并把错误写入 `error`。 +- 用户取消:继续沿用 `AbortError` 逻辑,不提交未完成批次。 + +第一版没有做并发生成,顺序生成更容易保证取消、fallback、历史顺序和保存逻辑一致。 + +## 参考图语义 + +多张候选图不会改变参考图来源。重新生成仍然使用: + +- 上游连接图片 +- 节点已有 `inputImages` +- 当前 prompt +- 当前参数 +- 当前 `imageCount` + +用户切换到第 2/4 张候选图,不会自动把这张图变成下一次生成的参考图。后续如需迭代能力,应单独设计显式动作,例如“以当前图继续生成”。 + +## 测试覆盖 + +当前相关测试: + +```text +src/components/__tests__/GenerationComposer.test.tsx +src/store/execution/__tests__/nanoBananaExecutor.test.ts +src/store/utils/__tests__/nodeDefaults.test.ts +``` + +覆盖点: + +- 新建图片节点会保存用户选择的 `imageCount`。 +- 默认 `nanoBanana` 节点数据包含 `imageCount: 1`。 +- `imageCount=4` 时执行器请求 4 次。 +- 多张结果写入同一个节点历史。 +- 多张结果中的第一张成为 `outputImage`。 +- 批次中后续失败时保留已成功候选图。 +- 下游 `outputGallery` 会收到每张候选图。 + +建议验证命令: + +```bash +npm run test:run -- src/components/__tests__/GenerationComposer.test.tsx src/store/execution/__tests__/nanoBananaExecutor.test.ts src/store/utils/__tests__/nodeDefaults.test.ts +``` + +## 维护注意点 + +- 不要把 `imageCount` 扩展为任意数字,当前产品选项只允许 `1 | 2 | 4`。 +- 如果未来接入 provider 原生多图参数,需要保留当前循环实现作为兼容 fallback。 +- 如果未来新增“展开为多个图片节点”,应作为显式用户动作,不应改变默认生成行为。 +- 如果未来新增“以当前图继续生成”,应明确写入 `inputImages`,不要让普通轮播切换隐式改变参考图。 +- 自动保存和输出画廊需要继续按“每张成功图片一次写入”维护。 diff --git a/src/components/__tests__/GenerationComposer.test.tsx b/src/components/__tests__/GenerationComposer.test.tsx index a2d01053..e3f38bc1 100644 --- a/src/components/__tests__/GenerationComposer.test.tsx +++ b/src/components/__tests__/GenerationComposer.test.tsx @@ -489,6 +489,26 @@ describe("GenerationComposer", () => { expect((nodes[0].data as NanoBananaNodeData).inputImages[0]).toMatch(/^data:image\/png;base64,/); }); + it("stores the selected image count on a new root image generation node", async () => { + render(); + + fireEvent.change(screen.getByLabelText("生成张数"), { + target: { value: "4" }, + }); + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "new root image candidates" }, + }); + fireEvent.click(screen.getByLabelText("生成")); + + await waitFor(() => { + expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalled(); + }); + const nodes = useWorkflowStore.getState().nodes; + expect(nodes).toHaveLength(1); + expect(nodes[0].type).toBe("nanoBanana"); + expect((nodes[0].data as NanoBananaNodeData).imageCount).toBe(4); + }); + it("renders uploaded reference images in order instead of stacking them", async () => { render(); diff --git a/src/components/composer/GenerationComposer.tsx b/src/components/composer/GenerationComposer.tsx index 41c31ffc..34957aee 100644 --- a/src/components/composer/GenerationComposer.tsx +++ b/src/components/composer/GenerationComposer.tsx @@ -19,6 +19,7 @@ import { GenerateAudioNodeData, GenerateVideoNodeData, ImageInputNodeData, + ImageGenerationCount, ModelType, NanoBananaNodeData, NodeType, @@ -41,7 +42,7 @@ import { DEFAULT_NEWAPIWG_LLM_MODEL_ID } from "@/lib/llmModels"; type ComposerMode = "empty-create" | "node-edit" | "process-node" | "unsupported-node" | "multi-select"; type ComposerCapability = "all" | "image" | "video" | "3d" | "audio"; type EditableNodeType = "nanoBanana" | "generateVideo" | "generateAudio"; -type DraftField = "prompt" | "aspectRatio" | "resolution" | "selectedModel" | "inputImages" | "parameters"; +type DraftField = "prompt" | "aspectRatio" | "resolution" | "selectedModel" | "inputImages" | "imageCount" | "parameters"; type ActiveChip = "style" | "mark" | "focus"; type VideoStitchLoopCount = 1 | 2 | 3; @@ -59,6 +60,7 @@ interface ComposerDraft { resolution: Resolution; selectedModel: SelectedModel; inputImages: string[]; + imageCount: ImageGenerationCount; parameters?: Record; } @@ -75,6 +77,7 @@ interface ComposerAdapter { const ASPECT_RATIOS: AspectRatio[] = ["1:1", "3:4", "4:3", "9:16", "16:9", "21:9"]; const RESOLUTIONS: Resolution[] = ["1K", "2K", "4K"]; +const IMAGE_COUNTS: ImageGenerationCount[] = [1, 2, 4]; const MAX_REFERENCE_IMAGES = 4; const MAX_REFERENCE_IMAGE_BYTES = 10 * 1024 * 1024; @@ -95,6 +98,7 @@ function createEmptyDraft(): ComposerDraft { resolution: "2K", selectedModel: DEFAULT_IMAGE_MODEL, inputImages: [], + imageCount: 1, parameters: {}, }; } @@ -233,6 +237,7 @@ const imageComposerAdapter: ComposerAdapter = { resolution: data.resolution ?? "2K", selectedModel: data.selectedModel ?? DEFAULT_IMAGE_MODEL, inputImages: data.inputImages ?? [], + imageCount: data.imageCount ?? 1, parameters: data.parameters ?? {}, }; }, @@ -242,6 +247,7 @@ const imageComposerAdapter: ComposerAdapter = { if (dirtyFields.has("aspectRatio")) patch.aspectRatio = draft.aspectRatio; if (dirtyFields.has("resolution")) patch.resolution = draft.resolution; if (dirtyFields.has("inputImages")) patch.inputImages = draft.inputImages; + if (dirtyFields.has("imageCount")) patch.imageCount = draft.imageCount; if (dirtyFields.has("selectedModel")) { patch.selectedModel = draft.selectedModel; patch.model = getLegacyImageModel(draft.selectedModel); @@ -268,6 +274,7 @@ const videoComposerAdapter: ComposerAdapter = { resolution: "2K", selectedModel: data.selectedModel ?? DEFAULT_VIDEO_MODEL, inputImages: data.inputImages ?? [], + imageCount: 1, parameters: data.parameters ?? {}, }; }, @@ -300,6 +307,7 @@ const audioComposerAdapter: ComposerAdapter = { resolution: "2K", selectedModel: data.selectedModel ?? DEFAULT_AUDIO_MODEL, inputImages: [], + imageCount: 1, parameters: data.parameters ?? {}, }; }, @@ -373,6 +381,7 @@ function nodeDraftFingerprint(context: ComposerContext): string { inputImages: "inputImages" in data ? data.inputImages : undefined, aspectRatio: "aspectRatio" in data ? data.aspectRatio : undefined, resolution: "resolution" in data ? data.resolution : undefined, + imageCount: "imageCount" in data ? data.imageCount : undefined, selectedModel: data.selectedModel ? { provider: data.selectedModel.provider, @@ -394,6 +403,7 @@ function buildInitialDataForNode(nodeType: NodeType, draft: ComposerDraft): Part resolution: draft.resolution, model: getLegacyImageModel(draft.selectedModel), selectedModel: draft.selectedModel, + imageCount: draft.imageCount, parameters: {}, inputSchema: undefined, useGoogleSearch: false, @@ -552,6 +562,7 @@ export function GenerationComposer() { resolution: dirty.has("resolution") ? current.resolution : nextDraft.resolution, selectedModel: dirty.has("selectedModel") ? current.selectedModel : nextDraft.selectedModel, inputImages: dirty.has("inputImages") ? current.inputImages : nextDraft.inputImages, + imageCount: dirty.has("imageCount") ? current.imageCount : nextDraft.imageCount, parameters: dirty.has("parameters") ? current.parameters : nextDraft.parameters, }; }); @@ -1402,6 +1413,21 @@ export function GenerationComposer() { ))} + + )} diff --git a/src/store/execution/__tests__/nanoBananaExecutor.test.ts b/src/store/execution/__tests__/nanoBananaExecutor.test.ts index de3cbf26..40ec1a86 100644 --- a/src/store/execution/__tests__/nanoBananaExecutor.test.ts +++ b/src/store/execution/__tests__/nanoBananaExecutor.test.ts @@ -265,6 +265,80 @@ describe("executeNanoBanana", () => { }); }); + it("should generate multiple image candidates into one node history", async () => { + const node = makeNode({ imageCount: 4 }); + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, image: "data:image/png;base64,result-1" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, image: "data:image/png;base64,result-2" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, image: "data:image/png;base64,result-3" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, image: "data:image/png;base64,result-4" }), + }); + + const ctx = makeCtx(node); + await executeNanoBanana(ctx); + + expect(mockFetch).toHaveBeenCalledTimes(4); + expect(ctx.addToGlobalHistory).toHaveBeenCalledTimes(4); + + const completeCall = (ctx.updateNodeData as ReturnType).mock.calls.find( + (c: unknown[]) => (c[1] as Record).status === "complete" + ); + expect(completeCall?.[1]).toMatchObject({ + outputImage: "data:image/png;base64,result-1", + selectedHistoryIndex: 0, + imageHistory: [ + expect.objectContaining({ image: "data:image/png;base64,result-1" }), + expect.objectContaining({ image: "data:image/png;base64,result-2" }), + expect.objectContaining({ image: "data:image/png;base64,result-3" }), + expect.objectContaining({ image: "data:image/png;base64,result-4" }), + ], + }); + }); + + it("should keep successful candidates when a later image in the batch fails", async () => { + const node = makeNode({ imageCount: 4 }); + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, image: "data:image/png;base64,result-1" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, image: "data:image/png;base64,result-2" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: false, error: "Bad prompt" }), + }); + + const ctx = makeCtx(node); + await executeNanoBanana(ctx); + + expect(mockFetch).toHaveBeenCalledTimes(3); + const completeCall = (ctx.updateNodeData as ReturnType).mock.calls.find( + (c: unknown[]) => (c[1] as Record).status === "complete" + ); + expect(completeCall?.[1]).toMatchObject({ + outputImage: "data:image/png;base64,result-1", + error: "Bad prompt", + imageHistory: [ + expect.objectContaining({ image: "data:image/png;base64,result-1" }), + expect.objectContaining({ image: "data:image/png;base64,result-2" }), + ], + }); + }); + it("should save browser-local generations through the browser file system", async () => { const node = makeNode(); mockFetch.mockResolvedValueOnce({ @@ -468,6 +542,45 @@ describe("executeNanoBanana", () => { expect(ctx.appendOutputGalleryImage).toHaveBeenCalledWith("gal-1", "data:image/png;base64,result"); }); + it("should push every generated candidate to downstream outputGallery nodes", async () => { + const node = makeNode({ imageCount: 2 }); + const galleryNode = { + id: "gal-1", + type: "outputGallery", + data: { images: ["old.png"] }, + } as WorkflowNode; + + const ctx = makeCtx(node, { + getEdges: vi.fn().mockReturnValue([ + { id: "e1", source: "gen-1", target: "gal-1" }, + ]), + getNodes: vi.fn().mockReturnValue([node, galleryNode]), + }); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, image: "data:image/png;base64,result-1" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, image: "data:image/png;base64,result-2" }), + }); + + await executeNanoBanana(ctx); + + expect(ctx.appendOutputGalleryImage).toHaveBeenNthCalledWith( + 1, + "gal-1", + "data:image/png;base64,result-1" + ); + expect(ctx.appendOutputGalleryImage).toHaveBeenNthCalledWith( + 2, + "gal-1", + "data:image/png;base64,result-2" + ); + }); + it("falls back on primary failure and stamps metadata", async () => { const node = makeNode({ fallbackModel: { diff --git a/src/store/execution/nanoBananaExecutor.ts b/src/store/execution/nanoBananaExecutor.ts index 9d8eea07..64ad1507 100644 --- a/src/store/execution/nanoBananaExecutor.ts +++ b/src/store/execution/nanoBananaExecutor.ts @@ -139,21 +139,81 @@ export async function executeNanoBanana( } try { - const response = await fetch("/api/generate", { - method: "POST", - headers, - body: JSON.stringify(requestPayload), - ...(signal ? { signal } : {}), - }); + const requestedImageCount = nodeData.imageCount === 2 || nodeData.imageCount === 4 + ? nodeData.imageCount + : 1; + const usedModelId = modelToUse.modelId || nodeData.model; + const usedModelDisplayName = modelToUse.displayName || usedModelId; + const batchTimestamp = Date.now(); + const generatedItems: Array<{ + id: string; + image: string; + timestamp: number; + }> = []; + let partialError: string | null = null; + + for (let imageIndex = 0; imageIndex < requestedImageCount; imageIndex++) { + const response = await fetch("/api/generate", { + method: "POST", + headers, + body: JSON.stringify(requestPayload), + ...(signal ? { signal } : {}), + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage = `HTTP ${response.status}`; + try { + const errorJson = JSON.parse(errorText); + errorMessage = errorJson.error || errorMessage; + } catch { + if (errorText) errorMessage += ` - ${errorText.substring(0, 200)}`; + } + + if (generatedItems.length > 0) { + partialError = errorMessage; + break; + } + + updateNodeData(node.id, { + status: "error", + error: errorMessage, + }); + throw new Error(errorMessage); + } + + let result = await response.json(); + + // Handle polling response (long-running Kie tasks) + if (result.polling) { + result = await pollGenerateTask({ + taskId: result.taskId, + provider: result.pollProvider, + modelId: result.pollModelId, + modelName: result.pollModelName, + mediaType: result.pollMediaType, + headers, + signal, + }); + } + + if (result.success && result.image) { + const timestamp = batchTimestamp + imageIndex; + const imageId = requestedImageCount === 1 + ? `${timestamp}` + : `${batchTimestamp}-${imageIndex + 1}`; + generatedItems.push({ + id: imageId, + image: result.image, + timestamp, + }); + continue; + } - if (!response.ok) { - const errorText = await response.text(); - let errorMessage = `HTTP ${response.status}`; - try { - const errorJson = JSON.parse(errorText); - errorMessage = errorJson.error || errorMessage; - } catch { - if (errorText) errorMessage += ` - ${errorText.substring(0, 200)}`; + const errorMessage = result.error || "Generation failed"; + if (generatedItems.length > 0) { + partialError = errorMessage; + break; } updateNodeData(node.id, { @@ -163,81 +223,62 @@ export async function executeNanoBanana( throw new Error(errorMessage); } - let result = await response.json(); - - // Handle polling response (long-running Kie tasks) - if (result.polling) { - result = await pollGenerateTask({ - taskId: result.taskId, - provider: result.pollProvider, - modelId: result.pollModelId, - modelName: result.pollModelName, - mediaType: result.pollMediaType, - headers, - signal, + if (generatedItems.length === 0) { + const errorMessage = partialError || "Generation failed"; + updateNodeData(node.id, { + status: "error", + error: errorMessage, }); - - if (!result.success) { - updateNodeData(node.id, { - status: "error", - error: result.error || "Generation failed", - }); - throw new Error(result.error || "Generation failed"); - } + throw new Error(errorMessage); } - if (result.success && result.image) { - const timestamp = Date.now(); - const imageId = `${timestamp}`; - const usedModelId = modelToUse.modelId || nodeData.model; - const usedModelDisplayName = modelToUse.displayName || usedModelId; + const newHistoryItems = generatedItems.map((item) => ({ + id: item.id, + image: item.image, + timestamp: item.timestamp, + prompt: finalPrompt, + aspectRatio: nodeData.aspectRatio, + model: usedModelId, + modelDisplayName: usedModelDisplayName, + modelProvider: modelToUse.provider, + })); + const updatedHistory = [...newHistoryItems, ...(nodeData.imageHistory || [])].slice(0, 50); + updateNodeData(node.id, { + outputImage: generatedItems[0].image, + status: "complete", + error: partialError, + imageHistory: updatedHistory, + selectedHistoryIndex: 0, + lastUsedModel: modelToUse, + }); + + const edges = getEdges(); + const nodes = getNodes(); + + generatedItems.forEach((item) => { // Save to global history addToGlobalHistory({ - image: result.image, - timestamp, - prompt: finalPrompt, - aspectRatio: nodeData.aspectRatio, - model: usedModelId, - modelDisplayName: usedModelDisplayName, - modelProvider: modelToUse.provider, - }); - - // Add to node's carousel history - const newHistoryItem = { - id: imageId, - image: result.image, - timestamp, + image: item.image, + timestamp: item.timestamp, prompt: finalPrompt, aspectRatio: nodeData.aspectRatio, model: usedModelId, modelDisplayName: usedModelDisplayName, modelProvider: modelToUse.provider, - }; - const updatedHistory = [newHistoryItem, ...(nodeData.imageHistory || [])].slice(0, 50); - - updateNodeData(node.id, { - outputImage: result.image, - status: "complete", - error: null, - imageHistory: updatedHistory, - selectedHistoryIndex: 0, - lastUsedModel: modelToUse, }); - // Push new image to connected downstream outputGallery nodes (atomic append) - const edges = getEdges(); - const nodes = getNodes(); + // Push each generated image to connected downstream outputGallery nodes. edges .filter((e) => e.source === node.id) .forEach((e) => { const target = nodes.find((n) => n.id === e.target); if (target?.type === "outputGallery") { - appendOutputGalleryImage(target.id, result.image); + appendOutputGalleryImage(target.id, item.image); } }); - // Track cost + // Track cost per successful image. if (modelToUse.provider === "fal" && modelToUse.pricing) { addIncurredCost(modelToUse.pricing.amount); } else if (modelToUse.provider === "gemini") { @@ -249,34 +290,34 @@ export async function executeNanoBanana( if (effectiveGenerationsPath && isBrowserFileSystemPath(effectiveGenerationsPath)) { const savePromise = writeBrowserGenerationFile( effectiveGenerationsPath, - imageId, - result.image + item.id, + item.image ) .then(() => undefined) .catch((err) => { console.error("Failed to save browser-local generation:", err); }); - trackSaveGeneration(imageId, savePromise); + trackSaveGeneration(item.id, savePromise); } else if (effectiveGenerationsPath) { const savePromise = fetch("/api/save-generation", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ directoryPath: effectiveGenerationsPath, - image: result.image, + image: item.image, prompt: finalPrompt, - imageId, + imageId: item.id, }), }) .then((res) => res.json()) .then((saveResult) => { - if (saveResult.success && saveResult.imageId && saveResult.imageId !== imageId) { + if (saveResult.success && saveResult.imageId && saveResult.imageId !== item.id) { const currentNode = getNodes().find((n) => n.id === node.id); if (currentNode) { const currentData = currentNode.data as NanoBananaNodeData; const histCopy = [...(currentData.imageHistory || [])]; - const entryIndex = histCopy.findIndex((h) => h.id === imageId); + const entryIndex = histCopy.findIndex((h) => h.id === item.id); if (entryIndex !== -1) { histCopy[entryIndex] = { ...histCopy[entryIndex], id: saveResult.imageId }; updateNodeData(node.id, { imageHistory: histCopy }); @@ -288,15 +329,9 @@ export async function executeNanoBanana( console.error("Failed to save generation:", err); }); - trackSaveGeneration(imageId, savePromise); + trackSaveGeneration(item.id, savePromise); } - } else { - updateNodeData(node.id, { - status: "error", - error: result.error || "Generation failed", - }); - throw new Error(result.error || "Generation failed"); - } + }); } catch (error) { if (error instanceof DOMException && error.name === "AbortError") { throw error; diff --git a/src/store/utils/__tests__/nodeDefaults.test.ts b/src/store/utils/__tests__/nodeDefaults.test.ts index a5fe932f..340aaef8 100644 --- a/src/store/utils/__tests__/nodeDefaults.test.ts +++ b/src/store/utils/__tests__/nodeDefaults.test.ts @@ -145,6 +145,7 @@ describe("nodeDefaults utilities", () => { expect(data).toHaveProperty("resolution"); expect(data).toHaveProperty("model"); expect(data).toHaveProperty("selectedModel"); + expect(data).toHaveProperty("imageCount", 1); expect(data).toHaveProperty("useGoogleSearch"); expect(data).toHaveProperty("status", "idle"); expect(data).toHaveProperty("error", null); diff --git a/src/store/utils/nodeDefaults.ts b/src/store/utils/nodeDefaults.ts index f91cb80b..fd4291a4 100644 --- a/src/store/utils/nodeDefaults.ts +++ b/src/store/utils/nodeDefaults.ts @@ -166,6 +166,7 @@ export const createDefaultNodeData = (type: NodeType): WorkflowNodeData => { resolution, model: legacyDefaults.model, // Keep legacy model field for backward compat selectedModel, + imageCount: 1, useGoogleSearch, useImageSearch, status: "idle", diff --git a/src/types/nodes.ts b/src/types/nodes.ts index c08667a6..0ce1918a 100644 --- a/src/types/nodes.ts +++ b/src/types/nodes.ts @@ -190,6 +190,8 @@ export interface ModelInputDef { /** * Nano Banana node - AI image generation */ +export type ImageGenerationCount = 1 | 2 | 4; + export interface NanoBananaNodeData extends BaseNodeData { inputImages: string[]; // Now supports multiple images inputImageRefs?: string[]; // External image references for storage optimization @@ -201,6 +203,7 @@ export interface NanoBananaNodeData extends BaseNodeData { model: ModelType; selectedModel?: SelectedModel; // Multi-provider model selection (optional for backward compat) lastUsedModel?: SelectedModel; // The model that produced the current output + imageCount?: ImageGenerationCount; // Number of candidate images to generate per run useGoogleSearch: boolean; // Only available for Nano Banana Pro and Nano Banana 2 useImageSearch: boolean; // Only available for Nano Banana 2 parameters?: Record; // Model-specific parameters for external providers