diff --git a/src/components/__tests__/GenerationComposer.test.tsx b/src/components/__tests__/GenerationComposer.test.tsx index 622912d7..44691cc7 100644 --- a/src/components/__tests__/GenerationComposer.test.tsx +++ b/src/components/__tests__/GenerationComposer.test.tsx @@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import type { ReactNode } from "react"; import { GenerationComposer } from "@/components/composer/GenerationComposer"; +import { clearPopiserverTaskPriceCacheForTests } from "@/hooks/usePopiserverTaskPrice"; import { useWorkflowStore } from "@/store/workflowStore"; import type { EaseCurveNodeData, @@ -321,6 +322,7 @@ function videoEdge(source: string, target: string, index: number, targetHandle = describe("GenerationComposer", () => { beforeEach(() => { vi.clearAllMocks(); + clearPopiserverTaskPriceCacheForTests(); useWorkflowStore.setState({ nodes: [], edges: [], @@ -673,6 +675,7 @@ describe("GenerationComposer", () => { nodes: [ imageNode("img-1", true, { imageCount: 4, + parameterSchema: [], selectedModel: { provider: "popiserver", modelId: "32", @@ -714,6 +717,71 @@ describe("GenerationComposer", () => { }); }); + it("reuses an in-flight PopiServer task price request across remounts", async () => { + let resolveTaskPrice: (response: Response) => void = () => undefined; + const taskPriceResponse = new Promise((resolve) => { + resolveTaskPrice = resolve; + }); + const fetchMock = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith("/api/points/calculate-task-price")) { + return taskPriceResponse; + } + if (url.startsWith("/api/models")) { + return Promise.resolve(new Response(JSON.stringify({ success: true, models: [] }), { status: 200 })); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + vi.stubGlobal("fetch", fetchMock); + useWorkflowStore.setState({ + nodes: [ + imageNode("img-1", true, { + imageCount: 4, + parameterSchema: [], + selectedModel: { + provider: "popiserver", + modelId: "32", + displayName: "Popi Image", + capabilities: ["text-to-image", "image-to-image"], + metadata: { + aiModelId: 32, + aiModelCode: "gpt-image-2-all", + aiModelCodeAlias: "gpt-image-2-all", + type: 1, + subType: 102, + billingMethod: 1, + }, + }, + }), + ], + }); + + const firstRender = render(); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + firstRender.unmount(); + render(); + + await waitFor(() => { + expect(screen.getByTestId("node-inline-composer")).toBeInTheDocument(); + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + + resolveTaskPrice(new Response(JSON.stringify({ + success: true, + data: { + taskPrice: 36, + }, + }), { status: 200 })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "36 points" })).toBeInTheDocument(); + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it("renders gateway image dimension options by schema name", () => { useWorkflowStore.setState({ nodes: [ @@ -830,6 +898,7 @@ describe("GenerationComposer", () => { aiModelCodeAlias: "seedance-video-pro", type: 2, subType: 203, + billingMethod: 1, }, }, }), @@ -856,7 +925,13 @@ describe("GenerationComposer", () => { }); it("does not show PopiServer points when the model cannot be priced", async () => { - const fetchMock = vi.fn(); + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith("/api/models")) { + return new Response(JSON.stringify({ success: true, models: [] }), { status: 200 }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); vi.stubGlobal("fetch", fetchMock); useWorkflowStore.setState({ nodes: [ @@ -874,7 +949,7 @@ describe("GenerationComposer", () => { render(); expect(screen.queryByRole("button", { name: /points/ })).not.toBeInTheDocument(); - expect(fetchMock).not.toHaveBeenCalled(); + expect(fetchMock.mock.calls.some(([input]) => String(input).startsWith("/api/points/calculate-task-price"))).toBe(false); }); it("hydrates NewApiWG point cost for stored models without pricing", async () => { diff --git a/src/components/composer/GenerationComposer.tsx b/src/components/composer/GenerationComposer.tsx index a0001538..c62f9e4d 100644 --- a/src/components/composer/GenerationComposer.tsx +++ b/src/components/composer/GenerationComposer.tsx @@ -41,6 +41,7 @@ import { isPopiProviderMode } from "@/lib/providerMode"; import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog"; import type { ModelParameter, ProviderModel } from "@/lib/providers/types"; import { useI18n } from "@/i18n"; +import { usePopiserverTaskPrice } from "@/hooks/usePopiserverTaskPrice"; import { DEFAULT_NEWAPIWG_LLM_MODEL_ID, LLM_MODELS } from "@/lib/llmModels"; import { DEFAULT_VIDEO_DURATION_SECONDS, @@ -99,13 +100,6 @@ type PointsEstimateResponse = { }; }; -type TaskPriceResponse = { - success?: boolean; - data?: { - taskPrice?: unknown; - }; -}; - type SeedanceVideoEstimatePayload = { modelName: string; estimationType: "seedance_video"; @@ -503,11 +497,6 @@ function getEstimatedPointAmount(response: PointsEstimateResponse): number | nul return finiteNumberFromUnknown(response.data?.estimated_points); } -function getTaskPricePointAmount(response: TaskPriceResponse): number | null { - const amount = finiteNumberFromUnknown(response.data?.taskPrice); - return amount !== null && amount > 0 ? amount : null; -} - function getNodeVideoDurationSeconds(node: WorkflowNode | undefined): number { if (!node) return 0; const data = node.data as Record; @@ -827,7 +816,6 @@ export function GenerationComposer() { const [isComposerCollapsed, setIsComposerCollapsed] = useState(false); const [referencePreviewIndex, setReferencePreviewIndex] = useState(null); const [seedanceEstimatedPointAmount, setSeedanceEstimatedPointAmount] = useState(null); - const [popiserverEstimatedPointAmount, setPopiserverEstimatedPointAmount] = useState(null); const referenceImageInputRef = useRef(null); const contextRef = useRef(context); @@ -835,7 +823,6 @@ export function GenerationComposer() { const dirtyFieldsRef = useRef(dirtyFields); const newApiWGPricingCacheRef = useRef>(new Map()); const seedanceEstimateCacheRef = useRef>(new Map()); - const popiserverTaskPriceCacheRef = useRef>(new Map()); useEffect(() => { draftRef.current = draft; @@ -980,9 +967,7 @@ export function GenerationComposer() { modelSchemaReadyForEstimate, ]); - const popiserverTaskPriceKey = popiserverTaskPricePayload - ? JSON.stringify(popiserverTaskPricePayload) - : null; + const popiserverEstimatedPointAmount = usePopiserverTaskPrice(popiserverTaskPricePayload); useEffect(() => { const selectedModel = draft.selectedModel; @@ -1204,49 +1189,6 @@ export function GenerationComposer() { }; }, [seedanceEstimateKey, seedanceEstimatePayload]); - useEffect(() => { - if (!popiserverTaskPricePayload || !popiserverTaskPriceKey) { - setPopiserverEstimatedPointAmount(null); - return; - } - - const cachedAmount = popiserverTaskPriceCacheRef.current.get(popiserverTaskPriceKey); - if (cachedAmount !== undefined) { - setPopiserverEstimatedPointAmount(cachedAmount); - return; - } - - let cancelled = false; - setPopiserverEstimatedPointAmount(null); - - const loadTaskPrice = async () => { - try { - const response = await fetch("/api/points/calculate-task-price", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(popiserverTaskPricePayload), - }); - if (!response.ok) return; - - const data = (await response.json()) as TaskPriceResponse; - const taskPrice = getTaskPricePointAmount(data); - if (taskPrice === null) return; - - popiserverTaskPriceCacheRef.current.set(popiserverTaskPriceKey, taskPrice); - if (!cancelled) setPopiserverEstimatedPointAmount(taskPrice); - } catch { - // Estimation failures should not block generation; the run request still performs the real charge. - } - }; - - void loadTaskPrice(); - return () => { - cancelled = true; - }; - }, [popiserverTaskPriceKey, popiserverTaskPricePayload]); - const isVideoStitchNode = context.mode === "process-node" && context.node?.type === "videoStitch"; const isEaseCurveNode = context.mode === "process-node" && context.node?.type === "easeCurve"; const isProcessNode = isVideoStitchNode || isEaseCurveNode; diff --git a/src/components/nodes/ControlPanel.tsx b/src/components/nodes/ControlPanel.tsx index c0e2f837..e0edff94 100644 --- a/src/components/nodes/ControlPanel.tsx +++ b/src/components/nodes/ControlPanel.tsx @@ -35,10 +35,7 @@ import { // List of node types that have configurable parameters const CONFIGURABLE_NODE_TYPES: NodeType[] = [ - "nanoBanana", - "generateVideo", "generate3d", - "generateAudio", "llmGenerate", "easeCurve", "conditionalSwitch", diff --git a/src/hooks/usePopiserverTaskPrice.ts b/src/hooks/usePopiserverTaskPrice.ts new file mode 100644 index 00000000..12c9fb85 --- /dev/null +++ b/src/hooks/usePopiserverTaskPrice.ts @@ -0,0 +1,90 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import type { PopiserverTaskPricePayload } from "@/utils/generationConfig"; + +type TaskPriceResponse = { + success?: boolean; + data?: { + taskPrice?: unknown; + }; +}; + +const taskPriceCache = new Map(); +const taskPriceInflight = new Map>(); + +function finiteNumberFromUnknown(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = Number.parseFloat(value.trim()); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function getTaskPricePointAmount(response: TaskPriceResponse): number | null { + const amount = finiteNumberFromUnknown(response.data?.taskPrice); + return amount !== null && amount > 0 ? amount : null; +} + +export function usePopiserverTaskPrice(payload: PopiserverTaskPricePayload | null): number | null { + const [pointAmount, setPointAmount] = useState(null); + const body = useMemo(() => payload ? JSON.stringify(payload) : null, [payload]); + + useEffect(() => { + if (!body) { + setPointAmount(null); + return; + } + + const cachedAmount = taskPriceCache.get(body); + if (cachedAmount !== undefined) { + setPointAmount(cachedAmount); + return; + } + + let cancelled = false; + setPointAmount(null); + + let request = taskPriceInflight.get(body); + if (!request) { + request = fetch("/api/points/calculate-task-price", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body, + }) + .then(async (response) => { + if (!response.ok) return null; + + const data = (await response.json()) as TaskPriceResponse; + const taskPrice = getTaskPricePointAmount(data); + if (taskPrice !== null) { + taskPriceCache.set(body, taskPrice); + } + return taskPrice; + }) + .catch(() => null) + .finally(() => { + taskPriceInflight.delete(body); + }); + taskPriceInflight.set(body, request); + } + + void request.then((taskPrice) => { + if (!cancelled) setPointAmount(taskPrice); + }); + + return () => { + cancelled = true; + }; + }, [body]); + + return pointAmount; +} + +export function clearPopiserverTaskPriceCacheForTests() { + taskPriceCache.clear(); + taskPriceInflight.clear(); +}