Browse Source

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.
TEST-s
jiajia 2 months ago
parent
commit
d4f9e2e73e
  1. 11
      src/app/api/popi/media/upload/route.ts
  2. 40
      src/components/__tests__/WorkflowCanvas.test.tsx
  3. 17
      src/components/composer/GenerationComposer.tsx
  4. 7
      src/components/nodes/GenerateImageNode.tsx
  5. 2
      src/utils/modelSchemaDefaults.ts

11
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] : [];
}

40
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(
<TestWrapper>
<WorkflowCanvas />
@ -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(
<TestWrapper>
<WorkflowCanvas />
@ -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(
<TestWrapper>
<WorkflowCanvas />
@ -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(
<TestWrapper>
<WorkflowCanvas />
@ -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(
<TestWrapper>
<WorkflowCanvas />
@ -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(
<TestWrapper>
<WorkflowCanvas />
@ -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);
});
});
});

17
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,
},
});
}

7
src/components/nodes/GenerateImageNode.tsx

@ -511,9 +511,14 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
const canFetchPopiModelSchema =
currentProvider !== POPI_PROVIDER_ID ||
modelOptions.some((model) => 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);

2
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 } : {}),

Loading…
Cancel
Save