From d4f9e2e73e296bd40d993214469ed2ad5b0b7887 Mon Sep 17 00:00:00 2001 From: jiajia Date: Thu, 28 May 2026 23:26:53 +0800 Subject: [PATCH] Stabilize merged Popi model pricing and schema handling The provider branch makes model defaults and Popi metadata resolution more asynchronous, while the local UI keeps duplicate node-body settings hidden. This keeps pricing available when metadata is already complete, avoids refreshing placeholder Popi selections, preserves explicit Popi schema hydration, and fixes strict TypeScript narrowing exposed by the merged media upload route. Constraint: Preserve hidden node-body settings while keeping Popi provider schema hydration available. Rejected: Re-enable inline node settings for all external providers | would reintroduce duplicated controls the user asked to hide. Confidence: high Scope-risk: moderate Directive: Do not fetch per-model schemas for hidden non-Popi node settings unless an explicit modelSchemaRequestId requests it. Tested: npm run test:run -- src/components/__tests__/PromptNode.test.tsx src/components/__tests__/WorkflowCanvas.test.tsx src/components/modals/__tests__/MarkdownEditorModal.test.tsx src/components/__tests__/FloatingNodeHeader.test.tsx src/components/__tests__/LLMGenerateNode.test.tsx src/components/__tests__/GenerateImageNode.test.tsx src/components/__tests__/GenerateVideoNode.test.tsx src/components/__tests__/GenerationComposer.test.tsx src/app/api/generate/providers/__tests__/popiserver.test.ts src/app/api/__tests__/popiserverModels.test.ts --reporter=dot Tested: npm run build Tested: git diff --check Tested: rg -n '^(<<<<<<<|=======|>>>>>>>)' src Not-tested: npm run lint cannot run because the existing Next 16 script maps next lint to a missing ./lint project directory. --- src/app/api/popi/media/upload/route.ts | 11 ++++- .../__tests__/WorkflowCanvas.test.tsx | 40 +++++++++++++------ .../composer/GenerationComposer.tsx | 17 ++++++-- src/components/nodes/GenerateImageNode.tsx | 7 +++- src/utils/modelSchemaDefaults.ts | 2 +- 5 files changed, 58 insertions(+), 19 deletions(-) diff --git a/src/app/api/popi/media/upload/route.ts b/src/app/api/popi/media/upload/route.ts index dc0ced94..fe6f30c4 100644 --- a/src/app/api/popi/media/upload/route.ts +++ b/src/app/api/popi/media/upload/route.ts @@ -65,6 +65,15 @@ function mediaResourceUrl(media: PopiMedia): string | null { return media.url || media.originUrl || media.path || media.originPath || null; } +function isUploadedMedia(value: PopiMediaUploadData): value is PopiMedia { + return ( + "url" in value || + "originUrl" in value || + "path" in value || + "originPath" in value + ); +} + function uploadedMediaUrls(data: PopiMediaUploadData | undefined): string[] { if (!data) return []; if (Array.isArray(data)) { @@ -81,7 +90,7 @@ function uploadedMediaUrls(data: PopiMediaUploadData | undefined): string[] { if (list) { return list.map(mediaResourceUrl).filter((url): url is string => Boolean(url)); } - const single = mediaResourceUrl(data); + const single = isUploadedMedia(data) ? mediaResourceUrl(data) : null; return single ? [single] : []; } diff --git a/src/components/__tests__/WorkflowCanvas.test.tsx b/src/components/__tests__/WorkflowCanvas.test.tsx index 4486ddb0..668e56dc 100644 --- a/src/components/__tests__/WorkflowCanvas.test.tsx +++ b/src/components/__tests__/WorkflowCanvas.test.tsx @@ -480,7 +480,9 @@ describe("WorkflowCanvas", () => { fireEvent.click(screen.getByText("Create Generate Image")); - expect(mockAddNode).toHaveBeenCalledWith("nanoBanana", { x: 560, y: 100 }); + await waitFor(() => { + expect(mockAddNode).toHaveBeenCalledWith("nanoBanana", { x: 560, y: 100 }, undefined); + }); expect(mockOnConnect).toHaveBeenCalledWith({ source: "image-1", sourceHandle: "image", @@ -778,7 +780,7 @@ describe("WorkflowCanvas", () => { expect(screen.queryByText("Drop to create node")).not.toBeInTheDocument(); }); - it("should call addNode when node type is dropped on canvas", () => { + it("should call addNode when node type is dropped on canvas", async () => { render( @@ -803,7 +805,9 @@ describe("WorkflowCanvas", () => { clientY: 300, }); - expect(mockAddNode).toHaveBeenCalledWith("prompt", expect.any(Object)); + await waitFor(() => { + expect(mockAddNode).toHaveBeenCalledWith("prompt", expect.any(Object), undefined); + }); }); it("should create a prompt node from dropped Markdown files", async () => { @@ -929,7 +933,7 @@ describe("WorkflowCanvas", () => { expect(mockUpdateNodeData).not.toHaveBeenCalled(); }); - it("should add prompt node on Shift+P", () => { + it("should add prompt node on Shift+P", async () => { render( @@ -938,10 +942,12 @@ describe("WorkflowCanvas", () => { fireEvent.keyDown(window, { key: "p", shiftKey: true }); - expect(mockAddNode).toHaveBeenCalledWith("prompt", expect.any(Object)); + await waitFor(() => { + expect(mockAddNode).toHaveBeenCalledWith("prompt", expect.any(Object), undefined); + }); }); - it("should add imageInput node on Shift+I", () => { + it("should add imageInput node on Shift+I", async () => { render( @@ -950,10 +956,12 @@ describe("WorkflowCanvas", () => { fireEvent.keyDown(window, { key: "i", shiftKey: true }); - expect(mockAddNode).toHaveBeenCalledWith("imageInput", expect.any(Object)); + await waitFor(() => { + expect(mockAddNode).toHaveBeenCalledWith("imageInput", expect.any(Object), undefined); + }); }); - it("should add nanoBanana node on Shift+G", () => { + it("should add nanoBanana node on Shift+G", async () => { render( @@ -962,10 +970,12 @@ describe("WorkflowCanvas", () => { fireEvent.keyDown(window, { key: "g", shiftKey: true }); - expect(mockAddNode).toHaveBeenCalledWith("nanoBanana", expect.any(Object)); + await waitFor(() => { + expect(mockAddNode).toHaveBeenCalledWith("nanoBanana", expect.any(Object), undefined); + }); }); - it("should add llmGenerate node on Shift+L", () => { + it("should add llmGenerate node on Shift+L", async () => { render( @@ -974,10 +984,12 @@ describe("WorkflowCanvas", () => { fireEvent.keyDown(window, { key: "l", shiftKey: true }); - expect(mockAddNode).toHaveBeenCalledWith("llmGenerate", expect.any(Object)); + await waitFor(() => { + expect(mockAddNode).toHaveBeenCalledWith("llmGenerate", expect.any(Object), undefined); + }); }); - it("should add annotation node on Shift+A", () => { + it("should add annotation node on Shift+A", async () => { render( @@ -986,7 +998,9 @@ describe("WorkflowCanvas", () => { fireEvent.keyDown(window, { key: "a", shiftKey: true }); - expect(mockAddNode).toHaveBeenCalledWith("annotation", expect.any(Object)); + await waitFor(() => { + expect(mockAddNode).toHaveBeenCalledWith("annotation", expect.any(Object), undefined); + }); }); }); diff --git a/src/components/composer/GenerationComposer.tsx b/src/components/composer/GenerationComposer.tsx index 8af1ff20..1c890d44 100644 --- a/src/components/composer/GenerationComposer.tsx +++ b/src/components/composer/GenerationComposer.tsx @@ -374,6 +374,13 @@ function findProviderModelForSelectedModel(models: ProviderModel[], selectedMode function selectedModelNeedsPopiserverMetadataLookup(model: SelectedModel): boolean { if (model.provider !== "popiserver") return false; + const hasResolvableIdentifier = Boolean( + model.modelId || + model.metadata?.aiModelId || + model.metadata?.aiModelCode || + model.metadata?.aiModelCodeAlias + ); + if (!hasResolvableIdentifier) return false; const billingMethod = finiteIntegerFromUnknown(model.metadata?.billingMethod); if (billingMethod === null) return true; if (billingMethod !== 1) return false; @@ -1150,9 +1157,11 @@ export function GenerationComposer() { const estimatedReferenceImageCount = context.mode === "node-edit" && (connectedInputs?.images.length ?? 0) > 0 ? connectedInputs?.images.length ?? 0 : draft.inputImages.length; + const selectedModelNeedsSchemaLookup = selectedModelNeedsPopiserverMetadataLookup(draft.selectedModel); const modelSchemaReadyForEstimate = context.mode !== "node-edit" || !context.node || + !selectedModelNeedsSchemaLookup || Array.isArray((context.node.data as { parameterSchema?: ModelParameter[] }).parameterSchema); const seedanceEstimatePayload = useMemo(() => { @@ -1211,6 +1220,7 @@ export function GenerationComposer() { const resolvedModel = selectedModelFromProvider(matchedModel); setDraft((current) => { + const currentCapabilities = current.selectedModel.capabilities ?? []; if ( !isSameSelectedModel(current.selectedModel, selectedModel) || !selectedModelNeedsPopiserverMetadataLookup(current.selectedModel) @@ -1222,8 +1232,8 @@ export function GenerationComposer() { ...current, selectedModel: { ...resolvedModel, - capabilities: current.selectedModel.capabilities.length > 0 - ? current.selectedModel.capabilities + capabilities: currentCapabilities.length > 0 + ? currentCapabilities : resolvedModel.capabilities, }, }; @@ -1242,10 +1252,11 @@ export function GenerationComposer() { nodeModel && selectedModelNeedsPopiserverMetadataLookup(nodeModel) ) { + const nodeModelCapabilities = nodeModel.capabilities ?? []; updateNodeData(activeContext.node.id, { selectedModel: { ...resolvedModel, - capabilities: nodeModel.capabilities.length > 0 ? nodeModel.capabilities : resolvedModel.capabilities, + capabilities: nodeModelCapabilities.length > 0 ? nodeModelCapabilities : resolvedModel.capabilities, }, }); } diff --git a/src/components/nodes/GenerateImageNode.tsx b/src/components/nodes/GenerateImageNode.tsx index 0e899aee..0a42a196 100644 --- a/src/components/nodes/GenerateImageNode.tsx +++ b/src/components/nodes/GenerateImageNode.tsx @@ -511,9 +511,14 @@ export function GenerateImageNode({ id, data, selected }: NodeProps model.id === nodeData.selectedModel?.modelId); + const shouldFetchPrimaryModelSchema = + Boolean(nodeData.selectedModel?.modelId) && + !usesFirstClassImageControls && + canFetchPopiModelSchema && + (currentProvider === POPI_PROVIDER_ID || showInlineNodeSettings || Boolean(nodeData.modelSchemaRequestId)); const modelSchema = useSelectedModelSchema({ selectedModel: nodeData.selectedModel, - enabled: Boolean(nodeData.selectedModel?.modelId) && !usesFirstClassImageControls && canFetchPopiModelSchema, + enabled: shouldFetchPrimaryModelSchema, refreshKey: nodeData.modelSchemaRequestId, onLoaded: ({ inputs, parameters, model }) => { const schemaDefaults = buildImageDefaultsFromSchema(parameters); diff --git a/src/utils/modelSchemaDefaults.ts b/src/utils/modelSchemaDefaults.ts index e8c01392..dfd6fc55 100644 --- a/src/utils/modelSchemaDefaults.ts +++ b/src/utils/modelSchemaDefaults.ts @@ -38,7 +38,7 @@ export function buildImageDefaultsFromSchema(schema: ModelParameter[]): { const imageCount = findParameter(schema, IMAGE_COUNT_PARAMETER_NAMES); const ratioValue = ratio ? getParameterDefault(ratio) : undefined; const resolutionValue = resolution ? getParameterDefault(resolution) : undefined; - const imageCountValue = imageCount ? Number(getParameterDefault(imageCount)) : undefined; + const imageCountValue = imageCount ? Number(getParameterDefault(imageCount)) : NaN; return { ...(hasValue(ratioValue) ? { aspectRatio: String(ratioValue) as AspectRatio } : {}),