"use client"; import { useState, useEffect, useCallback, useRef } from "react"; import { ProviderType, ModelInputDef } from "@/types"; import { ModelParameter } from "@/lib/providers/types"; import { useProviderApiKeys } from "@/store/workflowStore"; import { deduplicatedFetch } from "@/utils/deduplicatedFetch"; // localStorage cache for model schemas (persists across dev server restarts) const SCHEMA_CACHE_KEY = "node-banana-schema-cache"; const SCHEMA_CACHE_TTL = 48 * 60 * 60 * 1000; // 48 hours interface SchemaCacheEntry { parameters: ModelParameter[]; inputs: ModelInputDef[]; timestamp: number; } function getCachedSchema(modelId: string, provider: string): SchemaCacheEntry | null { try { const cache = JSON.parse(localStorage.getItem(SCHEMA_CACHE_KEY) || "{}"); const key = `${provider}:${modelId}`; const entry = cache[key]; if (entry && Date.now() - entry.timestamp < SCHEMA_CACHE_TTL) { return entry; } } catch { // Ignore cache errors } return null; } function setCachedSchema(modelId: string, provider: string, parameters: ModelParameter[], inputs: ModelInputDef[]) { try { const cache = JSON.parse(localStorage.getItem(SCHEMA_CACHE_KEY) || "{}"); cache[`${provider}:${modelId}`] = { parameters, inputs, timestamp: Date.now() }; localStorage.setItem(SCHEMA_CACHE_KEY, JSON.stringify(cache)); } catch { // Ignore cache errors } } /** 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; parameters: Record; onParametersChange: (parameters: Record) => void; onExpandChange?: (expanded: boolean, parameterCount: number) => void; onInputsLoaded?: (inputs: ModelInputDef[]) => void; } /** * Collapsible parameter inputs for external provider models. * Fetches schema from /api/models/{modelId}?provider={provider} * and renders appropriate inputs based on parameter types. */ export function ModelParameters({ modelId, provider, parameters, onParametersChange, onExpandChange, onInputsLoaded, }: ModelParametersProps) { const [schema, setSchema] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); // Use stable selector for API keys to prevent unnecessary re-fetches const { replicateApiKey, falApiKey, kieApiKey, wavespeedApiKey } = useProviderApiKeys(); // Fetch schema when modelId changes useEffect(() => { if (!modelId || provider === "gemini") { setSchema([]); onInputsLoaded?.([]); return; } const fetchSchema = async () => { // Check localStorage cache first const cached = getCachedSchema(modelId, provider); if (cached) { setSchema(cached.parameters); onInputsLoaded?.(cached.inputs); return; } setIsLoading(true); setError(null); try { const headers: HeadersInit = {}; if (replicateApiKey) { headers["X-Replicate-Key"] = replicateApiKey; } if (falApiKey) { headers["X-Fal-Key"] = falApiKey; } if (kieApiKey) { headers["X-Kie-Key"] = kieApiKey; } if (wavespeedApiKey) { headers["X-WaveSpeed-Key"] = wavespeedApiKey; } const encodedModelId = encodeURIComponent(modelId); const response = await deduplicatedFetch( `/api/models/${encodedModelId}?provider=${provider}`, { headers } ); if (!response.ok) { const data = await response.json(); throw new Error(data.error || `Failed to fetch schema: ${response.status}`); } const data = await response.json(); const params = data.parameters || []; const inputs = data.inputs || []; setSchema(params); // Cache the successful result setCachedSchema(modelId, provider, params, inputs); // Pass inputs to parent for dynamic handle rendering if (onInputsLoaded) { onInputsLoaded(inputs); } } catch (err) { console.error("Failed to fetch model schema:", err); setError(err instanceof Error ? err.message : "Failed to fetch schema"); setSchema([]); } finally { setIsLoading(false); } }; fetchSchema(); }, [modelId, provider, replicateApiKey, falApiKey, kieApiKey, wavespeedApiKey, onInputsLoaded]); // Notify parent to resize node when schema loads useEffect(() => { if (schema.length > 0 && onExpandChange) { onExpandChange(true, schema.length); } }, [schema, 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 = [...schema].sort((a, b) => { // Sort order: dropdowns first, then numbers, then strings, then checkboxes last const typeOrder = (p: ModelParameter) => { if (p.enum && p.enum.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); }); 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; } const observer = new ResizeObserver(() => { const cols = getComputedStyle(el).gridTemplateColumns.split(" ").length; setColCount(cols); }); observer.observe(el); return () => observer.disconnect(); }, [useGrid]); // Don't render anything for Gemini or if no model selected if (provider === "gemini" || !modelId) { return null; } // Don't render if no schema available and not loading if (!isLoading && schema.length === 0 && !error) { return null; } const displaySchema = useGrid && colCount > 1 ? reorderColumnFirst(sortedSchema, colCount) : sortedSchema; return (
{error ? ( {error} ) : isLoading ? ( Loading parameters... ) : schema.length === 0 ? ( No parameters available ) : (
{displaySchema.map((param) => ( handleParameterChange(param.name, value)} /> ))}
)}
); } interface ParameterInputProps { param: ModelParameter; value: unknown; onChange: (value: unknown) => void; } /** * 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 ParameterInput({ param, value, onChange }: ParameterInputProps) { const displayName = 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.enum && param.enum.length > 0) { // Enum: render as select return (
); } 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 ( ); } 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 === "") { onChange(undefined); } else { const num = param.type === "integer" ? parseInt(localValue, 10) : parseFloat(localValue); onChange(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; onChange(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" />
); }