"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(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; onParametersChange: (parameters: Record) => 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(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 = {}; 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(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 (
{error ? ( {error} ) : isLoading ? ( {t("node.loadingParameters")} ) : visibleSchema.length === 0 ? ( {t("node.noParameters")} ) : (
{displaySchema.map((param) => ( ))}
)}
); } interface ParameterInputProps { param: ModelParameter; name: string; value: unknown; onChange: (name: string, value: unknown) => void; t: (key: TranslationKey, vars?: Record) => 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 = { 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(() => { 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 (
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" /> {displayName} ); } 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 (
{ 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" }`} />
{validationError && ( {validationError} )}
); } // Skip array type for now (complex) if (param.type === "array") { return null; } // Default: string input — uses local state, syncs to store on blur return (
{ 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" />
); } // Memoized exports to prevent unnecessary re-renders export const ModelParameters = React.memo(ModelParametersInner); const ParameterInput = React.memo(ParameterInputInner);