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