Compare commits

...

2 Commits

  1. 31
      src/app/api/generate/providers/__tests__/popiserver.test.ts
  2. 11
      src/app/api/generate/providers/popiserver.ts
  3. 4
      src/app/api/llm/__tests__/route.test.ts
  4. 61
      src/store/execution/__tests__/llmGenerateExecutor.test.ts
  5. 5
      src/store/execution/__tests__/nanoBananaExecutor.test.ts
  6. 14
      src/store/execution/generateVideoExecutor.ts
  7. 14
      src/store/execution/llmGenerateExecutor.ts
  8. 18
      src/store/execution/nanoBananaExecutor.ts
  9. 39
      src/utils/temporaryImageUpload.ts

31
src/app/api/generate/providers/__tests__/popiserver.test.ts

@ -425,4 +425,35 @@ describe("popiserver generation provider", () => {
]);
}
});
it("does not include reference images from completed image tasks", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: () => Promise.resolve({
status: "0000",
message: "ok",
data: {
list: [
{
id: 1192,
status: 2,
images: ["https://example.com/reference.jpg"],
resultList: [
{ images: ["https://example.com/generated.png"] },
],
},
],
},
}),
} as Response);
const result = await checkPopiTaskOnce(makeRequest(), "login-token", "1192", "image");
expect(result.status).toBe("completed");
if (result.status === "completed") {
expect(result.output.outputs).toEqual([
{ type: "image", data: "", url: "https://example.com/generated.png" },
]);
}
});
});

11
src/app/api/generate/providers/popiserver.ts

@ -557,11 +557,14 @@ function extractOutputFromTask(task: PopiTask, mediaType: string): GenerationOut
? { success: true, outputs: [{ type: "audio", data: "", url }] }
: { success: false, error: "PopiServer audio task completed without output" };
}
const resultImages = collectStrings(
Array.isArray(task.resultList) ? task.resultList.map((result) => result.images) : []
);
// task.images is the original input/reference image list in Popiserver task
// payloads. Only use it as a defensive fallback for older responses that do
// not put generated images under resultList.
const images = [
...new Set(collectStrings([
...(Array.isArray(task.resultList) ? task.resultList.map((result) => result.images) : []),
task.images,
])),
...new Set(resultImages.length > 0 ? resultImages : collectStrings(task.images)),
];
return images.length > 0
? { success: true, outputs: images.map((url) => ({ type: "image", data: "", url })) }

4
src/app/api/llm/__tests__/route.test.ts

@ -478,7 +478,7 @@ describe("/api/llm route", () => {
prompt: "请描述这张图",
provider: "popiserver",
model: "kimi-k2.6",
images: ["data:image/png;base64,abc"],
images: ["https://static.popi.art/uploads/input.png"],
});
const response = await POST(request);
@ -498,7 +498,7 @@ describe("/api/llm route", () => {
role: "user",
content: [
{ type: "text", text: "请描述这张图" },
{ type: "image_url", image_url: { url: "data:image/png;base64,abc" } },
{ type: "image_url", image_url: { url: "https://static.popi.art/uploads/input.png" } },
],
},
],

61
src/store/execution/__tests__/llmGenerateExecutor.test.ts

@ -188,6 +188,67 @@ describe("executeLlmGenerate", () => {
expect((loadingCall![1] as Record<string, unknown>).inputVideos).toEqual(["data:video/mp4;base64,vid1"]);
});
it("uploads Popiserver LLM media before calling /api/llm", async () => {
const node = makeNode({
selectedModel: {
provider: "popiserver",
modelId: "31",
displayName: "Kimi K2.6",
metadata: { aiModelId: 31 },
},
});
const ctx = makeCtx(node, {
providerSettings: {
providers: {
...defaultProviderSettings.providers,
popiserver: { enabled: true },
},
} as any,
getConnectedInputs: vi.fn().mockReturnValue({
images: ["data:image/png;base64,img1"],
videos: [],
audio: [],
text: "describe this",
dynamicInputs: {},
easeCurve: null,
}),
});
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, url: "https://static.popi.art/uploads/input.png" }),
});
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, text: "description" }),
});
const originalMode = process.env.NEXT_PUBLIC_PROVIDER_MODE;
process.env.NEXT_PUBLIC_PROVIDER_MODE = "popi";
try {
await executeLlmGenerate(ctx);
} finally {
if (originalMode === undefined) {
delete process.env.NEXT_PUBLIC_PROVIDER_MODE;
} else {
process.env.NEXT_PUBLIC_PROVIDER_MODE = originalMode;
}
}
expect(mockFetch).toHaveBeenNthCalledWith(
1,
"/api/popi/media/upload",
expect.objectContaining({ method: "POST" })
);
expect(mockFetch).toHaveBeenNthCalledWith(
2,
"/api/llm",
expect.objectContaining({ method: "POST" })
);
const body = JSON.parse(mockFetch.mock.calls[1][1].body);
expect(body.provider).toBe("popiserver");
expect(body.images).toEqual(["https://static.popi.art/uploads/input.png"]);
});
it("should not include images field when none connected", async () => {
const node = makeNode();
mockFetch.mockResolvedValueOnce({

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

@ -232,6 +232,11 @@ describe("executeNanoBanana", () => {
expect(body.prompt).toBe("config prompt");
expect(body.aspectRatio).toBe("9:16");
expect(body.resolution).toBe("4K");
expect(body.model).toBe("32");
expect(body.selectedModel).toMatchObject({
provider: "popiserver",
modelId: "32",
});
expect(body.parameters).toMatchObject({
aspectRatio: "9:16",
aspect_ratio: "9:16",

14
src/store/execution/generateVideoExecutor.ts

@ -28,8 +28,7 @@ import {
import { readVideoGenerationConfig, removeFirstClassGenerationParameters } from "@/utils/generationConfig";
import {
cleanupTemporaryImages,
uploadPopiserverDynamicInputsForGeneration,
uploadPopiserverImagesForGeneration,
uploadPopiserverGenerationInputsForGeneration,
uploadTemporaryDynamicInputsForGeneration,
uploadTemporaryImagesForGeneration,
} from "@/utils/temporaryImageUpload";
@ -258,13 +257,16 @@ export async function executeGenerateVideo(
let temporaryMediaIds: string[] = [];
try {
const preparedImages = provider === "popiserver"
? await uploadPopiserverImagesForGeneration(images)
const preparedPopiserverInputs = provider === "popiserver"
? await uploadPopiserverGenerationInputsForGeneration(images, requestDynamicInputs)
: null;
const preparedImages = preparedPopiserverInputs
? { images: preparedPopiserverInputs.images, cleanupIds: preparedPopiserverInputs.cleanupIds }
: provider === "newapiwg"
? await uploadTemporaryImagesForGeneration(images)
: { images, cleanupIds: [] };
const preparedDynamicInputs = provider === "popiserver"
? await uploadPopiserverDynamicInputsForGeneration(requestDynamicInputs)
const preparedDynamicInputs = preparedPopiserverInputs
? { dynamicInputs: preparedPopiserverInputs.dynamicInputs, cleanupIds: preparedPopiserverInputs.cleanupIds }
: provider === "newapiwg"
? await uploadTemporaryDynamicInputsForGeneration(requestDynamicInputs)
: { dynamicInputs: requestDynamicInputs, cleanupIds: [] };

14
src/store/execution/llmGenerateExecutor.ts

@ -17,6 +17,7 @@ import { isPopiProviderMode } from "@/lib/providerMode";
import { runWithFallback } from "./runWithFallback";
import type { NodeExecutionContext } from "./types";
import { mergeConfiguredParameters } from "./generationParameters";
import { uploadPopiserverMediaForGeneration } from "@/utils/temporaryImageUpload";
export interface LlmGenerateOptions {
/** When true, falls back to stored inputImages/inputPrompt if no connections provide them. */
@ -113,13 +114,22 @@ export async function executeLlmGenerate(
});
try {
const preparedMedia = llmProvider === "popiserver"
? await uploadPopiserverMediaForGeneration([...images, ...videos])
: { media: [...images, ...videos], cleanupIds: [] };
const preparedImages = images.length > 0
? preparedMedia.media.slice(0, images.length)
: images;
const preparedVideos = videos.length > 0
? preparedMedia.media.slice(images.length, images.length + videos.length)
: videos;
const response = await fetch("/api/llm", {
method: "POST",
headers,
body: JSON.stringify({
prompt: finalText,
...(images.length > 0 && { images }),
...(videos.length > 0 && { videos }),
...(preparedImages.length > 0 && { images: preparedImages }),
...(preparedVideos.length > 0 && { videos: preparedVideos }),
provider: llmProvider,
model: llmModel,
...(aiModelId !== undefined && { aiModelId }),

18
src/store/execution/nanoBananaExecutor.ts

@ -29,8 +29,7 @@ import {
import { isHttpMediaUrl } from "@/utils/mediaResolver";
import {
cleanupTemporaryImages,
uploadPopiserverDynamicInputsForGeneration,
uploadPopiserverImagesForGeneration,
uploadPopiserverGenerationInputsForGeneration,
uploadTemporaryDynamicInputsForGeneration,
uploadTemporaryImagesForGeneration,
} from "@/utils/temporaryImageUpload";
@ -159,13 +158,16 @@ export async function executeNanoBanana(
let temporaryImageIds: string[] = [];
try {
const preparedImages = provider === "popiserver"
? await uploadPopiserverImagesForGeneration(images)
const preparedPopiserverInputs = provider === "popiserver"
? await uploadPopiserverGenerationInputsForGeneration(images, sanitizedDynamicInputs)
: null;
const preparedImages = preparedPopiserverInputs
? { images: preparedPopiserverInputs.images, cleanupIds: preparedPopiserverInputs.cleanupIds }
: provider === "newapiwg"
? await uploadTemporaryImagesForGeneration(images)
: { images, cleanupIds: [] };
const preparedDynamicInputs = provider === "popiserver"
? await uploadPopiserverDynamicInputsForGeneration(sanitizedDynamicInputs)
const preparedDynamicInputs = preparedPopiserverInputs
? { dynamicInputs: preparedPopiserverInputs.dynamicInputs, cleanupIds: preparedPopiserverInputs.cleanupIds }
: provider === "newapiwg"
? await uploadTemporaryDynamicInputsForGeneration(sanitizedDynamicInputs)
: { dynamicInputs: sanitizedDynamicInputs, cleanupIds: [] };
@ -196,12 +198,13 @@ export async function executeNanoBanana(
batchSize: requestedImageCount,
}
: mergeConfiguredParametersOverride(normalizedBaseParameters, configuredParameters);
const usedModelId = modelToUse.modelId || nodeData.model;
const requestPayload = {
images: preparedImages.images,
prompt: finalPrompt,
aspectRatio: (parametersOverride?.aspectRatio as string) ?? nodeConfig.aspectRatio,
resolution: (parametersOverride?.resolution as string) ?? nodeConfig.resolution,
model: nodeData.model,
model: usedModelId,
useGoogleSearch: (parametersOverride?.useGoogleSearch as boolean) ?? nodeData.useGoogleSearch,
useImageSearch: (parametersOverride?.useImageSearch as boolean) ?? nodeData.useImageSearch,
selectedModel: modelToUse,
@ -219,7 +222,6 @@ export async function executeNanoBanana(
const usePopiserverBatch = provider === "popiserver" && requestedImageCount > 1;
const requestCount = usePopiserverBatch ? 1 : requestedImageCount;
const usedModelId = modelToUse.modelId || nodeData.model;
const usedModelDisplayName = modelToUse.displayName || usedModelId;
const batchTimestamp = Date.now();
const generatedItems: Array<{

39
src/utils/temporaryImageUpload.ts

@ -15,6 +15,12 @@ type UploadedTemporaryDynamicInputs = {
cleanupIds: string[];
};
type UploadedPopiserverGenerationInputs = {
images: string[];
dynamicInputs: Record<string, string | string[]>;
cleanupIds: string[];
};
function dataUrlMimeType(dataUrl: string): string | null {
return dataUrl.match(/^data:([^;]+);base64,/)?.[1] ?? null;
}
@ -242,6 +248,39 @@ export async function uploadPopiserverDynamicInputsForGeneration(
return { dynamicInputs: Object.fromEntries(entries), cleanupIds: [] };
}
export async function uploadPopiserverGenerationInputsForGeneration(
images: string[],
dynamicInputs: Record<string, string | string[]>
): Promise<UploadedPopiserverGenerationInputs> {
const uploadCache = new Map<string, Promise<string>>();
const prepareMedia = async (media: string): Promise<string> => {
if (!shouldUploadPopiserverMedia(media)) return media;
let upload = uploadCache.get(media);
if (!upload) {
upload = uploadPopiserverMedia(media);
uploadCache.set(media, upload);
}
return upload;
};
const preparedImages = await Promise.all(images.map(prepareMedia));
const entries = await Promise.all(
Object.entries(dynamicInputs).map(async ([key, value]) => {
if (typeof value === "string") {
return [key, await prepareMedia(value)] as const;
}
return [key, await Promise.all(value.map(prepareMedia))] as const;
})
);
return {
images: preparedImages,
dynamicInputs: Object.fromEntries(entries),
cleanupIds: [],
};
}
export async function cleanupTemporaryImages(ids: string[]): Promise<void> {
if (ids.length === 0) return;

Loading…
Cancel
Save