weige 2 months ago
parent
commit
18c85e70e4
  1. 2
      src/app/api/__tests__/popiserverModels.test.ts
  2. 1
      src/app/api/_popiserverModels.ts
  3. 5
      src/app/api/models/[modelId]/route.ts
  4. 10
      src/components/FloatingActionBar.tsx
  5. 3
      src/components/WorkflowCanvas.tsx
  6. 35
      src/components/__tests__/GenerationComposer.test.tsx
  7. 23
      src/components/__tests__/ModelParameters.test.tsx
  8. 129
      src/components/composer/GenerationComposer.tsx
  9. 7
      src/components/nodes/GenerateAudioNode.tsx
  10. 7
      src/components/nodes/GenerateImageNode.tsx
  11. 7
      src/components/nodes/GenerateVideoNode.tsx
  12. 11
      src/components/nodes/ModelParameters.tsx
  13. 4
      src/hooks/useSelectedModelSchema.ts
  14. 19
      src/utils/modelSelection.ts

2
src/app/api/__tests__/popiserverModels.test.ts

@ -84,6 +84,7 @@ describe("popiserver model helpers", () => {
code: "speech-2.8-hd",
name: "Speech 2.8 HD",
aiModelCodeAlias: "speech-2.8-hd",
billingMethod: 1,
categories: [{ taskSubType: 301 }],
});
@ -94,6 +95,7 @@ describe("popiserver model helpers", () => {
aiModelId: 30,
aiModelCode: "speech-2.8-hd",
aiModelCodeAlias: "speech-2.8-hd",
billingMethod: 1,
});
});
});

1
src/app/api/_popiserverModels.ts

@ -199,6 +199,7 @@ export function popiModelToProviderModel(model: PopiAIModel): ProviderModel {
duration: model.duration,
videoRatio: model.videoRatio,
resolution: model.resolution,
billingMethod: model.billingMethod,
isDefault: model.isDefault,
isSupportImages: model.isSupportImages,
isSupportVideos: model.isSupportVideos,

5
src/app/api/models/[modelId]/route.ts

@ -24,7 +24,7 @@
import { NextRequest, NextResponse } from "next/server";
import { ProviderType } from "@/types";
import { ModelParameter, ModelInput } from "@/lib/providers/types";
import { ModelParameter, ModelInput, ProviderModel } from "@/lib/providers/types";
import {
getCachedWaveSpeedSchema,
setCachedWaveSpeedSchema,
@ -37,6 +37,7 @@ import {
buildPopiModelInputs,
buildPopiModelParameters,
fetchPopiModelDetail,
popiModelToProviderModel,
} from "@/app/api/_popiserverModels";
// Cache for model schemas (10 minute TTL)
@ -125,6 +126,7 @@ interface SchemaSuccessResponse {
success: true;
parameters: ModelParameter[];
inputs: ModelInput[];
model?: ProviderModel;
cached: boolean;
}
@ -1615,6 +1617,7 @@ export async function GET(
success: true,
parameters: buildPopiModelParameters(model),
inputs: buildPopiModelInputs(model),
model: popiModelToProviderModel(model),
cached: false,
});
} catch (error) {

10
src/components/FloatingActionBar.tsx

@ -166,6 +166,7 @@ function GenerateComboButton() {
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const addNode = useWorkflowStore((state) => state.addNode);
const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
const { screenToFlowPosition } = useReactFlow();
useEffect(() => {
@ -191,7 +192,8 @@ function GenerateComboButton() {
y: center.y + Math.random() * 100 - 50,
});
addNode(type, position, await buildDefaultGenerationNodeData(type));
const nodeId = addNode(type, position, await buildDefaultGenerationNodeData(type));
selectSingleNode(nodeId);
setIsOpen(false);
};
@ -286,6 +288,7 @@ function AllNodesMenu() {
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const addNode = useWorkflowStore((state) => state.addNode);
const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
const { screenToFlowPosition } = useReactFlow();
useEffect(() => {
@ -311,9 +314,10 @@ function AllNodesMenu() {
y: center.y + Math.random() * 100 - 50,
});
addNode(type, position, await buildDefaultGenerationNodeData(type));
const nodeId = addNode(type, position, await buildDefaultGenerationNodeData(type));
selectSingleNode(nodeId);
setIsOpen(false);
}, [addNode, screenToFlowPosition]);
}, [addNode, screenToFlowPosition, selectSingleNode]);
const handleDragStart = useCallback((event: React.DragEvent, type: NodeType) => {
event.dataTransfer.setData("application/node-type", type);

3
src/components/WorkflowCanvas.tsx

@ -2340,7 +2340,8 @@ export function WorkflowCanvas() {
x: event.clientX,
y: event.clientY,
});
addNode(nodeType, position, await buildDefaultGenerationNodeData(nodeType));
const nodeId = addNode(nodeType, position, await buildDefaultGenerationNodeData(nodeType));
selectSingleNode(nodeId);
return;
}

35
src/components/__tests__/GenerationComposer.test.tsx

@ -650,6 +650,7 @@ describe("GenerationComposer", () => {
aiModelCodeAlias: "gpt-image-2-all",
type: 1,
subType: 102,
billingMethod: 1,
},
},
}),
@ -679,6 +680,40 @@ describe("GenerationComposer", () => {
});
});
it("does not show PopiServer task price estimates for token-billed models", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
throw new Error(`Unexpected fetch: ${String(input)}`);
});
vi.stubGlobal("fetch", fetchMock);
useWorkflowStore.setState({
nodes: [
imageNode("img-1", true, {
selectedModel: {
provider: "popiserver",
modelId: "32",
displayName: "Token Billed Image",
capabilities: ["text-to-image"],
metadata: {
aiModelId: 32,
aiModelCode: "token-image-model",
aiModelCodeAlias: "token-image-model",
type: 1,
subType: 102,
billingMethod: 2,
},
},
}),
],
});
render(<GenerationComposer />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Generate" })).toBeInTheDocument();
});
expect(fetchMock.mock.calls.some(([input]) => String(input).startsWith("/api/points/calculate-task-price"))).toBe(false);
});
it("uses the Seedance estimate endpoint for PopiServer Seedance models", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);

23
src/components/__tests__/ModelParameters.test.tsx

@ -95,6 +95,29 @@ describe("ModelParameters", () => {
expect(onParametersChange).toHaveBeenCalledWith({ max_images: 2 });
});
it("replaces stale enum parameter values with the current schema default", async () => {
const onParametersChange = vi.fn();
render(
<ModelParameters
{...defaultProps}
parameters={{ resolution: "1K" }}
schema={[
createMockParameter({
name: "resolution",
type: "string",
enum: ["4K"],
default: "4K",
}),
]}
onParametersChange={onParametersChange}
/>
);
await waitFor(() => {
expect(onParametersChange).toHaveBeenCalledWith({ resolution: "4K" });
});
});
it("renders number validation and boolean defaults", () => {
render(
<ModelParameters

129
src/components/composer/GenerationComposer.tsx

@ -14,6 +14,7 @@ import {
import { createPortal } from "react-dom";
import { useReactFlow, useViewport, ViewportPortal } from "@xyflow/react";
import { useProviderApiKeys, useWorkflowStore } from "@/store/workflowStore";
import { useModelStore } from "@/store/modelStore";
import {
AspectRatio,
EaseCurveNodeData,
@ -253,6 +254,54 @@ function selectedModelFromProvider(model: ProviderModel): SelectedModel {
};
}
function identifierFromUnknown(value: unknown): string | null {
if (typeof value === "string" && value.trim().length > 0) return value.trim();
if (typeof value === "number" && Number.isFinite(value)) return String(Math.trunc(value));
return null;
}
function getSelectedModelIdentifiers(model: SelectedModel): Set<string> {
return new Set(
[
model.modelId,
model.displayName,
identifierFromUnknown(model.metadata?.aiModelId),
identifierFromUnknown(model.metadata?.aiModelCode),
identifierFromUnknown(model.metadata?.aiModelCodeAlias),
]
.filter((value): value is string => Boolean(value))
.map((value) => value.trim().toLowerCase())
.filter(Boolean)
);
}
function providerModelMatchesSelectedModel(model: ProviderModel, selectedModel: SelectedModel): boolean {
if (model.provider !== selectedModel.provider) return false;
const identifiers = getSelectedModelIdentifiers(selectedModel);
return [
model.id,
model.name,
identifierFromUnknown(model.metadata?.aiModelId),
identifierFromUnknown(model.metadata?.aiModelCode),
identifierFromUnknown(model.metadata?.aiModelCodeAlias),
]
.filter((value): value is string => Boolean(value))
.some((value) => identifiers.has(value.trim().toLowerCase()));
}
function findProviderModelForSelectedModel(models: ProviderModel[], selectedModel: SelectedModel): ProviderModel | null {
return models.find((model) => providerModelMatchesSelectedModel(model, selectedModel)) ?? null;
}
function selectedModelNeedsPopiserverMetadataLookup(model: SelectedModel): boolean {
if (model.provider !== "popiserver") return false;
const billingMethod = finiteIntegerFromUnknown(model.metadata?.billingMethod);
if (billingMethod === null) return true;
if (billingMethod !== 1) return false;
const aiModelId = finiteIntegerFromUnknown(model.metadata?.aiModelId) ?? finiteIntegerFromUnknown(model.modelId);
return !aiModelId;
}
function isSameSelectedModel(a: SelectedModel | undefined, b: SelectedModel | undefined): boolean {
return Boolean(a?.provider && b?.provider && a.provider === b.provider && a.modelId === b.modelId);
}
@ -393,6 +442,10 @@ function getPopiserverModelName(model: SelectedModel): string {
);
}
function isPopiserverPerRunBillingModel(model: SelectedModel): boolean {
return model.provider === "popiserver" && finiteIntegerFromUnknown(model.metadata?.billingMethod) === 1;
}
function buildSeedanceVideoEstimatePayload(
draft: ComposerDraft,
inputVideoDurationSeconds: number
@ -456,6 +509,7 @@ function buildPopiserverTaskPricePayload(
): PopiserverTaskPricePayload | null {
const model = draft.selectedModel;
if (model.provider !== "popiserver") return null;
if (!isPopiserverPerRunBillingModel(model)) return null;
const aiModelId = finiteIntegerFromUnknown(model.metadata?.aiModelId) ?? finiteIntegerFromUnknown(model.modelId);
if (!aiModelId) return null;
@ -906,6 +960,8 @@ export function GenerationComposer() {
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
const getConnectedInputs = useWorkflowStore((state) => state.getConnectedInputs);
const popiModels = useModelStore((state) => state.all);
const refreshPopiModels = useModelStore((state) => state.refreshPopiModels);
const { newApiWGBaseUrl } = useProviderApiKeys();
const { screenToFlowPosition } = useReactFlow();
const { x: viewportX, y: viewportY, zoom } = useViewport();
@ -1053,6 +1109,79 @@ export function GenerationComposer() {
? JSON.stringify(popiserverTaskPricePayload)
: null;
useEffect(() => {
const selectedModel = draft.selectedModel;
if (!selectedModelNeedsPopiserverMetadataLookup(selectedModel)) return;
let cancelled = false;
const applyResolvedModel = (models: ProviderModel[]): boolean => {
const matchedModel = findProviderModelForSelectedModel(models, selectedModel);
if (!matchedModel || finiteIntegerFromUnknown(matchedModel.metadata?.billingMethod) === null) {
return false;
}
const resolvedModel = selectedModelFromProvider(matchedModel);
setDraft((current) => {
if (
!isSameSelectedModel(current.selectedModel, selectedModel) ||
!selectedModelNeedsPopiserverMetadataLookup(current.selectedModel)
) {
return current;
}
const next = {
...current,
selectedModel: {
...resolvedModel,
capabilities: current.selectedModel.capabilities.length > 0
? current.selectedModel.capabilities
: resolvedModel.capabilities,
},
};
draftRef.current = next;
return next;
});
const activeContext = contextRef.current;
const nodeModel = activeContext.node?.data && "selectedModel" in activeContext.node.data
? (activeContext.node.data.selectedModel as SelectedModel | undefined)
: undefined;
if (
activeContext.mode === "node-edit" &&
activeContext.node &&
isSameSelectedModel(nodeModel, selectedModel) &&
nodeModel &&
selectedModelNeedsPopiserverMetadataLookup(nodeModel)
) {
updateNodeData(activeContext.node.id, {
selectedModel: {
...resolvedModel,
capabilities: nodeModel.capabilities.length > 0 ? nodeModel.capabilities : resolvedModel.capabilities,
},
});
}
return true;
};
if (applyResolvedModel(popiModels)) return;
void refreshPopiModels().then((models) => {
if (!cancelled) applyResolvedModel(models);
});
return () => {
cancelled = true;
};
}, [
draft.selectedModel.displayName,
draft.selectedModel.metadata,
draft.selectedModel.modelId,
draft.selectedModel.provider,
popiModels,
refreshPopiModels,
updateNodeData,
]);
useEffect(() => {
const selectedModel = draft.selectedModel;
if (

7
src/components/nodes/GenerateAudioNode.tsx

@ -23,7 +23,7 @@ import { HandleLabel } from "./HandleLabel";
import { isPopiProviderMode, POPI_PROVIDER_ID } from "@/lib/providerMode";
import { useI18n } from "@/i18n";
import { formatUserFacingError } from "@/utils/userFacingErrors";
import { chooseModelFromList, toSelectedModel } from "@/utils/modelSelection";
import { chooseModelFromList, shouldUpdateSelectedModelFromProviderModel, toSelectedModel } from "@/utils/modelSelection";
import { useModelStore } from "@/store/modelStore";
type GenerateAudioNodeType = Node<GenerateAudioNodeData, "generateAudio">;
@ -136,10 +136,13 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
!popiProviderMode || popiserverAudioModels.some((model) => model.id === nodeData.selectedModel?.modelId)
),
refreshKey: nodeData.modelSchemaRequestId,
onLoaded: ({ inputs, parameters }) => {
onLoaded: ({ inputs, parameters, model }) => {
updateNodeData(id, {
inputSchema: inputs,
parameterSchema: parameters,
...(model && shouldUpdateSelectedModelFromProviderModel(nodeData.selectedModel, model)
? { selectedModel: toSelectedModel(model) }
: {}),
modelSchemaError: null,
});
},

7
src/components/nodes/GenerateImageNode.tsx

@ -26,7 +26,7 @@ import { useI18n } from "@/i18n";
import { MediaPreviewModal } from "@/components/MediaPreviewModal";
import { createGeminiLegacyImageModel } from "@/store/utils/defaultImageModel";
import { getProviderDisplayName, isPopiProviderMode, POPI_PROVIDER_ID } from "@/lib/providerMode";
import { chooseModelFromList, toSelectedModel } from "@/utils/modelSelection";
import { chooseModelFromList, shouldUpdateSelectedModelFromProviderModel, toSelectedModel } from "@/utils/modelSelection";
import { useModelStore } from "@/store/modelStore";
import {
isBrowserFileSystemPath,
@ -514,10 +514,13 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
selectedModel: nodeData.selectedModel,
enabled: Boolean(nodeData.selectedModel?.modelId) && !usesFirstClassImageControls && canFetchPopiModelSchema,
refreshKey: nodeData.modelSchemaRequestId,
onLoaded: ({ inputs, parameters }) => {
onLoaded: ({ inputs, parameters, model }) => {
updateNodeData(id, {
inputSchema: inputs,
parameterSchema: parameters,
...(model && shouldUpdateSelectedModelFromProviderModel(nodeData.selectedModel, model)
? { selectedModel: toSelectedModel(model) }
: {}),
modelSchemaError: null,
});
},

7
src/components/nodes/GenerateVideoNode.tsx

@ -26,7 +26,7 @@ import { HandleLabel } from "./HandleLabel";
import { useI18n } from "@/i18n";
import { MediaPreviewModal } from "@/components/MediaPreviewModal";
import { getProviderDisplayName, isPopiProviderMode, POPI_PROVIDER_ID } from "@/lib/providerMode";
import { chooseModelFromList, toSelectedModel } from "@/utils/modelSelection";
import { chooseModelFromList, shouldUpdateSelectedModelFromProviderModel, toSelectedModel } from "@/utils/modelSelection";
import { useModelStore } from "@/store/modelStore";
import { formatUserFacingError } from "@/utils/userFacingErrors";
import {
@ -343,10 +343,13 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
selectedModel: nodeData.selectedModel,
enabled: Boolean(nodeData.selectedModel?.modelId) && canFetchPopiModelSchema,
refreshKey: nodeData.modelSchemaRequestId,
onLoaded: ({ inputs, parameters }) => {
onLoaded: ({ inputs, parameters, model }) => {
updateNodeData(id, {
inputSchema: inputs,
parameterSchema: parameters,
...(model && shouldUpdateSelectedModelFromProviderModel(nodeData.selectedModel, model)
? { selectedModel: toSelectedModel(model) }
: {}),
modelSchemaError: null,
});
},

11
src/components/nodes/ModelParameters.tsx

@ -85,8 +85,15 @@ function ModelParametersInner({
const defaults: Record<string, unknown> = {};
let hasNewDefaults = false;
for (const param of visibleSchema) {
if (param.default !== undefined && baseParameters[param.name] === undefined) {
defaults[param.name] = param.default;
const currentValue = baseParameters[param.name];
const enumValues = Array.isArray(param.enum) ? param.enum : [];
const hasInvalidEnumValue =
enumValues.length > 0 &&
currentValue !== undefined &&
!enumValues.some((item) => String(item) === String(currentValue));
const defaultValue = param.default ?? enumValues[0];
if ((currentValue === undefined || hasInvalidEnumValue) && defaultValue !== undefined) {
defaults[param.name] = defaultValue;
hasNewDefaults = true;
}
}

4
src/hooks/useSelectedModelSchema.ts

@ -2,12 +2,13 @@
import { useEffect, useRef, useState } from "react";
import { ModelInputDef, SelectedModel } from "@/types";
import { ModelParameter } from "@/lib/providers/types";
import { ModelParameter, ProviderModel } from "@/lib/providers/types";
import { useProviderApiKeys } from "@/store/workflowStore";
interface ModelSchemaResult {
parameters: ModelParameter[];
inputs: ModelInputDef[];
model?: ProviderModel;
}
interface UseSelectedModelSchemaOptions {
@ -89,6 +90,7 @@ export function useSelectedModelSchema({
const nextSchema = {
parameters: data.parameters || [],
inputs: data.inputs || [],
model: data.model,
};
setParameters(nextSchema.parameters);
setInputs(nextSchema.inputs);

19
src/utils/modelSelection.ts

@ -11,6 +11,25 @@ export function toSelectedModel(model: ProviderModel): SelectedModel {
};
}
function hasValue(value: unknown): boolean {
return value !== undefined && value !== null && value !== "";
}
export function shouldUpdateSelectedModelFromProviderModel(
current: SelectedModel | undefined,
model: ProviderModel
): boolean {
if (!current) return true;
if (current.provider !== model.provider || current.modelId !== model.id) return true;
if (model.provider !== "popiserver") return false;
const currentMetadata = current.metadata ?? {};
const nextMetadata = model.metadata ?? {};
return ["billingMethod", "aiModelId", "type", "subType", "aiModelCode", "aiModelCodeAlias"].some(
(key) => !hasValue(currentMetadata[key]) && hasValue(nextMetadata[key])
);
}
export function chooseModelFromList(
models: ProviderModel[],
preferred?: SelectedModel

Loading…
Cancel
Save