From 4f01833d3b9b1fcf6bd05cd04764eff278e28713 Mon Sep 17 00:00:00 2001 From: yun Date: Thu, 2 Jul 2026 16:15:03 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=82=E6=95=B0=E7=BB=9F=E4=B8=80=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/_gatewayLogging.ts | 3 + .../providers/__tests__/popiserver.test.ts | 88 +++++++++++++++++++ src/app/api/generate/providers/popiserver.ts | 25 +++++- src/app/api/generate/task/create/route.ts | 2 + .../__tests__/GenerationComposer.test.tsx | 31 +++---- .../composer/GenerationComposer.tsx | 25 ++++-- .../composer/NodeMultiAnglePanel.tsx | 29 +++++- src/lib/popiartTask/runTask.ts | 2 + src/lib/providers/types.ts | 2 + .../__tests__/generateVideoExecutor.test.ts | 23 +++-- src/store/execution/generate3dExecutor.ts | 9 +- src/store/execution/generateAudioExecutor.ts | 9 +- src/store/execution/generateVideoExecutor.ts | 36 +++----- src/store/execution/nanoBananaExecutor.ts | 9 +- .../execution/popiMediaGenerationExecutor.ts | 11 +-- src/store/execution/popiartTaskClient.ts | 1 + src/utils/__tests__/generationConfig.test.ts | 40 +++++++++ src/utils/generationConfig.ts | 17 +++- src/utils/popiserverTaskPayload.ts | 4 +- 19 files changed, 292 insertions(+), 74 deletions(-) diff --git a/src/app/api/_gatewayLogging.ts b/src/app/api/_gatewayLogging.ts index 1fdafab6..ca371cd4 100644 --- a/src/app/api/_gatewayLogging.ts +++ b/src/app/api/_gatewayLogging.ts @@ -4,6 +4,7 @@ import { createHash } from "crypto"; const SENSITIVE_KEY_PATTERN = /authorization|(^|[-_])token($|[-_])|token$|api[-_]?key|apikey|secret|password/i; const MAX_STRING_LENGTH = 500; const VERBOSE_GATEWAY_ORIGIN = "https://wwwtest.popi.art"; +const IGNORED_GATEWAY_PATHS = new Set(["/api_client/canvas/update"]); type JsonLike = | null @@ -148,6 +149,7 @@ export function logGatewayRequest( context?: Record ): void { const { interfaceName, origin, queryParams, isVerboseGateway } = getUrlParts(url); + if (IGNORED_GATEWAY_PATHS.has(interfaceName)) return; const headers = sanitizeHeaders(init.headers); const bodyParams = sanitizeBody(init.body); const passedParams = { @@ -178,6 +180,7 @@ export async function logGatewayResponse( startedAt?: number ): Promise { const { interfaceName, origin, isVerboseGateway } = getUrlParts(url); + if (IGNORED_GATEWAY_PATHS.has(interfaceName)) return; const responseBody = await sanitizeResponseBody(response); const durationMs = startedAt ? Date.now() - startedAt : undefined; diff --git a/src/app/api/generate/providers/__tests__/popiserver.test.ts b/src/app/api/generate/providers/__tests__/popiserver.test.ts index a787ede1..9fed0e2e 100644 --- a/src/app/api/generate/providers/__tests__/popiserver.test.ts +++ b/src/app/api/generate/providers/__tests__/popiserver.test.ts @@ -75,6 +75,7 @@ describe("popiserver generation provider", () => { afterEach(() => { global.fetch = originalFetch; process.env.POPIART_API_BASE_URL = originalPopiartApiBaseUrl; + vi.unstubAllEnvs(); }); it("submits a unified async task create request", async () => { @@ -111,6 +112,24 @@ describe("popiserver generation provider", () => { expect(body.aspectRatio).toBeUndefined(); }); + it("returns the prepared PopiServer task request when gateway skip is enabled", async () => { + vi.stubEnv("SKIP_GENERATION_GATEWAY", "true"); + + let error: unknown; + try { + await submitPopiTask(makeRequest(), "login-token", makeInput()); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(Error); + const message = error instanceof Error ? error.message : ""; + expect(message).toContain("Gateway request preview"); + expect(message).toContain("/api_client/anime/task/create"); + expect(message).toContain('"aiModelCode": "viduq2-pro"'); + expect(vi.mocked(global.fetch)).not.toHaveBeenCalled(); + }); + it("uses top-level task routing fields and drops legacy parameter routing fields", async () => { vi.mocked(global.fetch).mockResolvedValue({ ok: true, @@ -142,6 +161,48 @@ describe("popiserver generation provider", () => { }); }); + it("flattens model parameters and keeps extra task params nested in PopiServer body", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + status: "0000", + message: "ok", + data: { id: 1197 }, + }), + } as Response); + + const input = makeInput(); + input.parameters = { + ratio: "16:9", + resolution: "std", + duration: 5, + }; + input.extraTaskParams = { + generate_audio: true, + }; + input.referenceSubjectList = [ + { id: 1, type: "image", url: "https://example.com/1.png", name: "图1" }, + ]; + + await submitPopiTask(makeRequest(), "login-token", input); + + const [, init] = vi.mocked(global.fetch).mock.calls[0]; + const body = JSON.parse(String(init?.body)); + expect(body).toMatchObject({ + ratio: "16:9", + resolution: "std", + duration: 5, + extraTaskParams: { + generate_audio: true, + }, + referenceSubjectList: [ + { id: 1, type: "image", url: "https://example.com/1.png", name: "图1" }, + { id: 2, type: "image", url: "https://example.com/2.png", name: "图2" }, + ], + }); + expect(body.generate_audio).toBeUndefined(); + }); + it("collects all task ids from batched task create responses", async () => { vi.mocked(global.fetch).mockResolvedValue({ ok: true, @@ -828,6 +889,33 @@ describe("popiserver generation provider", () => { expect(result.status).toBe("processing"); }); + it("returns UserErrorTipMsg from failed PopiServer tasks", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + status: "0000", + message: "ok", + data: { + list: [ + { + id: 1193, + status: 3, + ErrorMsg: "raw provider error", + UserErrorTipMsg: "Please revise the prompt and try again.", + }, + ], + }, + }), + } as Response); + + const result = await checkPopiTaskOnce(makeRequest(), "login-token", "1193", "image"); + + expect(result).toEqual({ + status: "failed", + error: "Please revise the prompt and try again.", + }); + }); + it("does not include reference images from completed image tasks", async () => { vi.mocked(global.fetch).mockResolvedValue({ ok: true, diff --git a/src/app/api/generate/providers/popiserver.ts b/src/app/api/generate/providers/popiserver.ts index 8bd5d6fa..8065fc76 100644 --- a/src/app/api/generate/providers/popiserver.ts +++ b/src/app/api/generate/providers/popiserver.ts @@ -18,6 +18,7 @@ import { buildPopiTaskBody as buildSharedPopiTaskBody } from "@/utils/popiserver const POPISERVER_TASK_CREATE_PATH = "/api_client/anime/task/create"; const POPISERVER_TASK_LIST_PATH = "/api_client/anime/task/list"; const POPISERVER_MEDIA_UPLOAD_PATH = "/api_client/media/upload"; +const SKIPPED_GATEWAY_PREVIEW_MAX_LENGTH = 12000; type PopiserverResponse = { status?: string; @@ -68,7 +69,9 @@ type PopiTask = { subType?: number; status?: number; errorMsg?: string; + ErrorMsg?: string; userErrorTipMsg?: string; + UserErrorTipMsg?: string; images?: string[] | null; voice?: string | null; resultList?: PopiTaskResult[] | null; @@ -79,6 +82,16 @@ export type PopiTaskPollResult = | { status: "failed"; error: string } | { status: "completed"; output: GenerationOutput }; +function buildSkippedGatewayPreviewMessage(provider: string, request: { method: string; url: string; body: unknown }): string { + const message = skippedGenerationGatewayMessage(provider); + const preview = JSON.stringify(request, null, 2); + const visiblePreview = + preview.length > SKIPPED_GATEWAY_PREVIEW_MAX_LENGTH + ? `${preview.slice(0, SKIPPED_GATEWAY_PREVIEW_MAX_LENGTH)}\n...[truncated:${preview.length}]` + : preview; + return `${message}\n\nGateway request preview:\n${visiblePreview}`; +} + function asString(value: unknown): string | null { return typeof value === "string" && value.length > 0 ? value : null; } @@ -120,6 +133,10 @@ function getTaskList(data: PopiTaskListData | undefined): PopiTask[] { return Array.isArray(data) ? data : data.list ?? []; } +function getTaskError(task: PopiTask): string | undefined { + return task.userErrorTipMsg || task.UserErrorTipMsg || task.errorMsg || task.ErrorMsg; +} + function mediaResourceUrl(media: PopiUploadedMedia): string | null { return media.url || media.originUrl || media.path || media.originPath || null; } @@ -369,7 +386,11 @@ export async function submitPopiTask( modelName: input.model.name, } ); - throw ApiError.provider(skippedGenerationGatewayMessage("PopiServer")); + throw ApiError.provider(buildSkippedGatewayPreviewMessage("PopiServer", { + method: init.method || "POST", + url, + body, + })); } const data = await fetchPopiJson( @@ -457,7 +478,7 @@ export async function checkPopiTaskOnce( const tasks = getTaskList(body.data); if (tasks.length === 0) return { status: "processing" }; for (const task of tasks) { - const error = task.userErrorTipMsg || task.errorMsg; + const error = getTaskError(task); if (error) return { status: "failed", error }; } if (tasks.every((task) => task.status === 2 && Array.isArray(task.resultList) && task.resultList.length > 0)) { diff --git a/src/app/api/generate/task/create/route.ts b/src/app/api/generate/task/create/route.ts index ae803418..73213dc6 100644 --- a/src/app/api/generate/task/create/route.ts +++ b/src/app/api/generate/task/create/route.ts @@ -12,6 +12,7 @@ export const maxDuration = 60; type TaskCreateRequest = GenerateRequest & { selectedModel?: SelectedModel; parameters?: Record; + extraTaskParams?: Record; referenceSubjectList?: unknown; batchSize?: number; audioVoiceId?: string; @@ -56,6 +57,7 @@ export async function POST(request: NextRequest) { videos: body.videos ? [...body.videos] : [], voices: body.voices ? [...body.voices] : [], parameters: body.parameters, + extraTaskParams: body.extraTaskParams, type: body.type, subType: body.subType, referenceSubjectList: body.referenceSubjectList, diff --git a/src/components/__tests__/GenerationComposer.test.tsx b/src/components/__tests__/GenerationComposer.test.tsx index 87a7dda0..8f706281 100644 --- a/src/components/__tests__/GenerationComposer.test.tsx +++ b/src/components/__tests__/GenerationComposer.test.tsx @@ -1614,8 +1614,9 @@ describe("GenerationComposer", () => { render(); await waitFor(() => { - expect(screen.getByRole("button", { name: /36/ })).toBeInTheDocument(); + expect(screen.getByText("36 points")).toBeInTheDocument(); }); + expect(screen.getByRole("button", { name: /Generate/ })).toBeInTheDocument(); const priceCall = fetchMock.mock.calls.find(([input]) => String(input).startsWith("/api/points/quote")); expect(priceCall).toBeDefined(); expect(JSON.parse(String((priceCall?.[1] as RequestInit).body))).toMatchObject({ @@ -1626,10 +1627,6 @@ describe("GenerationComposer", () => { aiModelId: 32, aiModelCode: "gpt-image-2-all", aiModelCodeAlias: "gpt-image-2-all", - ratio: "16:9", - resolution: "2K", - width: 1280, - height: 720, batchSize: 4, }, }); @@ -1910,18 +1907,16 @@ describe("GenerationComposer", () => { render(); await waitFor(() => { - expect(screen.getByRole("button", { name: /66 (points|积分)/ })).toBeInTheDocument(); + expect(screen.getByText("66 points")).toBeInTheDocument(); }); + expect(screen.getByRole("button", { name: /Generate/ })).toBeInTheDocument(); expect(fetchMock.mock.calls.some(([input]) => String(input).startsWith("/api/points/calculate-task-price"))).toBe(false); const estimateCall = fetchMock.mock.calls.find(([input]) => String(input).startsWith("/api/points/quote")); expect(JSON.parse(String((estimateCall?.[1] as RequestInit).body))).toMatchObject({ source: "estimate", payload: { model_name: "token-image-model", - parameters: { - resolution: "2K", - aspect_ratio: "16:9", - }, + parameters: {}, }, }); }); @@ -2116,17 +2111,19 @@ describe("GenerationComposer", () => { expect(fetchMock.mock.calls.some(([input]) => String(input).startsWith("/api/points/calculate-task-price"))).toBe(false); }); - it("submits from the collapsed generate button without expanding", async () => { + it("expands from the collapsed summary without submitting", async () => { useWorkflowStore.setState({ nodes: [imageNode("img-1", true, { inputPrompt: "visible prompt" })] }); render(); - fireEvent.click(getCollapseButton()); - fireEvent.click(getGenerateButton()); - - await waitFor(() => { - expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("img-1"); + await act(async () => { + fireEvent.click(getCollapseButton()); }); - expect(queryPromptEditor("visible prompt")).not.toBeInTheDocument(); + await act(async () => { + fireEvent.click(screen.getByText("visible prompt")); + }); + + expect(useWorkflowStore.getState().regenerateNode).not.toHaveBeenCalled(); + expectPromptEditorValue("visible prompt"); }); it("uses the llm model filter and reruns the selected LLM node from the bottom composer", async () => { diff --git a/src/components/composer/GenerationComposer.tsx b/src/components/composer/GenerationComposer.tsx index f7907c6c..8c7ac456 100644 --- a/src/components/composer/GenerationComposer.tsx +++ b/src/components/composer/GenerationComposer.tsx @@ -1051,7 +1051,8 @@ function ComposerEditor({ const modelPointCostLabel = dynamicEstimatedPointAmount !== null ? t("composer.pointCost", { count: formatPointAmount(dynamicEstimatedPointAmount) }) : null; - const submitButtonLabel = isCurrentNodeRunning ? "Cancel task" : modelPointCostLabel ?? (isPriceLoading ? "Loading" : t("composer.generate")); + const submitButtonLabel = isCurrentNodeRunning ? "Cancel task" : t("composer.generate"); + const priceLabel = modelPointCostLabel ?? (isPriceLoading ? "..." : null); const hasComposerMediaHeader = isProcessNode || connectedTextItems.length > 0 || referenceMaterials.length > 0 || hasVideoSubTypes; @@ -1276,7 +1277,9 @@ function ComposerEditor({ {prompt || config.selectedModel.displayName || t("composer.describeContent")} - {submitButtonLabel} + + {isCurrentNodeRunning ? submitButtonLabel : priceLabel ?? submitButtonLabel} + ) : ( <> @@ -1376,13 +1379,23 @@ function ComposerEditor({ )}
+ {priceLabel ? ( +
+ + {priceLabel} +
+ ) : null}