Browse Source

优化参数配置逻辑

feature/add_provider_popi
weige 2 months ago
parent
commit
cfd9a499ea
  1. 149
      src/components/composer/GenerationComposer.tsx
  2. 2
      src/components/nodes/GenerateAudioNode.tsx
  3. 3
      src/components/nodes/GenerateImageNode.tsx
  4. 3
      src/components/nodes/GenerateVideoNode.tsx
  5. 6
      src/store/execution/generateAudioExecutor.ts
  6. 10
      src/store/execution/generateVideoExecutor.ts
  7. 13
      src/store/execution/generationParameters.ts
  8. 22
      src/store/execution/nanoBananaExecutor.ts
  9. 31
      src/utils/__tests__/modelSchemaDefaults.test.ts
  10. 10
      src/utils/modelSchemaDefaults.ts

149
src/components/composer/GenerationComposer.tsx

@ -188,13 +188,15 @@ function getSchemaSelectOptions(param: ModelParameter): SchemaSelectOption[] {
}
function getSelectedSchemaOptionKey(options: SchemaSelectOption[], value: unknown): string | undefined {
const normalizedValue = String(value ?? "").toLowerCase();
return options.find((option) =>
Object.is(option.value, value) || String(option.value) === String(value ?? "")
Object.is(option.value, value) || String(option.value).toLowerCase() === normalizedValue
)?.key;
}
function valueMatchesSchemaOptions(value: unknown, options: string[]): boolean {
return options.some((option) => String(option) === String(value));
const normalizedValue = String(value ?? "").toLowerCase();
return options.some((option) => String(option).toLowerCase() === normalizedValue);
}
function buildGatewayDimensionSelects(
@ -207,8 +209,14 @@ function buildGatewayDimensionSelects(
.filter((param) => nameSet.has(param.name) && param.options && param.options.length > 0)
.map((parameter) => {
const options = getSchemaSelectOptions(parameter);
const value = draft.parameters?.[parameter.name]
?? options[0]?.value;
const value =
parameter.name === "resolution"
? draft.resolution
: parameter.name === "ratio" || parameter.name === "videoRatio"
? draft.aspectRatio
: parameter.name === "duration"
? draft.durationSeconds
: draft.parameters?.[parameter.name] ?? options[0]?.value;
return {
parameter,
options,
@ -217,28 +225,6 @@ function buildGatewayDimensionSelects(
});
}
function getSchemaParameterFallbackValue(parameter: ModelParameter): unknown {
return parameter.default ?? parameter.options?.[0]?.value ?? parameter.enum?.[0];
}
function applySchemaParameterDefaults(
schema: ModelParameter[] | undefined,
parameters: Record<string, unknown> | undefined
): { parameters: Record<string, unknown>; changed: boolean } {
const next = { ...(parameters ?? {}) };
let changed = false;
for (const parameter of schema ?? []) {
if (next[parameter.name] !== undefined && next[parameter.name] !== null && next[parameter.name] !== "") continue;
const value = getSchemaParameterFallbackValue(parameter);
if (value === undefined || value === null || value === "") continue;
next[parameter.name] = value;
changed = true;
}
return { parameters: next, changed };
}
function getImageCountOptions(schema: ModelParameter[] | undefined): ImageGenerationCount[] {
return getSchemaOptionValues(schema, IMAGE_COUNT_PARAMETER_NAMES)
.map((value) => Number(value))
@ -602,13 +588,14 @@ function buildPopiserverTaskPricePayload(
if (!subType) return null;
const aspectRatio =
draft.aspectRatio ||
stringFromUnknown(draft.parameters?.aspectRatio) ||
stringFromUnknown(draft.parameters?.aspect_ratio) ||
draft.aspectRatio ||
"16:9";
const dimensions = dimensionsForAspectRatio(aspectRatio);
const duration = finiteIntegerFromUnknown(draft.parameters?.duration) ??
(type === 2 ? normalizeVideoDurationSeconds(draft.durationSeconds) : null);
const duration = type === 2
? normalizeVideoDurationSeconds(draft.durationSeconds)
: finiteIntegerFromUnknown(draft.parameters?.duration);
const modelName = getPopiserverModelName(model);
return {
@ -620,14 +607,11 @@ function buildPopiserverTaskPricePayload(
model: modelName,
aspectRatio,
ratio: aspectRatio,
resolution:
stringFromUnknown(draft.parameters?.resolution) ||
draft.resolution ||
(type === 2 ? DEFAULT_VIDEO_RESOLUTION : "2K"),
resolution: draft.resolution || stringFromUnknown(draft.parameters?.resolution) || (type === 2 ? DEFAULT_VIDEO_RESOLUTION : "2K"),
duration: duration ?? undefined,
width: finiteIntegerFromUnknown(draft.parameters?.width) ?? dimensions.width,
height: finiteIntegerFromUnknown(draft.parameters?.height) ?? dimensions.height,
batchSize: finiteIntegerFromUnknown(draft.parameters?.batchSize) ?? (type === 1 ? draft.imageCount : 1),
width: dimensions.width,
height: dimensions.height,
batchSize: type === 1 ? draft.imageCount : 1,
};
}
@ -925,6 +909,20 @@ function readDraftFromContext(context: ComposerContext): ComposerDraft {
return adapter && context.node ? adapter.readDraft(context.node) : createEmptyDraft();
}
function composerDraftShallowEqual(a: ComposerDraft, b: ComposerDraft): boolean {
return (
a.prompt === b.prompt &&
a.aspectRatio === b.aspectRatio &&
a.resolution === b.resolution &&
a.selectedModel === b.selectedModel &&
a.inputImages === b.inputImages &&
a.imageCount === b.imageCount &&
a.durationSeconds === b.durationSeconds &&
a.soundEnabled === b.soundEnabled &&
a.parameters === b.parameters
);
}
function nodeDraftFingerprint(context: ComposerContext): string {
if (context.mode !== "node-edit" || !context.node) return contextIdentity(context);
const data = context.node.data as NanoBananaNodeData | GenerateVideoNodeData | GenerateAudioNodeData;
@ -1051,6 +1049,7 @@ export function GenerationComposer() {
const identity = contextIdentity(context);
const fingerprint = nodeDraftFingerprint(context);
const [draft, setDraft] = useState(() => readDraftFromContext(context));
const [draftIdentity, setDraftIdentity] = useState(() => identity);
const [dirtyFields, setDirtyFields] = useState<Set<DraftField>>(() => new Set());
const [assistMenu, setAssistMenu] = useState<"command" | "reference" | null>(null);
const [modelDialogOpen, setModelDialogOpen] = useState(false);
@ -1114,7 +1113,16 @@ export function GenerationComposer() {
flushDraftForContext(previousContext);
contextRef.current = context;
setDraft(readDraftFromContext(context));
const nextDraft = readDraftFromContext(context);
setDraft((current) => {
if (composerDraftShallowEqual(current, nextDraft)) {
draftRef.current = current;
return current;
}
draftRef.current = nextDraft;
return nextDraft;
});
setDraftIdentity(identity);
clearDirtyFields();
}, [clearDirtyFields, context, flushDraftForContext, identity]);
@ -1123,7 +1131,7 @@ export function GenerationComposer() {
const nextDraft = readDraftFromContext(context);
setDraft((current) => {
const dirty = dirtyFieldsRef.current;
return {
const mergedDraft = {
prompt: dirty.has("prompt") ? current.prompt : nextDraft.prompt,
aspectRatio: dirty.has("aspectRatio") ? current.aspectRatio : nextDraft.aspectRatio,
resolution: dirty.has("resolution") ? current.resolution : nextDraft.resolution,
@ -1134,7 +1142,14 @@ export function GenerationComposer() {
soundEnabled: dirty.has("soundEnabled") ? current.soundEnabled : nextDraft.soundEnabled,
parameters: dirty.has("parameters") ? current.parameters : nextDraft.parameters,
};
if (composerDraftShallowEqual(current, mergedDraft)) {
draftRef.current = current;
return current;
}
draftRef.current = mergedDraft;
return mergedDraft;
});
setDraftIdentity(identity);
}, [context, fingerprint]);
const connectedInputs = useMemo(() => {
@ -1571,20 +1586,6 @@ export function GenerationComposer() {
? context.node?.data as NanoBananaNodeData | GenerateVideoNodeData | GenerateAudioNodeData | undefined
: undefined;
const activeParameterSchema = generationNodeData?.parameterSchema ?? [];
useEffect(() => {
if (context.mode !== "node-edit" || !context.node || activeParameterSchema.length === 0) return;
const normalized = applySchemaParameterDefaults(activeParameterSchema, draftRef.current.parameters);
if (!normalized.changed) return;
setDraft((current) => {
const currentNormalized = applySchemaParameterDefaults(activeParameterSchema, current.parameters);
if (!currentNormalized.changed) return current;
const next = { ...current, parameters: currentNormalized.parameters };
draftRef.current = next;
return next;
});
updateNodeData(context.node.id, { parameters: normalized.parameters });
}, [activeParameterSchema, context, updateNodeData]);
const gatewayDimensionSelects = buildGatewayDimensionSelects(activeParameterSchema, draft);
const imageCountOptions = getImageCountOptions(activeParameterSchema);
const visibleImageCountOptions = imageCountOptions.length > 0
@ -1592,7 +1593,7 @@ export function GenerationComposer() {
: DEFAULT_IMAGE_COUNT_OPTIONS;
const imageCountParameterName = getSchemaParameterName(activeParameterSchema, IMAGE_COUNT_PARAMETER_NAMES);
const selectedImageCountValue = imageCountParameterName
? Number(draft.parameters?.[imageCountParameterName] ?? draft.imageCount)
? draft.imageCount
: draft.imageCount;
const videoSoundParameterName = getSchemaParameterName(activeParameterSchema, VIDEO_SOUND_PARAMETER_NAMES);
const audioVoiceSelect = useMemo(() => {
@ -1733,11 +1734,18 @@ export function GenerationComposer() {
}, []);
const updateSchemaDraftParameter = useCallback(
(name: string, value: unknown, patch: Partial<ComposerDraft> = {}, fields: DraftField[] = []) => {
(
name: string,
value: unknown,
patch: Partial<ComposerDraft> = {},
fields: DraftField[] = [],
expectedContextIdentity?: string
) => {
const parameters = updateParameterValue(draftRef.current.parameters, name, value);
const nextPatch = { ...patch, parameters };
const updatedFields: DraftField[] = [...fields, "parameters"];
const activeContext = contextRef.current;
if (expectedContextIdentity && contextIdentity(activeContext) !== expectedContextIdentity) return;
setDraft((current) => {
const next = { ...current, ...nextPatch };
@ -1770,14 +1778,15 @@ export function GenerationComposer() {
const resolutionSelect = gatewayDimensionSelects.find((select) => select.parameter.name === "resolution");
if (
isProcessNode ||
activeCapability !== "image" ||
(activeCapability !== "image" && activeCapability !== "video") ||
draftIdentity !== identity ||
!resolutionSelect ||
resolutionSelect.options.length === 0
) {
return;
}
const currentResolution = draft.parameters?.[resolutionSelect.parameter.name] ?? draft.resolution;
const currentResolution = draft.resolution ?? draft.parameters?.[resolutionSelect.parameter.name];
if (valueMatchesSchemaOptions(
currentResolution,
resolutionSelect.options.map((option) => String(option.value))
@ -1792,14 +1801,17 @@ export function GenerationComposer() {
resolutionSelect.parameter.name,
nextResolution,
{ resolution: String(nextResolution) },
["resolution"]
["resolution"],
identity
);
}, [
activeCapability,
activeParameterSchema,
draft.parameters,
draft.resolution,
draftIdentity,
gatewayDimensionSelects,
identity,
isProcessNode,
updateSchemaDraftParameter,
]);
@ -1832,6 +1844,8 @@ export function GenerationComposer() {
const activeContext = contextRef.current;
const nextResolution = modelSupportsCapability(nextModel, "video")
? DEFAULT_VIDEO_RESOLUTION
: modelSupportsCapability(nextModel, "image")
? "2K"
: draftRef.current.resolution;
if (activeContext.mode === "node-edit") {
@ -1857,11 +1871,13 @@ export function GenerationComposer() {
? DEFAULT_VIDEO_DURATION_SECONDS
: draftRef.current.durationSeconds,
soundEnabled: getDefaultVideoSoundEnabled(nextModel),
parameters: {},
},
new Set<DraftField>(["selectedModel"])
new Set<DraftField>(["selectedModel", "resolution", "parameters"])
);
updateNodeData(activeContext.node.id, modelPatch);
setDraft((current) => ({
setDraft((current) => {
const next = {
...current,
selectedModel: nextModel,
resolution: nextResolution,
@ -1870,24 +1886,33 @@ export function GenerationComposer() {
: current.durationSeconds,
soundEnabled: getDefaultVideoSoundEnabled(nextModel),
parameters: {},
}));
};
draftRef.current = next;
return next;
});
clearDirtyFields();
return;
}
if (activeContext.mode === "empty-create") {
setDraft((current) => ({
setDraft((current) => {
const next = {
...current,
selectedModel: nextModel,
resolution: modelSupportsCapability(nextModel, "video")
? DEFAULT_VIDEO_RESOLUTION
: modelSupportsCapability(nextModel, "image")
? "2K"
: current.resolution,
durationSeconds: modelSupportsCapability(nextModel, "video")
? DEFAULT_VIDEO_DURATION_SECONDS
: current.durationSeconds,
soundEnabled: getDefaultVideoSoundEnabled(nextModel),
parameters: {},
}));
};
draftRef.current = next;
return next;
});
}
},
[clearDirtyFields, updateNodeData]

2
src/components/nodes/GenerateAudioNode.tsx

@ -25,7 +25,6 @@ import { useI18n } from "@/i18n";
import { formatUserFacingError } from "@/utils/userFacingErrors";
import { chooseModelFromList, shouldUpdateSelectedModelFromProviderModel, toSelectedModel } from "@/utils/modelSelection";
import { useModelStore } from "@/store/modelStore";
import { buildDefaultParametersFromSchema } from "@/utils/modelSchemaDefaults";
import { withSpeechVoiceParameter } from "@/utils/speechVoiceParameter";
type GenerateAudioNodeType = Node<GenerateAudioNodeData, "generateAudio">;
@ -149,7 +148,6 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
updateNodeData(id, {
inputSchema: inputs,
parameterSchema: nextParameterSchema,
parameters: buildDefaultParametersFromSchema(nextParameterSchema),
...(model && shouldUpdateSelectedModelFromProviderModel(nodeData.selectedModel, model)
? { selectedModel: toSelectedModel(model) }
: {}),

3
src/components/nodes/GenerateImageNode.tsx

@ -27,7 +27,6 @@ import { MediaPreviewModal } from "@/components/MediaPreviewModal";
import { createGeminiLegacyImageModel } from "@/store/utils/defaultImageModel";
import { getProviderDisplayName, isPopiProviderMode, POPI_PROVIDER_ID } from "@/lib/providerMode";
import { chooseModelFromList, shouldUpdateSelectedModelFromProviderModel, toSelectedModel } from "@/utils/modelSelection";
import { buildImageDefaultsFromSchema } from "@/utils/modelSchemaDefaults";
import { useModelStore } from "@/store/modelStore";
import {
isBrowserFileSystemPath,
@ -516,11 +515,9 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
enabled: Boolean(nodeData.selectedModel?.modelId) && !usesFirstClassImageControls && canFetchPopiModelSchema,
refreshKey: nodeData.modelSchemaRequestId,
onLoaded: ({ inputs, parameters, model }) => {
const schemaDefaults = buildImageDefaultsFromSchema(parameters);
updateNodeData(id, {
inputSchema: inputs,
parameterSchema: parameters,
...schemaDefaults,
...(model && shouldUpdateSelectedModelFromProviderModel(nodeData.selectedModel, model)
? { selectedModel: toSelectedModel(model) }
: {}),

3
src/components/nodes/GenerateVideoNode.tsx

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

6
src/store/execution/generateAudioExecutor.ts

@ -11,6 +11,7 @@ import { pollGenerateTask } from "./pollTaskCompletion";
import { runWithFallback } from "./runWithFallback";
import type { NodeExecutionContext } from "./types";
import { uploadPopiserverDynamicInputsForGeneration } from "@/utils/temporaryImageUpload";
import { buildDefaultParametersFromSchema, mergeSchemaDefaults } from "@/utils/modelSchemaDefaults";
export interface GenerateAudioOptions {
/** When true, falls back to stored inputPrompt if no connections provide it. */
@ -83,7 +84,10 @@ export async function executeGenerateAudio(
images: [],
prompt: text,
selectedModel: modelToUse,
parameters: parametersOverride ?? nodeData.parameters,
parameters: mergeSchemaDefaults(
buildDefaultParametersFromSchema(nodeData.parameterSchema ?? []),
parametersOverride ?? nodeData.parameters
),
dynamicInputs: preparedDynamicInputs.dynamicInputs,
mediaType: "audio" as const,
};

10
src/store/execution/generateVideoExecutor.ts

@ -32,7 +32,8 @@ import {
uploadTemporaryDynamicInputsForGeneration,
uploadTemporaryImagesForGeneration,
} from "@/utils/temporaryImageUpload";
import { mergeConfiguredParameters } from "./generationParameters";
import { mergeConfiguredParametersOverride } from "./generationParameters";
import { buildDefaultParametersFromSchema, mergeSchemaDefaults } from "@/utils/modelSchemaDefaults";
export interface GenerateVideoOptions {
/** When true, falls back to stored inputImages/inputPrompt if no connections provide them. */
@ -44,13 +45,16 @@ function buildVideoParameters(
modelToUse: SelectedModel,
parametersOverride?: Record<string, unknown>
): Record<string, unknown> | undefined {
const parameters = { ...(parametersOverride ?? nodeData.parameters ?? {}) };
const parameters = mergeSchemaDefaults(
buildDefaultParametersFromSchema(nodeData.parameterSchema ?? []),
parametersOverride ?? nodeData.parameters
);
const durationSeconds = normalizeVideoDurationSeconds(nodeData.durationSeconds);
const resolution = nodeData.resolution ?? DEFAULT_VIDEO_RESOLUTION;
const soundParameterName = getVideoSoundParameterName(modelToUse);
if (modelToUse.provider === "popiserver") {
const merged = mergeConfiguredParameters(parameters, {
const merged = mergeConfiguredParametersOverride(parameters, {
...(nodeData.aspectRatio ? { aspectRatio: nodeData.aspectRatio, aspect_ratio: nodeData.aspectRatio } : {}),
resolution,
duration: durationSeconds,

13
src/store/execution/generationParameters.ts

@ -30,6 +30,19 @@ export function mergeConfiguredParameters(
return parameters;
}
export function mergeConfiguredParametersOverride(
base: Record<string, unknown> | undefined,
configured: Record<string, unknown>
): Record<string, unknown> {
const parameters = { ...(base ?? {}) };
for (const [key, value] of Object.entries(configured)) {
if (value !== undefined && value !== null) {
parameters[key] = value;
}
}
return parameters;
}
export function resolveImageGenerationCount(
parameters: Record<string, unknown> | undefined,
nodeImageCount: number | undefined

22
src/store/execution/nanoBananaExecutor.ts

@ -35,10 +35,10 @@ import {
uploadTemporaryImagesForGeneration,
} from "@/utils/temporaryImageUpload";
import {
mergeConfiguredParameters,
mergeImageGenerationParameters,
mergeConfiguredParametersOverride,
resolveImageGenerationCount,
} from "./generationParameters";
import { buildDefaultParametersFromSchema, mergeSchemaDefaults } from "@/utils/modelSchemaDefaults";
export interface NanoBananaOptions {
/** When true, falls back to stored inputImages/inputPrompt if no connections provide them. */
@ -166,10 +166,11 @@ export async function executeNanoBanana(
...preparedDynamicInputs.cleanupIds,
];
const requestedImageCount = resolveImageGenerationCount(
parametersOverride ?? nodeData.parameters,
nodeData.imageCount
const baseParameters = mergeSchemaDefaults(
buildDefaultParametersFromSchema(nodeData.parameterSchema ?? []),
parametersOverride ?? nodeData.parameters
);
const requestedImageCount = resolveImageGenerationCount(baseParameters, nodeData.imageCount);
const configuredParameters = {
aspectRatio: (parametersOverride?.aspectRatio as string | undefined) ?? nodeData.aspectRatio,
aspect_ratio: (parametersOverride?.aspectRatio as string | undefined) ?? nodeData.aspectRatio,
@ -179,12 +180,11 @@ export async function executeNanoBanana(
useImageSearch: (parametersOverride?.useImageSearch as boolean | undefined) ?? nodeData.useImageSearch,
};
const effectiveParameters = provider === "popiserver"
? mergeImageGenerationParameters(
parametersOverride ?? nodeData.parameters,
configuredParameters,
requestedImageCount
)
: mergeConfiguredParameters(parametersOverride ?? nodeData.parameters, configuredParameters);
? {
...mergeConfiguredParametersOverride(baseParameters, configuredParameters),
batchSize: requestedImageCount,
}
: mergeConfiguredParametersOverride(baseParameters, configuredParameters);
const requestPayload = {
images: preparedImages.images,
prompt: finalPrompt,

31
src/utils/__tests__/modelSchemaDefaults.test.ts

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { mergeSchemaDefaults } from "@/utils/modelSchemaDefaults";
describe("mergeSchemaDefaults", () => {
it("fills missing schema defaults without overwriting saved parameters", () => {
expect(
mergeSchemaDefaults(
{
resolution: "720p",
duration: 5,
quality: "standard",
},
{
resolution: "1080p",
prompt_strength: 0.8,
}
)
).toEqual({
resolution: "1080p",
duration: 5,
quality: "standard",
prompt_strength: 0.8,
});
});
it("returns defaults when there are no saved parameters", () => {
expect(mergeSchemaDefaults({ voice: "default" }, undefined)).toEqual({
voice: "default",
});
});
});

10
src/utils/modelSchemaDefaults.ts

@ -26,6 +26,16 @@ export function buildDefaultParametersFromSchema(schema: ModelParameter[]): Reco
return parameters;
}
export function mergeSchemaDefaults(
defaults: Record<string, unknown>,
existing: Record<string, unknown> | undefined
): Record<string, unknown> {
return {
...defaults,
...(existing ?? {}),
};
}
export function buildImageDefaultsFromSchema(schema: ModelParameter[]): {
aspectRatio?: AspectRatio;
resolution?: string;

Loading…
Cancel
Save