Browse Source

Merge branch 'master' into feature_apikey

feature_apikey
TianYun 2 months ago
parent
commit
a89412c58c
  1. 4
      src/components/Header.tsx
  2. 8
      src/components/__tests__/Header.test.tsx
  3. 62
      src/store/execution/__tests__/nanoBananaExecutor.test.ts
  4. 16
      src/store/execution/__tests__/runWithFallback.test.ts
  5. 14
      src/store/execution/nanoBananaExecutor.ts
  6. 6
      src/store/execution/runWithFallback.ts
  7. 14
      src/store/utils/defaultImageModel.ts
  8. 6
      src/store/workflowStore.ts
  9. 204
      src/utils/__tests__/mediaStorage.browserfs.test.ts
  10. 48
      src/utils/browserFileSystem.ts
  11. 54
      src/utils/mediaStorage.ts

4
src/components/Header.tsx

@ -251,9 +251,9 @@ export function Header() {
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
title={t("header.openWelcome")}
>
<img src="/banana_icon.png" alt="PopiArt" className="w-6 h-6" />
<img src="/banana_icon.png" alt="" aria-hidden="true" className="w-6 h-6" />
<h1 className="text-xl font-semibold text-neutral-100 tracking-tight">
popiart-node
Popi.TV
</h1>
</button>

8
src/components/__tests__/Header.test.tsx

@ -75,14 +75,16 @@ describe("Header", () => {
describe("Basic Rendering", () => {
it("should render the app title", () => {
render(<Header />);
expect(screen.getByText("popiart-node")).toBeInTheDocument();
expect(screen.getByText("Popi.TV")).toBeInTheDocument();
});
it("should render the brand icon", () => {
render(<Header />);
const icon = screen.getByAltText("PopiArt");
const { container } = render(<Header />);
const icon = container.querySelector('img[src="/banana_icon.png"]');
expect(icon).toBeInTheDocument();
expect(icon).toHaveAttribute("src", "/banana_icon.png");
expect(icon).toHaveAttribute("alt", "");
expect(icon).toHaveAttribute("aria-hidden", "true");
});
it("should hide external author link", () => {

62
src/store/execution/__tests__/nanoBananaExecutor.test.ts

@ -195,6 +195,68 @@ describe("executeNanoBanana", () => {
});
});
it("falls back from the default NewApiWG image model on provider overload", async () => {
const node = makeNode({
selectedModel: {
provider: "newapiwg",
modelId: "apiyi_nano_banana_2",
displayName: "apiyi_nano_banana_2",
capabilities: ["text-to-image", "image-to-image"],
},
});
mockFetch
.mockResolvedValueOnce({
ok: false,
text: () => Promise.resolve(JSON.stringify({
error: "apiyi_nano_banana_2: provider is temporarily overloaded. Please retry in a moment or switch models.",
})),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, image: "data:image/png;base64,fallback" }),
});
const ctx = makeCtx(node);
await executeNanoBanana(ctx);
expect(mockFetch).toHaveBeenCalledTimes(2);
const fallbackBody = JSON.parse(mockFetch.mock.calls[1][1].body);
expect(fallbackBody.selectedModel).toMatchObject({
provider: "newapiwg",
modelId: "gpt-image-2-all",
});
const calls = (ctx.updateNodeData as ReturnType<typeof vi.fn>).mock.calls;
const stampCall = calls.find(
(c: unknown[]) => (c[1] as Record<string, unknown>).__usedFallback === true
);
expect(stampCall?.[1]).toMatchObject({
__fallbackModelUsed: "gpt-image-2-all",
__primaryError: "apiyi_nano_banana_2: provider is temporarily overloaded. Please retry in a moment or switch models.",
});
});
it("does not implicitly fall back from the default NewApiWG image model on non-overload errors", async () => {
const node = makeNode({
selectedModel: {
provider: "newapiwg",
modelId: "apiyi_nano_banana_2",
displayName: "apiyi_nano_banana_2",
capabilities: ["text-to-image", "image-to-image"],
},
});
mockFetch.mockResolvedValueOnce({
ok: false,
text: () => Promise.resolve(JSON.stringify({ error: "bad request" })),
});
const ctx = makeCtx(node);
await expect(executeNanoBanana(ctx)).rejects.toThrow("bad request");
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("reroutes stored Gemini image nodes to NewApiWG when Gemini key is missing", async () => {
const node = makeNode();
mockFetch.mockResolvedValueOnce({

16
src/store/execution/__tests__/runWithFallback.test.ts

@ -73,6 +73,22 @@ describe("runWithFallback", () => {
expect(runOnce).toHaveBeenCalledTimes(1);
});
it("primary fails but predicate rejects fallback: rethrows primary error", async () => {
const runOnce = vi.fn().mockRejectedValueOnce(new Error("primary boom"));
await expect(
runWithFallback({
nodeId: "n1",
primary,
fallback,
updateNodeData,
runOnce,
shouldFallback: () => false,
})
).rejects.toThrow("primary boom");
expect(runOnce).toHaveBeenCalledTimes(1);
});
it("primary fails, fallback succeeds: stamps metadata and clears error", async () => {
const runOnce = vi
.fn()

14
src/store/execution/nanoBananaExecutor.ts

@ -14,6 +14,8 @@ import { buildGenerateHeaders } from "@/store/utils/buildApiHeaders";
import {
createDefaultImageModel,
createNewApiWGDefaultImageModel,
createNewApiWGDefaultImageFallbackModel,
isNewApiWGDefaultImageModel,
shouldPreferNewApiWGImageModel,
} from "@/store/utils/defaultImageModel";
import { pollGenerateTask } from "./pollTaskCompletion";
@ -31,6 +33,11 @@ export interface NanoBananaOptions {
useStoredFallback?: boolean;
}
function isProviderOverloadError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /provider is temporarily overloaded|system\s+disk\s+overloaded/i.test(message);
}
async function loadSavedGenerationImage(directoryPath: string, imageId: string): Promise<string | null> {
const response = await fetch("/api/load-generation", {
method: "POST",
@ -419,6 +426,7 @@ export async function executeNanoBanana(
nodeData.selectedModel ?? createDefaultImageModel(nodeData.model, providerSettings, providerEnvStatus);
let primaryParameters: Record<string, unknown> | undefined;
let fallbackModel = nodeData.fallbackModel;
let shouldFallback: ((error: unknown) => boolean) | undefined;
if (
primaryModel.provider === "gemini" &&
@ -447,6 +455,11 @@ export async function executeNanoBanana(
});
}
if (!fallbackModel && isNewApiWGDefaultImageModel(primaryModel)) {
fallbackModel = createNewApiWGDefaultImageFallbackModel();
shouldFallback = isProviderOverloadError;
}
await runWithFallback({
nodeId: node.id,
primary: primaryModel,
@ -455,6 +468,7 @@ export async function executeNanoBanana(
fallbackParameters: nodeData.fallbackParameters,
updateNodeData,
runOnce,
shouldFallback,
clearOutput: { outputImage: null },
});
}

6
src/store/execution/runWithFallback.ts

@ -23,6 +23,7 @@ export interface RunWithFallbackOptions {
fallbackParameters?: Record<string, unknown>;
updateNodeData: (id: string, data: Partial<WorkflowNodeData>) => void;
runOnce: (model: SelectedModel, parametersOverride?: Record<string, unknown>) => Promise<void>;
shouldFallback?: (error: unknown) => boolean;
/** Data to merge when transitioning to fallback (e.g. { outputImage: null }) */
clearOutput?: Partial<WorkflowNodeData>;
}
@ -44,7 +45,7 @@ function isSameModel(a: SelectedModel, b: SelectedModel): boolean {
export async function runWithFallback(
options: RunWithFallbackOptions
): Promise<void> {
const { nodeId, primary, primaryParameters, fallback, fallbackParameters, updateNodeData, runOnce, clearOutput } = options;
const { nodeId, primary, primaryParameters, fallback, fallbackParameters, updateNodeData, runOnce, shouldFallback, clearOutput } = options;
// Clear any prior fallback metadata before we start.
updateNodeData(nodeId, {
@ -68,6 +69,9 @@ export async function runWithFallback(
}
const primaryErrMsg = errorMessage(primaryError);
if (shouldFallback && !shouldFallback(primaryError)) {
throw primaryError;
}
// Clear error state and stale output, show the fallback is now running.
updateNodeData(nodeId, {

14
src/store/utils/defaultImageModel.ts

@ -3,6 +3,7 @@ import { MODEL_DISPLAY_NAMES } from "@/types";
import { isPopiProviderMode } from "@/lib/providerMode";
const NEWAPIWG_DEFAULT_IMAGE_MODEL_ID = "apiyi_nano_banana_2";
const NEWAPIWG_DEFAULT_IMAGE_FALLBACK_MODEL_ID = "gpt-image-2-all";
const NEWAPIWG_DEFAULT_VIDEO_MODEL_ID = "doubao-seedance-2-0-260128";
const DEFAULT_AUDIO_MODEL_ID = "elevenlabs/turbo-v2.5";
@ -15,6 +16,19 @@ export function createNewApiWGDefaultImageModel(): SelectedModel {
};
}
export function createNewApiWGDefaultImageFallbackModel(): SelectedModel {
return {
provider: "newapiwg",
modelId: NEWAPIWG_DEFAULT_IMAGE_FALLBACK_MODEL_ID,
displayName: NEWAPIWG_DEFAULT_IMAGE_FALLBACK_MODEL_ID,
capabilities: ["text-to-image", "image-to-image"],
};
}
export function isNewApiWGDefaultImageModel(model: SelectedModel): boolean {
return model.provider === "newapiwg" && model.modelId === NEWAPIWG_DEFAULT_IMAGE_MODEL_ID;
}
export function createNewApiWGDefaultVideoModel(): SelectedModel {
return {
provider: "newapiwg",

6
src/store/workflowStore.ts

@ -2689,7 +2689,7 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
const isBrowserPath = isBrowserFileSystemPath(saveDirectoryPath);
// If external media storage is enabled, externalize media before saving
if (useExternalImageStorage && !isBrowserPath) {
if (useExternalImageStorage) {
workflow = await externalizeWorkflowMedia(workflow, saveDirectoryPath);
}
@ -2710,7 +2710,7 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
// If we externalized media, update store nodes with the refs
// This prevents duplicate media on subsequent saves
if (useExternalImageStorage && !isBrowserPath && workflow.nodes !== currentNodes) {
if (useExternalImageStorage && workflow.nodes !== currentNodes) {
// Merge refs from externalized nodes into current nodes (keeping media data)
const nodesWithRefs = currentNodes.map((node, index) => {
const externalizedNode = workflow.nodes[index];
@ -2776,7 +2776,7 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
lastSavedAt: timestamp,
hasUnsavedChanges: false,
// Update imageRefBasePath to reflect save location
imageRefBasePath: useExternalImageStorage && !isBrowserPath ? saveDirectoryPath : null,
imageRefBasePath: useExternalImageStorage ? saveDirectoryPath : null,
});
}

204
src/utils/__tests__/mediaStorage.browserfs.test.ts

@ -0,0 +1,204 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { externalizeWorkflowMedia, hydrateWorkflowMedia } from "../mediaStorage";
import type { WorkflowFile } from "@/store/workflowStore";
const browserFsMocks = vi.hoisted(() => ({
writeBrowserGenerationFile: vi.fn(),
loadBrowserGenerationFile: vi.fn(),
}));
vi.mock("../browserFileSystem", () => ({
isBrowserFileSystemPath: (path: string | null | undefined) =>
typeof path === "string" && path.startsWith("browserfs://"),
joinBrowserFileSystemPath: (basePath: string, folderName: string) =>
`${basePath.replace(/\/+$/, "")}/${encodeURIComponent(folderName)}`,
writeBrowserGenerationFile: browserFsMocks.writeBrowserGenerationFile,
loadBrowserGenerationFile: browserFsMocks.loadBrowserGenerationFile,
}));
const imageData = "data:image/png;base64,aW1hZ2U=";
const videoData = "data:video/mp4;base64,dmlkZW8=";
const audioData = "data:audio/mpeg;base64,YXVkaW8=";
const workflowPath = "browserfs://root/project";
function makeWorkflow(): WorkflowFile {
return {
version: 1,
id: "wf-browserfs",
name: "browserfs media",
nodes: [
{
id: "imageInput-1",
type: "imageInput",
position: { x: 0, y: 0 },
data: {
image: imageData,
filename: "input.png",
dimensions: null,
},
},
{
id: "generateVideo-1",
type: "generateVideo",
position: { x: 200, y: 0 },
data: {
inputImages: [imageData],
inputPrompt: "move",
outputVideo: videoData,
outputVideoRef: undefined,
outputVideoRemoteUrl: undefined,
outputVideoStorageStatus: undefined,
selectedVideoHistoryIndex: 0,
videoHistory: [
{
id: "vid-history-1",
video: videoData,
timestamp: 1,
prompt: "move",
},
],
status: "completed",
error: null,
},
},
{
id: "generateAudio-1",
type: "generateAudio",
position: { x: 400, y: 0 },
data: {
inputPrompt: "sound",
outputAudio: audioData,
outputAudioRef: undefined,
selectedAudioHistoryIndex: 0,
audioHistory: [
{
id: "aud-history-1",
audio: audioData,
timestamp: 1,
prompt: "sound",
},
],
status: "completed",
error: null,
},
},
],
edges: [],
edgeStyle: "angular",
} as WorkflowFile;
}
describe("mediaStorage browserfs support", () => {
beforeEach(() => {
browserFsMocks.writeBrowserGenerationFile.mockReset();
browserFsMocks.loadBrowserGenerationFile.mockReset();
browserFsMocks.writeBrowserGenerationFile.mockResolvedValue({ filePath: "browserfs://root/project/media.bin" });
vi.stubGlobal("fetch", vi.fn(async () => ({
json: async () => ({ success: true, imageId: "server-ref" }),
})));
});
it("externalizes browserfs media through browser file helpers instead of API fetch", async () => {
const workflow = makeWorkflow();
const externalized = await externalizeWorkflowMedia(workflow, workflowPath);
expect(fetch).not.toHaveBeenCalled();
expect(browserFsMocks.writeBrowserGenerationFile).toHaveBeenCalledWith(
"browserfs://root/project/inputs",
expect.any(String),
imageData
);
expect(browserFsMocks.writeBrowserGenerationFile).toHaveBeenCalledWith(
"browserfs://root/project/generations",
"vid-history-1",
videoData
);
expect(browserFsMocks.writeBrowserGenerationFile).toHaveBeenCalledWith(
"browserfs://root/project/generations",
"aud-history-1",
audioData
);
expect(externalized.nodes[0].data).toMatchObject({
image: null,
imageRef: expect.any(String),
});
expect(externalized.nodes[1].data).toMatchObject({
inputImages: [],
inputImageRefs: [expect.any(String)],
outputVideo: null,
outputVideoRef: "vid-history-1",
outputVideoStorageStatus: "localized",
videoHistory: [
{
id: "vid-history-1",
timestamp: 1,
prompt: "move",
},
],
});
expect(externalized.nodes[2].data).toMatchObject({
outputAudio: null,
outputAudioRef: "aud-history-1",
});
});
it("hydrates browserfs media refs through browser file helpers instead of API fetch", async () => {
browserFsMocks.loadBrowserGenerationFile.mockImplementation(async (directoryPath: string, mediaId: string) => {
if (directoryPath.endsWith("/inputs") && mediaId === "input-ref") return imageData;
if (directoryPath.endsWith("/generations") && mediaId === "video-ref") return videoData;
if (directoryPath.endsWith("/generations") && mediaId === "audio-ref") return audioData;
return null;
});
const workflow: WorkflowFile = {
...makeWorkflow(),
nodes: [
{
id: "imageInput-1",
type: "imageInput",
position: { x: 0, y: 0 },
data: { image: null, imageRef: "input-ref" },
},
{
id: "generateVideo-1",
type: "generateVideo",
position: { x: 200, y: 0 },
data: {
inputImages: [],
inputImageRefs: ["input-ref"],
outputVideo: null,
outputVideoRef: "video-ref",
outputVideoStorageStatus: "localized",
status: "idle",
error: null,
},
},
{
id: "generateAudio-1",
type: "generateAudio",
position: { x: 400, y: 0 },
data: {
inputPrompt: "sound",
outputAudio: null,
outputAudioRef: "audio-ref",
status: "idle",
error: null,
},
},
],
} as WorkflowFile;
const hydrated = await hydrateWorkflowMedia(workflow, workflowPath);
expect(fetch).not.toHaveBeenCalled();
expect(hydrated.nodes[0].data).toMatchObject({ image: imageData });
expect(hydrated.nodes[1].data).toMatchObject({
inputImages: [imageData],
outputVideo: videoData,
outputVideoStorageStatus: "localized",
});
expect(hydrated.nodes[2].data).toMatchObject({ outputAudio: audioData });
});
});

48
src/utils/browserFileSystem.ts

@ -52,15 +52,49 @@ export function joinBrowserFileSystemPath(basePath: string, folderName: string):
return `${basePath.replace(/\/+$/, "")}/${encodeURIComponent(folderName)}`;
}
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif"] as const;
const MEDIA_EXTENSIONS = [
"png",
"jpg",
"jpeg",
"webp",
"gif",
"mp4",
"webm",
"mov",
"mp3",
"wav",
"ogg",
"flac",
"aac",
] as const;
function extensionFromMime(mime: string | null | undefined): string {
if (mime === "image/jpeg") return "jpg";
if (mime === "image/webp") return "webp";
if (mime === "image/gif") return "gif";
if (mime === "video/webm") return "webm";
if (mime === "video/quicktime") return "mov";
if (mime?.startsWith("video/")) return "mp4";
if (mime === "audio/wav") return "wav";
if (mime === "audio/ogg") return "ogg";
if (mime === "audio/flac") return "flac";
if (mime === "audio/aac") return "aac";
if (mime?.startsWith("audio/")) return "mp3";
return "png";
}
function mimeFromExtension(extension: string): string {
if (extension === "jpg" || extension === "jpeg") return "image/jpeg";
if (extension === "mov") return "video/quicktime";
if (extension === "mp3") return "audio/mpeg";
if (extension === "wav") return "audio/wav";
if (extension === "ogg") return "audio/ogg";
if (extension === "flac") return "audio/flac";
if (extension === "aac") return "audio/aac";
if (extension === "mp4" || extension === "webm") return `video/${extension}`;
return `image/${extension}`;
}
function extensionFromDataUrl(dataUrl: string): string {
return extensionFromMime(dataUrl.match(/^data:([^;]+);base64,/)?.[1]);
}
@ -75,12 +109,16 @@ async function imageSourceToBlob(src: string): Promise<Blob> {
return response.blob();
}
async function fileToDataUrl(file: File): Promise<string> {
async function fileToDataUrl(file: File, fallbackMime?: string): Promise<string> {
const blob = file.type || !fallbackMime
? file
: file.slice(0, file.size, fallbackMime);
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
reader.readAsDataURL(blob);
});
}
@ -154,10 +192,10 @@ export async function loadBrowserGenerationFile(
const directoryHandle = await resolveDirectoryHandle(directoryPath, false);
await ensurePermission(directoryHandle, "readwrite");
for (const extension of IMAGE_EXTENSIONS) {
for (const extension of MEDIA_EXTENSIONS) {
try {
const fileHandle = await directoryHandle.getFileHandle(`${imageId}.${extension}`);
return fileToDataUrl(await fileHandle.getFile());
return fileToDataUrl(await fileHandle.getFile(), mimeFromExtension(extension));
} catch {
continue;
}

54
src/utils/mediaStorage.ts

@ -1,6 +1,12 @@
import { WorkflowNode, WorkflowNodeData } from "@/types";
import { WorkflowFile } from "@/store/workflowStore";
import crypto from "crypto";
import {
isBrowserFileSystemPath,
joinBrowserFileSystemPath,
loadBrowserGenerationFile,
writeBrowserGenerationFile,
} from "./browserFileSystem";
/**
* Fetch with timeout support using AbortController
@ -672,6 +678,16 @@ async function saveImageAndGetId(
const imageId = existingId || generateImageId();
const savePromise = (async () => {
if (isBrowserFileSystemPath(workflowPath)) {
await writeBrowserGenerationFile(
joinBrowserFileSystemPath(workflowPath, folder),
imageId,
imageData
);
savedImageIds.set(hash, imageId);
return imageId;
}
const response = await fetchWithTimeout(
"/api/workflow-images",
{
@ -738,6 +754,16 @@ async function saveVideoAndGetRef(
const videoId = existingId || generateMediaId("vid");
const savePromise = (async () => {
if (isBrowserFileSystemPath(workflowPath)) {
await writeBrowserGenerationFile(
joinBrowserFileSystemPath(workflowPath, "generations"),
videoId,
videoData
);
savedMediaIds.set(hash, videoId);
return videoId;
}
const response = await fetchWithTimeout(
"/api/save-generation",
{
@ -806,6 +832,16 @@ async function saveAudioAndGetRef(
const audioId = existingId || generateMediaId("aud");
const savePromise = (async () => {
if (isBrowserFileSystemPath(workflowPath)) {
await writeBrowserGenerationFile(
joinBrowserFileSystemPath(workflowPath, "generations"),
audioId,
audioData
);
savedMediaIds.set(hash, audioId);
return audioId;
}
const response = await fetchWithTimeout(
"/api/save-generation",
{
@ -1242,6 +1278,24 @@ async function loadMediaById(
return loadedMedia.get(mediaId)!;
}
if (isBrowserFileSystemPath(workflowPath)) {
const folderNames = mediaType === "image" ? ["inputs", "generations"] : ["generations"];
for (const folderName of folderNames) {
const mediaData = await loadBrowserGenerationFile(
joinBrowserFileSystemPath(workflowPath, folderName),
mediaId
);
if (mediaData) {
loadedMedia.set(mediaId, mediaData);
return mediaData;
}
}
console.log(`${mediaType} not found: ${mediaId}`);
return "";
}
let response: Response;
if (mediaType === "image") {

Loading…
Cancel
Save