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