You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

437 lines
14 KiB

"use client";
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { Select } from "antd";
import { ProviderType } from "@/types";
import { ModelParameter } from "@/lib/providers/types";
import { TranslationKey, useI18n } from "@/i18n";
/** Reorder items so they read column-first in a row-based CSS grid.
* e.g. [1,2,3,4,5,6,7,8] with 2 cols → [1,5,2,6,3,7,4,8] */
function reorderColumnFirst<T>(items: T[], cols: number): T[] {
const rows = Math.ceil(items.length / cols);
const result: T[] = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const idx = c * rows + r;
if (idx < items.length) result.push(items[idx]);
}
}
return result;
}
interface ModelParametersProps {
modelId?: string;
provider?: ProviderType;
schema?: ModelParameter[];
isLoading?: boolean;
error?: string | null;
parameters: Record<string, unknown>;
onParametersChange: (parameters: Record<string, unknown>) => void;
onExpandChange?: (expanded: boolean, parameterCount: number) => void;
hiddenParameterNames?: string[];
pruneHiddenParameters?: boolean;
}
/**
* Collapsible parameter inputs for external provider models.
* and renders appropriate inputs based on parameter types.
*/
function ModelParametersInner({
modelId,
schema = [],
isLoading = false,
error = null,
parameters,
onParametersChange,
onExpandChange,
hiddenParameterNames,
pruneHiddenParameters = true,
}: ModelParametersProps) {
const { t } = useI18n();
const lastExpandCountRef = useRef<number | null>(null);
const hiddenParameterSet = useMemo(
() => new Set(hiddenParameterNames ?? []),
[hiddenParameterNames]
);
const visibleSchema = useMemo(
() => schema.filter((param) => !hiddenParameterSet.has(param.name)),
[hiddenParameterSet, schema]
);
const visibleParameterCount = visibleSchema.length;
// Remove parameters controlled by a higher-priority surface, such as the bottom composer.
useEffect(() => {
if (!pruneHiddenParameters || hiddenParameterSet.size === 0) return;
const nextParameters = { ...parameters };
let changed = false;
for (const name of hiddenParameterSet) {
if (Object.prototype.hasOwnProperty.call(nextParameters, name)) {
delete nextParameters[name];
changed = true;
}
}
if (changed) {
onParametersChange(nextParameters);
}
}, [hiddenParameterSet, onParametersChange, parameters, pruneHiddenParameters]);
// Pre-populate visible schema defaults into parameters
useEffect(() => {
if (visibleSchema.length === 0) return;
const baseParameters = { ...parameters };
if (pruneHiddenParameters) {
for (const name of hiddenParameterSet) {
delete baseParameters[name];
}
}
const defaults: Record<string, unknown> = {};
let hasNewDefaults = false;
for (const param of visibleSchema) {
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;
}
}
if (hasNewDefaults) {
onParametersChange({ ...baseParameters, ...defaults });
}
}, [visibleSchema, parameters, onParametersChange, hiddenParameterSet, pruneHiddenParameters]);
// Notify parent to resize node when schema loads
useEffect(() => {
if (visibleParameterCount === 0) {
lastExpandCountRef.current = null;
return;
}
if (lastExpandCountRef.current === visibleParameterCount) return;
lastExpandCountRef.current = visibleParameterCount;
if (visibleParameterCount > 0 && onExpandChange) {
onExpandChange(true, visibleParameterCount);
}
}, [visibleParameterCount, onExpandChange]);
const handleParameterChange = useCallback(
(name: string, value: unknown) => {
// Create new parameters object with updated value
const newParams = { ...parameters };
// If value is empty/undefined, remove the parameter
if (value === "" || value === undefined || value === null) {
delete newParams[name];
} else {
newParams[name] = value;
}
onParametersChange(newParams);
},
[parameters, onParametersChange]
);
const sortedSchema = useMemo(() => {
return [...visibleSchema].sort((a, b) => {
// Sort order: dropdowns first, then numbers, then strings, then checkboxes last
const typeOrder = (p: ModelParameter) => {
if (p.options && p.options.length > 0) return 0; // dropdowns first
if (p.type === "number" || p.type === "integer") return 1;
if (p.type === "boolean") return 3; // checkboxes last
return 2; // string and other
};
return typeOrder(a) - typeOrder(b);
});
}, [visibleSchema]);
const useGrid = sortedSchema.length > 4;
const gridRef = useRef<HTMLDivElement>(null);
const [colCount, setColCount] = useState(1);
useEffect(() => {
const el = gridRef.current;
if (!el || !useGrid) { setColCount(1); return; }
let rafId: number;
const observer = new ResizeObserver(() => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
const cols = getComputedStyle(el).gridTemplateColumns.split(" ").length;
setColCount(prev => prev === cols ? prev : cols);
});
});
observer.observe(el);
return () => {
cancelAnimationFrame(rafId);
observer.disconnect();
};
}, [useGrid]);
const displaySchema = useMemo(() => {
return useGrid && colCount > 1
? reorderColumnFirst(sortedSchema, colCount)
: sortedSchema;
}, [sortedSchema, useGrid, colCount]);
// Don't render if no model selected
if (!modelId) {
return null;
}
// Don't render if no schema available and not loading
if (!isLoading && visibleSchema.length === 0 && !error) {
return null;
}
return (
<div className="shrink-0">
{error ? (
<span className="text-[9px] text-red-400">{error}</span>
) : isLoading ? (
<span className="text-[9px] text-neutral-500">{t("node.loadingParameters")}</span>
) : visibleSchema.length === 0 ? (
<span className="text-[9px] text-neutral-500">{t("node.noParameters")}</span>
) : (
<div
ref={gridRef}
className={useGrid
? "grid grid-cols-[repeat(auto-fill,minmax(min(180px,100%),1fr))] max-w-[420px] gap-x-6 gap-y-1.5"
: "space-y-1.5 max-w-[280px]"
}
>
{displaySchema.map((param) => (
<ParameterInput
key={param.name}
param={param}
name={param.name}
value={parameters[param.name]}
onChange={handleParameterChange}
t={t}
/>
))}
</div>
)}
</div>
);
}
interface ParameterInputProps {
param: ModelParameter;
name: string;
value: unknown;
onChange: (name: string, value: unknown) => void;
t: (key: TranslationKey, vars?: Record<string, string | number>) => string;
}
/**
* Individual parameter input based on type.
* Text and number inputs use local state during editing to prevent
* cursor-jump issues caused by React Flow re-renders on store updates.
*/
function ParameterInputInner({ param, name, value, onChange, t }: ParameterInputProps) {
// Stable callback that passes name along with value
const handleChange = useCallback((value: unknown) => {
onChange(name, value);
}, [name, onChange]);
const labelMap: Record<string, TranslationKey> = {
size: "node.size",
quality: "node.quality",
voice: "node.voice",
model: "node.model",
aspect_ratio: "node.aspectRatio",
resolution: "node.resolution",
duration: "node.duration",
duration_seconds: "node.duration",
durationSeconds: "node.duration",
sound: "node.sound",
generate_audio: "node.sound",
enable_audio: "node.sound",
with_audio: "node.sound",
audio_enabled: "node.sound",
};
const displayName = param.label ?? (labelMap[param.name]
? t(labelMap[param.name])
: param.name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()));
// Local state for text/number inputs to prevent cursor jumping
const [localValue, setLocalValue] = useState<string>(() => {
if (value === undefined || value === null) return "";
return String(value);
});
const isFocusedRef = useRef(false);
// Sync from store when not focused (external changes)
useEffect(() => {
if (!isFocusedRef.current) {
setLocalValue(value === undefined || value === null ? "" : String(value));
}
}, [value]);
// Determine input type and render accordingly
if (param.options && param.options.length > 0) {
const selectOptions = param.options.map((option, index) => ({
value: String(index),
label: option.label,
rawValue: option.value,
}));
const selectedOption = selectOptions.find((option) =>
Object.is(option.rawValue, value) || String(option.rawValue) === String(value ?? "")
);
// Render label/value option pairs from the model schema.
return (
<div className="flex items-center gap-2">
<label
className="text-[11px] text-neutral-400 shrink-0"
title={param.description || undefined}
>
{displayName}
</label>
<Select
value={selectedOption?.value}
onChange={(optionKey) => {
const option = selectOptions.find((item) => item.value === optionKey);
handleChange(option?.rawValue);
}}
options={selectOptions.map((option) => ({
value: option.value,
label: option.label,
}))}
size="small"
popupMatchSelectWidth={false}
className="nodrag nopan flex-1 min-w-0 text-[11px] [&_.ant-select-selection-item]:text-[11px] [&_.ant-select-selector]:!text-[11px]"
classNames={{
popup: {
root: "nodrag nopan [&_.ant-select-item-option-content]:text-[11px]",
},
}}
/>
</div>
);
}
if (param.type === "boolean") {
// Use schema default when value not explicitly set
const effectiveValue = value !== undefined ? Boolean(value) : Boolean(param.default);
// Boolean: render as checkbox
return (
<label
className="flex items-center gap-1.5 text-[11px] text-neutral-300 cursor-pointer"
title={param.description || undefined}
>
<input
type="checkbox"
checked={effectiveValue}
onChange={(e) => handleChange(e.target.checked)}
className="nodrag nopan w-3 h-3 rounded bg-[#1a1a1a] text-neutral-600 focus:ring-1 focus:ring-neutral-600 focus:ring-offset-0"
/>
<span>{displayName}</span>
</label>
);
}
if (param.type === "number" || param.type === "integer") {
const hasMin = param.minimum !== undefined;
const hasMax = param.maximum !== undefined;
// Validate current value against constraints
let validationError: string | null = null;
if (localValue !== "" && !isNaN(Number(localValue))) {
const num = Number(localValue);
if (hasMin && num < param.minimum!) {
validationError = `Min: ${param.minimum}`;
} else if (hasMax && num > param.maximum!) {
validationError = `Max: ${param.maximum}`;
} else if (param.type === "integer" && !Number.isInteger(num)) {
validationError = "Must be integer";
}
}
return (
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<label
className="text-[11px] text-neutral-400 shrink-0 flex items-center gap-1"
title={param.description || undefined}
>
{displayName}
{hasMin && hasMax && (
<span className="text-neutral-500 text-[9px]">
({param.minimum}-{param.maximum})
</span>
)}
</label>
<input
type="number"
value={localValue}
min={param.minimum}
max={param.maximum}
step={param.type === "integer" ? 1 : 0.1}
onFocus={() => { isFocusedRef.current = true; }}
onChange={(e) => {
setLocalValue(e.target.value);
}}
onBlur={() => {
isFocusedRef.current = false;
if (localValue === "") {
handleChange(undefined);
} else {
const num = param.type === "integer" ? parseInt(localValue, 10) : parseFloat(localValue);
handleChange(isNaN(num) ? undefined : num);
}
}}
placeholder={param.default !== undefined ? `${param.default}` : undefined}
className={`nodrag nopan flex-1 min-w-0 text-[11px] py-1 px-2 rounded-md bg-[#1a1a1a] focus:outline-none focus:ring-1 text-white placeholder:text-neutral-500 ${
validationError
? "ring-1 ring-red-500"
: "focus:ring-neutral-600"
}`}
/>
</div>
{validationError && (
<span className="text-[9px] text-red-400">{validationError}</span>
)}
</div>
);
}
// Skip array type for now (complex)
if (param.type === "array") {
return null;
}
// Default: string input — uses local state, syncs to store on blur
return (
<div className="flex items-center gap-2">
<label
className="text-[11px] text-neutral-400 shrink-0"
title={param.description || undefined}
>
{displayName}
</label>
<input
type="text"
value={localValue}
onFocus={() => { isFocusedRef.current = true; }}
onChange={(e) => {
setLocalValue(e.target.value);
}}
onBlur={() => {
isFocusedRef.current = false;
handleChange(localValue || undefined);
}}
placeholder={param.default !== undefined ? `${param.default}` : undefined}
className="nodrag nopan flex-1 min-w-0 text-[11px] py-1 px-2 rounded-md bg-[#1a1a1a] focus:outline-none focus:ring-1 focus:ring-neutral-600 text-white placeholder:text-neutral-500"
/>
</div>
);
}
// Memoized exports to prevent unnecessary re-renders
export const ModelParameters = React.memo(ModelParametersInner);
const ParameterInput = React.memo(ParameterInputInner);