Browse Source

Merge branch 'master' into sync-test-s

feature/canvas-chatbot-copilot
jiajia 2 months ago
parent
commit
4ac2db4049
  1. 66
      src/components/__tests__/GenerateImageNode.test.tsx
  2. 21
      src/components/nodes/GenerateImageNode.tsx
  3. 20
      src/store/execution/__tests__/nanoBananaExecutor.test.ts
  4. 27
      src/store/execution/nanoBananaExecutor.ts
  5. 13
      src/store/workflowStore.ts
  6. 63
      src/utils/browserFileSystem.ts

66
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(
<TestWrapper>
<GenerateImageNode {...createNodeProps({
outputImage: "data:image/png;base64,current",
imageHistory: [
{ id: "img1", timestamp: Date.now(), prompt: "test1", aspectRatio: "1:1", model: "nano-banana" },
{ id: "img2", timestamp: Date.now(), prompt: "test2", aspectRatio: "1:1", model: "nano-banana" },
],
selectedHistoryIndex: 0,
})} />
</TestWrapper>
);
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", () => {

21
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<NanoBananaNo
const adaptiveOutputImage = useAdaptiveImageSrc(data.outputImage, id);
const updateNodeData = useWorkflowStore((state) => 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<NanoBananaNo
}, [id, regenerateNode]);
const loadImageById = useCallback(async (imageId: string) => {
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<NanoBananaNo
console.warn("Error loading image:", error);
return null;
}
}, [generationsPath]);
}, [generationsPath, saveDirectoryPath]);
const handleCarouselPrevious = useCallback(async () => {
const history = nodeData.imageHistory || [];

20
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<string, unknown> = {}): 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 () => {

27
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,

13
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<WorkflowStore> = (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<WorkflowStore> = (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({

63
src/utils/browserFileSystem.ts

@ -40,7 +40,7 @@ declare global {
const memoryHandles = new Map<string, FileSystemDirectoryHandle>();
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<Blob> {
const response = await fetch(dataUrl);
return response.blob();
}
async function fileToDataUrl(file: File): Promise<string> {
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<string | null> {
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<string | null> {
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<BrowserWorkflowListEntry[]> {
const rootHandle = await resolveDirectoryHandle(parentPath, false);
await ensurePermission(rootHandle, "readwrite");

Loading…
Cancel
Save