Browse Source

修复价格问题

feature/071
yun 1 week ago
parent
commit
051ef25bbb
  1. 4
      src/app/api/points/quote/route.ts
  2. 78
      src/components/__tests__/GenerationComposer.test.tsx
  3. 63
      src/components/composer/GenerationComposer.tsx
  4. 11
      src/store/__tests__/generationPreferenceStore.test.ts
  5. 45
      src/utils/__tests__/generationConfig.test.ts
  6. 4
      src/utils/defaultGenerationNodeModel.ts
  7. 3
      src/utils/generationConfig.ts

4
src/app/api/points/quote/route.ts

@ -91,6 +91,10 @@ export async function POST(request: NextRequest) {
const source = readSource(body);
const upstreamBody = buildUpstreamBody(source, readPayload(body));
console.info("[points quote] upstream params", {
source,
payload: upstreamBody,
});
const url = getPopiartApiBaseUrl() + (
source === "estimate" ? UPSTREAM_ESTIMATE_POINTS_PATH : UPSTREAM_CALCULATE_TASK_PRICE_PATH
);

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

@ -1521,6 +1521,84 @@ describe("GenerationComposer", () => {
});
});
it("hydrates missing image node settings from model detail defaults when opened", async () => {
useModelStore.setState({
detailsById: {
"apiyi_nano_banana_2": {
loadedAt: Date.now(),
parameters: [
{
name: "ratio",
type: "string",
label: "Ratio",
options: [
{ label: "16:9", value: "16:9" },
{ label: "9:16", value: "9:16" },
],
default: "9:16",
},
],
inputs: [],
},
},
});
useWorkflowStore.setState({
nodes: [
{
...imageNode("img-1", true, { aspectRatio: "16:9", parameters: {} }),
width: 360,
height: 203,
measured: { width: 360, height: 203 },
} as WorkflowNode,
],
});
render(<GenerationComposer />);
await waitFor(() => {
const node = useWorkflowStore.getState().nodes[0];
const data = node.data as NanoBananaNodeData;
expect(data.parameters).toEqual({ ratio: "9:16" });
expect(data.aspectRatio).toBe("9:16");
expect(node.width).toBe(203);
expect(node.height).toBe(360);
});
});
it("does not overwrite existing image node settings when hydrating model detail defaults", async () => {
useModelStore.setState({
detailsById: {
"apiyi_nano_banana_2": {
loadedAt: Date.now(),
parameters: [
{
name: "ratio",
type: "string",
label: "Ratio",
options: [
{ label: "16:9", value: "16:9" },
{ label: "9:16", value: "9:16" },
],
default: "9:16",
},
],
inputs: [],
},
},
});
useWorkflowStore.setState({
nodes: [imageNode("img-1", true, { aspectRatio: "16:9", parameters: { ratio: "16:9" } })],
});
render(<GenerationComposer />);
await waitFor(() => {
const data = useWorkflowStore.getState().nodes[0].data as NanoBananaNodeData;
expect(data.parameters).toEqual({ ratio: "16:9" });
expect(data.aspectRatio).toBe("16:9");
});
});
it("renders extension fields in the settings popover and submits them as extraTaskParams", async () => {
const regenerateNode = vi.fn().mockResolvedValue(undefined) as typeof originalActions.regenerateNode;
useWorkflowStore.setState({

63
src/components/composer/GenerationComposer.tsx

@ -23,6 +23,7 @@ import {
type ComposerContext,
} from "@/utils/composerNodeDescriptor";
import {
buildGenerationConfigDraftFromModelSchema,
buildInitialGenerationNodeData,
buildSubmittableGenerationConfig,
filterTaskRoutingParameters,
@ -99,6 +100,7 @@ const PROMPT_LINE_HEIGHT_PX = 24;
const PROMPT_MIN_HEIGHT_PX = PROMPT_LINE_HEIGHT_PX * 2;
const PROMPT_MAX_HEIGHT_PX = PROMPT_LINE_HEIGHT_PX * 4;
const EMPTY_EXTRA_TASK_PARAMS: Record<string, unknown> = {};
const ASPECT_RATIO_PARAMETER_NAMES = ["ratio", "aspectRatio", "aspect_ratio", "imageRatio", "image_ratio"];
const EMPTY_SELECTED_MODEL: SelectedModel = {
provider: "popiserver",
@ -208,6 +210,18 @@ function buildComposerGenerationConfig(config: ComposerConfig, prompt: string):
};
}
function readAspectRatioFromComposerConfig(config: Pick<GenerationNodeConfig, "parameters">): string | undefined {
for (const name of ASPECT_RATIO_PARAMETER_NAMES) {
const value = config.parameters?.[name];
if (typeof value === "string" && value.trim()) return value.trim();
}
return undefined;
}
function composerConfigEquals(a: ComposerConfig, b: ComposerConfig): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
function referenceSubjectsToMaterials(subjects: ComposerConfig["referenceSubjectList"]): ComposerReferenceMaterial[] {
return readReferenceSubjectList(subjects).map((subject, index) => ({
id: index + 1,
@ -317,12 +331,14 @@ function buildComposerConfigPersistPatch(node: WorkflowNode, config: GenerationN
const nodeType = node.type;
const parameters = buildComposerPersistParameters(node, config);
if (nodeType === "smartImage" || nodeType === "nanoBanana") {
const aspectRatio = readAspectRatioFromComposerConfig(config);
return {
inputPrompt: config.prompt.trim(),
selectedModel: config.selectedModel,
batchSize: config.batchSize,
parameters,
extraTaskParams: config.extraTaskParams ?? {},
...(aspectRatio ? { aspectRatio } : {}),
} as Partial<NanoBananaNodeData>;
}
if (nodeType === "smartVideo" || nodeType === "generateVideo") {
@ -713,6 +729,7 @@ function ComposerEditor({
const suppressAutoPriceRefreshRef = useRef(false);
const modelChangeCompletionTimerRef = useRef<number | null>(null);
const priceRefreshTimerRef = useRef<number | null>(null);
const hydratedModelDefaultsRef = useRef<Set<string>>(new Set());
const pricingInputsRef = useRef<PricingInputsSnapshot>({
config,
prompt,
@ -777,6 +794,41 @@ function ComposerEditor({
: storedModelFitsCapability(config.selectedModel, context.capability);
const canChooseModel = context.mode === "empty-create" || context.mode === "node-edit";
useEffect(() => {
if (isHidden) return;
if (context.mode !== "node-edit" || !context.node) return;
if (!activeModelDetail || !config.selectedModel.modelId) return;
const hydrationKey = `${context.node.id}:${config.selectedModel.modelId}`;
if (hydratedModelDefaultsRef.current.has(hydrationKey)) return;
hydratedModelDefaultsRef.current.add(hydrationKey);
const { prompt: _prompt, ...hydratedConfig } = buildGenerationConfigDraftFromModelSchema(
buildGenerationConfigForContext(context),
activeModelDetail
);
let shouldPersistAspectRatio = false;
if (context.capability === "image") {
const aspectRatio = readAspectRatioFromComposerConfig(hydratedConfig);
if (aspectRatio) {
onApplyNodeSize(context.node.id, aspectRatio);
shouldPersistAspectRatio = (context.node.data as { aspectRatio?: unknown }).aspectRatio !== aspectRatio;
}
}
if (composerConfigEquals(config, hydratedConfig) && !shouldPersistAspectRatio) return;
setConfig(hydratedConfig);
onPersistConfig(context, buildGenerationConfigForContext(context, hydratedConfig, prompt));
}, [
activeModelDetail,
buildGenerationConfigForContext,
config,
context,
isHidden,
onApplyNodeSize,
onPersistConfig,
prompt,
]);
const connectedReferenceImages = context.mode === "node-edit" ? connectedInputs?.images ?? [] : [];
const connectedReferenceAudio = context.mode === "node-edit" ? connectedInputs?.audio ?? [] : [];
const referenceImages = connectedReferenceImages.length > 0 ? connectedReferenceImages : config.inputImages;
@ -910,7 +962,7 @@ function ComposerEditor({
generationConfig,
detail ?? inputs.activeModelDetail ?? { parameters: inputs.modelSchema, extensionFields: inputs.extensionFields }
);
const quoteRequest = buildPopiserverPriceQuoteRequest(buildComposerPricingContext({
const pricingContextInput = {
config: normalizedConfig,
prompt: nextPrompt,
capability: context.capability,
@ -918,12 +970,17 @@ function ComposerEditor({
modelDetail: detail,
media: inputs.popiserverPricingMedia,
inputVideoDurationSeconds: inputs.connectedVideoDurationSeconds,
}));
};
console.info("[points quote] build input params", pricingContextInput);
const quoteRequest = buildPopiserverPriceQuoteRequest(buildComposerPricingContext(pricingContextInput));
if (!quoteRequest) {
setPriceState({ amount: null, isLoading: false });
return;
}
console.info("[points quote] request params", quoteRequest);
try {
const response = await fetch("/api/points/quote", {
method: "POST",
@ -961,7 +1018,6 @@ function ComposerEditor({
}, [config, context.node, updateNodeExtraTaskParams]);
const schedulePriceRefresh = useCallback(() => {
console.log('priceRefreshTimerRef -----------', priceRefreshTimerRef.current)
if (priceRefreshTimerRef.current !== null) {
window.clearTimeout(priceRefreshTimerRef.current);
}
@ -971,7 +1027,6 @@ function ComposerEditor({
if (latest.isHidden) return;
if (suppressAutoPriceRefreshRef.current) return;
if (!latest.config.selectedModel.modelId) return;
console.log('refreshPrice ---------', latest)
void refreshPrice(latest.config, latest.prompt, latest.activeModelDetail, latest);
}, 200);
}, [refreshPrice]);

11
src/store/__tests__/generationPreferenceStore.test.ts

@ -156,6 +156,7 @@ describe("generationPreferenceStore", () => {
provider: "popiserver",
capabilities: ["text-to-video", "image-to-video"],
metadata: {
billingMethod: 2,
aiModelId: 9001,
aiModelCode: "seedance-video-pro",
aiModelCodeAlias: "seedance-pro",
@ -185,15 +186,17 @@ describe("generationPreferenceStore", () => {
provider: "popiserver",
modelId: "44",
displayName: "seedance 2.0-fast",
billingMethod: 2,
},
});
expect(useGenerationPreferenceStore.getState().video.model.modelId).toBe("44");
expect(useGenerationPreferenceStore.getState().video.model.displayName).toBe("seedance 2.0-fast");
expect(useGenerationPreferenceStore.getState().video.model.billingMethod).toBe(2);
await expect(buildDefaultGenerationNodeCreation("generateVideo")).resolves.toEqual({
data: expect.objectContaining({
parameters: { ratio: "9:16" },
selectedModel: expect.objectContaining({ modelId: "44" }),
selectedModel: expect.objectContaining({ modelId: "44", billingMethod: 2 }),
}),
size: { width: 203, height: 360 },
});
@ -230,6 +233,7 @@ describe("generationPreferenceStore", () => {
description: null,
provider: "popiserver",
capabilities: ["text-to-image"],
metadata: { billingMethod: 2 },
},
{
id: "vid-44",
@ -237,6 +241,7 @@ describe("generationPreferenceStore", () => {
description: null,
provider: "popiserver",
capabilities: ["text-to-video"],
metadata: { billingMethod: 1 },
},
{
id: "aud-55",
@ -265,10 +270,10 @@ describe("generationPreferenceStore", () => {
const llmData = await buildDefaultGenerationNodeData("llmGenerate");
expect(imageData).toMatchObject({
selectedModel: { modelId: "img-32", displayName: DEFAULT_IMAGE_GENERATION_MODEL_NAME },
selectedModel: { modelId: "img-32", displayName: DEFAULT_IMAGE_GENERATION_MODEL_NAME, billingMethod: 2 },
});
expect(videoData).toMatchObject({
selectedModel: { modelId: "vid-44", displayName: DEFAULT_VIDEO_GENERATION_MODEL_NAME },
selectedModel: { modelId: "vid-44", displayName: DEFAULT_VIDEO_GENERATION_MODEL_NAME, billingMethod: 1 },
});
expect(audioData).toMatchObject({
selectedModel: { modelId: "aud-55", displayName: DEFAULT_AUDIO_GENERATION_MODEL_NAME },

45
src/utils/__tests__/generationConfig.test.ts

@ -85,7 +85,7 @@ describe("generationConfig", () => {
});
});
it("merges extra task params into runtime generation parameters", () => {
it("keeps model params and extra task params in separate runtime buckets", () => {
const config = {
prompt: "prompt",
selectedModel: defaultModel,
@ -117,9 +117,6 @@ describe("generationConfig", () => {
expect(buildRuntimeGenerationParameters(config)).toEqual({
width: 1024,
height: 768,
horizontal_angle: 15,
vertical_angle: 30,
zoom: 5,
});
});
@ -380,9 +377,32 @@ describe("generationConfig", () => {
).toEqual({ resolution: "720P" });
});
it("builds Popiserver task price payload from stored model parameters", () => {
it("keeps extra task params separate from runtime generation parameters", () => {
expect(
buildPopiserverTaskPricePayload(
buildRuntimeGenerationParameters({
prompt: "prompt",
selectedModel: defaultModel,
inputImages: [],
batchSize: 1,
referenceSubjectList: [],
parameters: {
resolution: "std",
ratio: "16:9",
duration: 4,
},
extraTaskParams: {
sound: "on",
},
})
).toEqual({
resolution: "std",
ratio: "16:9",
duration: 4,
});
});
it("builds Popiserver task price payload from stored model parameters", () => {
const payload = buildPopiserverTaskPricePayload(
{
prompt: "prompt",
selectedModel: {
@ -405,11 +425,14 @@ describe("generationConfig", () => {
duration: 4,
videoRatio: 20,
},
extraTaskParams: {},
extraTaskParams: {
sound: "on",
},
},
"video"
)
).toMatchObject({
);
expect(payload).toMatchObject({
type: 2,
subType: POPI_VIDEO_SUBTYPE_IMAGE_TO_VIDEO,
aiModelId: 37,
@ -420,6 +443,10 @@ describe("generationConfig", () => {
resolution: "720p",
duration: 4,
videoRatio: 20,
extraTaskParams: {
sound: "on",
},
});
expect(payload?.sound).toBeUndefined();
});
});

4
src/utils/defaultGenerationNodeModel.ts

@ -7,6 +7,7 @@ import { useGenerationPreferenceStore } from "@/store/generationPreferenceStore"
import { filterTaskRoutingParameters } from "@/utils/generationConfig";
import { inferNodeSizeFromData } from "@/utils/nodeSizing";
import { DEFAULT_POPISERVER_LLM_MODEL_NAME } from "@/lib/llmModels";
import { toSelectedModel } from "@/utils/selectedModel";
type GenerationNodeType = "smartImage" | "smartVideo" | "smartAudio" | "smartText" | "nanoBanana" | "generateVideo" | "generateAudio" | "llmGenerate";
@ -44,8 +45,7 @@ async function resolvePreferenceModel(type: DefaultModelKind, selectedModel: Sel
if (!model) return selectedModel;
const resolved: SelectedModel = {
...selectedModel,
provider: model.provider,
modelId: model.id,
...toSelectedModel(model),
displayName: selectedModel.displayName || model.name,
};
const preferences = useGenerationPreferenceStore.getState();

3
src/utils/generationConfig.ts

@ -382,7 +382,6 @@ export function buildRuntimeGenerationParameters(config: GenerationNodeConfig):
const referenceSubjectList = config.referenceSubjectList ?? [];
return {
...buildRuntimeModelParameters(config),
...buildRuntimeExtraTaskParams(config),
...(referenceSubjectList.length > 0 ? { referenceSubjectList } : {}),
};
}
@ -457,6 +456,7 @@ export function buildPopiserverPricingParameterPayload(
if (model.provider !== "popiserver") return null;
const dynamicParameters = buildRuntimeGenerationParameters(config);
const dynamicExtraTaskParams = buildRuntimeExtraTaskParams(config);
const input: GenerationInput = {
model: {
id: model.modelId,
@ -480,6 +480,7 @@ export function buildPopiserverPricingParameterPayload(
voices: media.voices ?? [],
subType: config.subType,
parameters: dynamicParameters,
extraTaskParams: dynamicExtraTaskParams,
referenceSubjectList: config.referenceSubjectList,
audioVoiceId: config.audioVoiceId,
batchSize: config.batchSize,

Loading…
Cancel
Save