"use client"; import { useState, useEffect, useCallback } 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 } } 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); const [isExpanded, setIsExpanded] = useState(true); // 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 and panel is expanded useEffect(() => { if (isExpanded && schema.length > 0 && onExpandChange) { onExpandChange(true, schema.length); } }, [schema, isExpanded, 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] ); // 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; } return (
{/* Collapsible header */} {/* Parameter inputs (when expanded) */} {isExpanded && (
{error ? ( {error} ) : isLoading ? ( Loading parameters... ) : schema.length === 0 ? ( No parameters available ) : ( schema.map((param) => ( handleParameterChange(param.name, value)} /> )) )}
)}
); } interface ParameterInputProps { param: ModelParameter; value: unknown; onChange: (value: unknown) => void; } /** * Individual parameter input based on type */ function ParameterInput({ param, value, onChange }: ParameterInputProps) { const displayName = param.name .replace(/_/g, " ") .replace(/\b\w/g, (c) => c.toUpperCase()); // 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") { // Number: render as number input with validation const numValue = value !== undefined ? Number(value) : ""; const hasMin = param.minimum !== undefined; const hasMax = param.maximum !== undefined; // Validate current value against constraints let validationError: string | null = null; if (value !== undefined && value !== "" && !isNaN(Number(value))) { const num = Number(value); 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 (
{ const val = e.target.value; if (val === "") { onChange(undefined); } else { const num = param.type === "integer" ? parseInt(val, 10) : parseFloat(val); onChange(isNaN(num) ? undefined : num); } }} placeholder={param.default !== undefined ? `Default: ${param.default}` : undefined} className={`nodrag nopan w-full text-[9px] py-0.5 px-1 border rounded bg-neutral-900/50 focus:outline-none focus:ring-1 text-neutral-300 placeholder:text-neutral-600 ${ validationError ? "border-red-500 focus:ring-red-500" : "border-neutral-700 focus:ring-neutral-600" }`} /> {validationError && ( {validationError} )}
); } // Skip array type for now (complex) if (param.type === "array") { return null; } // Default: string input return (
onChange(e.target.value || undefined)} placeholder={param.default !== undefined ? `Default: ${param.default}` : undefined} className="nodrag nopan w-full text-[9px] py-0.5 px-1 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300 placeholder:text-neutral-600" />
); }