Browse Source

feat: 增加多张图片生成功能

feature/canvas-chatbot-copilot
宋向航 2 months ago
parent
commit
c22086621b
  1. 199
      docs/multi-image-generation-implementation.md
  2. 20
      src/components/__tests__/GenerationComposer.test.tsx
  3. 28
      src/components/composer/GenerationComposer.tsx
  4. 113
      src/store/execution/__tests__/nanoBananaExecutor.test.ts
  5. 201
      src/store/execution/nanoBananaExecutor.ts
  6. 1
      src/store/utils/__tests__/nodeDefaults.test.ts
  7. 1
      src/store/utils/nodeDefaults.ts
  8. 3
      src/types/nodes.ts

199
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`,不要让普通轮播切换隐式改变参考图。
- 自动保存和输出画廊需要继续按“每张成功图片一次写入”维护。

20
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(<GenerationComposer />);
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(<GenerationComposer />);

28
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<string, unknown>;
}
@ -75,6 +77,7 @@ interface ComposerAdapter<TData extends WorkflowNodeData> {
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<NanoBananaNodeData> = {
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<NanoBananaNodeData> = {
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<GenerateVideoNodeData> = {
resolution: "2K",
selectedModel: data.selectedModel ?? DEFAULT_VIDEO_MODEL,
inputImages: data.inputImages ?? [],
imageCount: 1,
parameters: data.parameters ?? {},
};
},
@ -300,6 +307,7 @@ const audioComposerAdapter: ComposerAdapter<GenerateAudioNodeData> = {
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() {
</option>
))}
</select>
<select
aria-label="生成张数"
value={draft.imageCount}
onChange={(event) =>
markDraft({ imageCount: Number(event.target.value) as ImageGenerationCount }, ["imageCount"])
}
className="rounded-md border border-transparent bg-transparent px-1.5 py-1 text-neutral-200 outline-none transition-colors hover:border-neutral-700 hover:bg-neutral-700/60"
>
{IMAGE_COUNTS.map((count) => (
<option key={count} value={count} className="bg-neutral-900">
{count}
</option>
))}
</select>
</>
)}

113
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<typeof vi.fn>).mock.calls.find(
(c: unknown[]) => (c[1] as Record<string, unknown>).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<typeof vi.fn>).mock.calls.find(
(c: unknown[]) => (c[1] as Record<string, unknown>).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: {

201
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;

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

1
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",

3
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<string, unknown>; // Model-specific parameters for external providers

Loading…
Cancel
Save