diff --git a/src/components/__tests__/GenerateImageNode.test.tsx b/src/components/__tests__/GenerateImageNode.test.tsx
index c02448f8..322b45bf 100644
--- a/src/components/__tests__/GenerateImageNode.test.tsx
+++ b/src/components/__tests__/GenerateImageNode.test.tsx
@@ -10,6 +10,16 @@ vi.mock("@/utils/deduplicatedFetch", () => ({
clearFetchCache: vi.fn(),
}));
+const mockLoadBrowserGenerationFile = vi.fn();
+
+vi.mock("@/utils/browserFileSystem", () => ({
+ isBrowserFileSystemPath: (path: string | null | undefined) =>
+ typeof path === "string" && path.startsWith("browserfs://"),
+ joinBrowserFileSystemPath: (basePath: string, folderName: string) =>
+ `${basePath.replace(/\/+$/, "")}/${encodeURIComponent(folderName)}`,
+ loadBrowserGenerationFile: (...args: unknown[]) => mockLoadBrowserGenerationFile(...args),
+}));
+
// Mock the workflow store
const mockUpdateNodeData = vi.fn();
const mockRegenerateNode = vi.fn();
@@ -110,6 +120,7 @@ describe("GenerateImageNode", () => {
decrementModalCount: mockDecrementModalCount,
providerSettings: defaultProviderSettings,
generationsPath: "/test/generations",
+ saveDirectoryPath: null,
isRunning: false,
currentNodeIds: [],
groups: {},
@@ -406,6 +417,61 @@ describe("GenerateImageNode", () => {
});
expect(mockFetch).not.toHaveBeenCalledWith("/api/load-generation", expect.anything());
});
+
+ it("should load carousel images from browser-local generations when only an id is stored", async () => {
+ mockLoadBrowserGenerationFile.mockResolvedValueOnce("data:image/png;base64,browser-next");
+ mockUseWorkflowStore.mockImplementation((selector) => {
+ const state = {
+ updateNodeData: mockUpdateNodeData,
+ regenerateNode: mockRegenerateNode,
+ addNode: mockAddNode,
+ incrementModalCount: mockIncrementModalCount,
+ decrementModalCount: mockDecrementModalCount,
+ providerSettings: defaultProviderSettings,
+ generationsPath: null,
+ saveDirectoryPath: "browserfs://root/project",
+ isRunning: false,
+ currentNodeIds: [],
+ groups: {},
+ nodes: [],
+ recentModels: [],
+ trackModelUsage: vi.fn(),
+ getNodesWithComments: vi.fn(() => []),
+ markCommentViewed: vi.fn(),
+ setNavigationTarget: vi.fn(),
+ };
+ return selector(state);
+ });
+
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByTitle("Next image"));
+
+ await waitFor(() => {
+ expect(mockLoadBrowserGenerationFile).toHaveBeenCalledWith(
+ "browserfs://root/project/generations",
+ "img2"
+ );
+ expect(mockUpdateNodeData).toHaveBeenCalledWith("test-node-1", {
+ outputImage: "data:image/png;base64,browser-next",
+ selectedHistoryIndex: 1,
+ status: "idle",
+ error: null,
+ });
+ });
+ expect(mockFetch).not.toHaveBeenCalledWith("/api/load-generation", expect.anything());
+ });
});
describe("Legacy Data Migration", () => {
diff --git a/src/components/nodes/GenerateImageNode.tsx b/src/components/nodes/GenerateImageNode.tsx
index 2d0ca18b..443477f6 100644
--- a/src/components/nodes/GenerateImageNode.tsx
+++ b/src/components/nodes/GenerateImageNode.tsx
@@ -22,6 +22,11 @@ import { useShowHandleLabels } from "@/hooks/useShowHandleLabels";
import { HandleLabel } from "./HandleLabel";
import { useI18n } from "@/i18n";
import { createGeminiLegacyImageModel, createNewApiWGDefaultImageModel } from "@/store/utils/defaultImageModel";
+import {
+ isBrowserFileSystemPath,
+ joinBrowserFileSystemPath,
+ loadBrowserGenerationFile,
+} from "@/utils/browserFileSystem";
/** Reorder items so they read column-first in a row-based CSS grid.
* e.g. [1,2,3,4,5,6,7,8] with 2 cols → [1,5,2,6,3,7,4,8] */
@@ -65,6 +70,7 @@ export function GenerateImageNode({ id, data, selected }: NodeProps state.updateNodeData);
const generationsPath = useWorkflowStore((state) => state.generationsPath);
+ const saveDirectoryPath = useWorkflowStore((state) => state.saveDirectoryPath);
// Use stable selector for API keys to prevent unnecessary re-fetches
const { newApiWGApiKey, newApiWGBaseUrl, replicateApiKey, falApiKey, kieApiKey, replicateEnabled, kieEnabled } = useProviderApiKeys();
const [isLoadingCarouselImage, setIsLoadingCarouselImage] = useState(false);
@@ -334,17 +340,26 @@ export function GenerateImageNode({ id, data, selected }: NodeProps {
- if (!generationsPath) {
+ const effectiveGenerationsPath = generationsPath
+ ?? (isBrowserFileSystemPath(saveDirectoryPath)
+ ? joinBrowserFileSystemPath(saveDirectoryPath, "generations")
+ : null);
+
+ if (!effectiveGenerationsPath) {
console.error("Generations path not configured");
return null;
}
try {
+ if (isBrowserFileSystemPath(effectiveGenerationsPath)) {
+ return await loadBrowserGenerationFile(effectiveGenerationsPath, imageId);
+ }
+
const response = await fetch("/api/load-generation", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
- directoryPath: generationsPath,
+ directoryPath: effectiveGenerationsPath,
imageId,
}),
});
@@ -360,7 +375,7 @@ export function GenerateImageNode({ id, data, selected }: NodeProps {
const history = nodeData.imageHistory || [];
diff --git a/src/store/execution/__tests__/nanoBananaExecutor.test.ts b/src/store/execution/__tests__/nanoBananaExecutor.test.ts
index e563df30..de3cbf26 100644
--- a/src/store/execution/__tests__/nanoBananaExecutor.test.ts
+++ b/src/store/execution/__tests__/nanoBananaExecutor.test.ts
@@ -12,6 +12,16 @@ vi.mock("@/utils/costCalculator", () => ({
calculateGenerationCost: vi.fn().mockReturnValue(0.05),
}));
+const mockWriteBrowserGenerationFile = vi.fn();
+
+vi.mock("@/utils/browserFileSystem", () => ({
+ isBrowserFileSystemPath: (path: string | null | undefined) =>
+ typeof path === "string" && path.startsWith("browserfs://"),
+ joinBrowserFileSystemPath: (basePath: string, folderName: string) =>
+ `${basePath.replace(/\/+$/, "")}/${encodeURIComponent(folderName)}`,
+ writeBrowserGenerationFile: (...args: unknown[]) => mockWriteBrowserGenerationFile(...args),
+}));
+
function makeNode(data: Record = {}): WorkflowNode {
return {
id: "gen-1",
@@ -87,6 +97,7 @@ function makeCtx(
beforeEach(() => {
vi.clearAllMocks();
+ mockWriteBrowserGenerationFile.mockResolvedValue({ filePath: "browserfs://root/project/generations/img.png" });
});
describe("executeNanoBanana", () => {
@@ -254,7 +265,7 @@ describe("executeNanoBanana", () => {
});
});
- it("should not call server save-generation for browser-local generation paths", async () => {
+ it("should save browser-local generations through the browser file system", async () => {
const node = makeNode();
mockFetch.mockResolvedValueOnce({
ok: true,
@@ -268,7 +279,12 @@ describe("executeNanoBanana", () => {
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith("/api/generate", expect.anything());
- expect(ctx.trackSaveGeneration).not.toHaveBeenCalled();
+ expect(mockWriteBrowserGenerationFile).toHaveBeenCalledWith(
+ "browserfs://root/project/generations",
+ expect.any(String),
+ "data:image/png;base64,result"
+ );
+ expect(ctx.trackSaveGeneration).toHaveBeenCalled();
});
it("should add to global history on success", async () => {
diff --git a/src/store/execution/nanoBananaExecutor.ts b/src/store/execution/nanoBananaExecutor.ts
index 8ae0b339..787cb83f 100644
--- a/src/store/execution/nanoBananaExecutor.ts
+++ b/src/store/execution/nanoBananaExecutor.ts
@@ -19,7 +19,11 @@ import {
import { pollGenerateTask } from "./pollTaskCompletion";
import { runWithFallback } from "./runWithFallback";
import type { NodeExecutionContext } from "./types";
-import { isBrowserFileSystemPath } from "@/utils/browserFileSystem";
+import {
+ isBrowserFileSystemPath,
+ joinBrowserFileSystemPath,
+ writeBrowserGenerationFile,
+} from "@/utils/browserFileSystem";
export interface NanoBananaOptions {
/** When true, falls back to stored inputImages/inputPrompt if no connections provide them. */
@@ -42,6 +46,7 @@ export async function executeNanoBanana(
addIncurredCost,
addToGlobalHistory,
generationsPath,
+ saveDirectoryPath,
trackSaveGeneration,
appendOutputGalleryImage,
} = ctx;
@@ -49,6 +54,10 @@ export async function executeNanoBanana(
const { useStoredFallback = false } = options;
const { images: connectedImages, text: connectedText, dynamicInputs } = getConnectedInputs(node.id);
+ const effectiveGenerationsPath = generationsPath
+ ?? (isBrowserFileSystemPath(saveDirectoryPath)
+ ? joinBrowserFileSystemPath(saveDirectoryPath, "generations")
+ : null);
// Get fresh node data from store
const freshNode = getFreshNode(node.id);
@@ -237,12 +246,24 @@ export async function executeNanoBanana(
}
// Auto-save to generations folder if configured
- if (generationsPath && !isBrowserFileSystemPath(generationsPath)) {
+ if (effectiveGenerationsPath && isBrowserFileSystemPath(effectiveGenerationsPath)) {
+ const savePromise = writeBrowserGenerationFile(
+ effectiveGenerationsPath,
+ imageId,
+ result.image
+ )
+ .then(() => undefined)
+ .catch((err) => {
+ console.error("Failed to save browser-local generation:", err);
+ });
+
+ trackSaveGeneration(imageId, savePromise);
+ } else if (effectiveGenerationsPath) {
const savePromise = fetch("/api/save-generation", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
- directoryPath: generationsPath,
+ directoryPath: effectiveGenerationsPath,
image: result.image,
prompt: finalPrompt,
imageId,
diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts
index e4a1dba5..6d0f6289 100644
--- a/src/store/workflowStore.ts
+++ b/src/store/workflowStore.ts
@@ -29,7 +29,11 @@ import { UndoManager, UndoSnapshot, clonePreservingStrings } from "./undoHistory
import { useToast } from "@/components/Toast";
import { logger } from "@/utils/logger";
import { externalizeWorkflowMedia, hydrateWorkflowMedia } from "@/utils/mediaStorage";
-import { isBrowserFileSystemPath, writeBrowserWorkflowFile } from "@/utils/browserFileSystem";
+import {
+ isBrowserFileSystemPath,
+ joinBrowserFileSystemPath,
+ writeBrowserWorkflowFile,
+} from "@/utils/browserFileSystem";
import { EditOperation, applyEditOperations as executeEditOps } from "@/lib/chat/editOperations";
import { findNearestFreePosition } from "@/utils/spatialLayout";
import {
@@ -2251,7 +2255,10 @@ const workflowStoreImpl: StateCreator = (set, get) => ({
workflowId: workflow.id || null,
workflowName: workflow.name,
saveDirectoryPath: directoryPath || null,
- generationsPath: savedConfig?.generationsPath || null,
+ generationsPath: savedConfig?.generationsPath
+ || (directoryPath && isBrowserFileSystemPath(directoryPath)
+ ? joinBrowserFileSystemPath(directoryPath, "generations")
+ : null),
lastSavedAt: savedConfig?.lastSavedAt || null,
hasUnsavedChanges: false,
// Restore cost data
@@ -2341,7 +2348,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({
// Auto-derive generationsPath: use provided value, fall back to existing, then auto-derive
const currentGenPath = get().generationsPath;
const derivedGenerationsPath = isBrowserFileSystemPath(path)
- ? null
+ ? joinBrowserFileSystemPath(path, "generations")
: generationsPath ?? currentGenPath ?? `${path}/generations`;
set({
diff --git a/src/utils/browserFileSystem.ts b/src/utils/browserFileSystem.ts
index 0a670ffd..7c3f2365 100644
--- a/src/utils/browserFileSystem.ts
+++ b/src/utils/browserFileSystem.ts
@@ -40,7 +40,7 @@ declare global {
const memoryHandles = new Map();
-export function isBrowserFileSystemPath(path: string | null | undefined): boolean {
+export function isBrowserFileSystemPath(path: string | null | undefined): path is string {
return typeof path === "string" && path.startsWith(PREFIX);
}
@@ -52,6 +52,30 @@ export function joinBrowserFileSystemPath(basePath: string, folderName: string):
return `${basePath.replace(/\/+$/, "")}/${encodeURIComponent(folderName)}`;
}
+const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif"] as const;
+
+function extensionFromDataUrl(dataUrl: string): string {
+ const mime = dataUrl.match(/^data:([^;]+);base64,/)?.[1];
+ if (mime === "image/jpeg") return "jpg";
+ if (mime === "image/webp") return "webp";
+ if (mime === "image/gif") return "gif";
+ return "png";
+}
+
+async function dataUrlToBlob(dataUrl: string): Promise {
+ const response = await fetch(dataUrl);
+ return response.blob();
+}
+
+async function fileToDataUrl(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(String(reader.result));
+ reader.onerror = () => reject(reader.error);
+ reader.readAsDataURL(file);
+ });
+}
+
export async function pickBrowserWorkflowDirectory(): Promise {
if (!isBrowserDirectoryPickerSupported() || !window.showDirectoryPicker) return null;
@@ -84,6 +108,43 @@ export async function writeBrowserWorkflowFile(
return { filePath: `${directoryPath}/${fileName}` };
}
+export async function writeBrowserGenerationFile(
+ directoryPath: string,
+ imageId: string,
+ imageData: string
+): Promise<{ filePath: string }> {
+ const directoryHandle = await resolveDirectoryHandle(directoryPath, true);
+ await ensurePermission(directoryHandle, "readwrite");
+
+ const extension = extensionFromDataUrl(imageData);
+ const fileName = `${imageId}.${extension}`;
+ const fileHandle = await directoryHandle.getFileHandle(fileName, { create: true });
+ const writable = await fileHandle.createWritable();
+ await writable.write(await dataUrlToBlob(imageData));
+ await writable.close();
+
+ return { filePath: `${directoryPath}/${fileName}` };
+}
+
+export async function loadBrowserGenerationFile(
+ directoryPath: string,
+ imageId: string
+): Promise {
+ const directoryHandle = await resolveDirectoryHandle(directoryPath, false);
+ await ensurePermission(directoryHandle, "readwrite");
+
+ for (const extension of IMAGE_EXTENSIONS) {
+ try {
+ const fileHandle = await directoryHandle.getFileHandle(`${imageId}.${extension}`);
+ return fileToDataUrl(await fileHandle.getFile());
+ } catch {
+ continue;
+ }
+ }
+
+ return null;
+}
+
export async function listBrowserWorkflows(parentPath: string): Promise {
const rootHandle = await resolveDirectoryHandle(parentPath, false);
await ensurePermission(rootHandle, "readwrite");