From 1e60f18140a736e4b2c72c0088b0c3918a9070df Mon Sep 17 00:00:00 2001 From: shrimbly Date: Sat, 10 Jan 2026 22:42:57 +1300 Subject: [PATCH] feat(06-03): add ModelParameters component for dynamic parameter inputs - Create ModelParameters component with collapsible UI - Fetches schema from /api/models/{modelId} endpoint - Renders appropriate inputs based on parameter type (string, number, boolean, enum) - Add parameters field to NanoBananaNodeData and GenerateVideoNodeData - Integrate into GenerateImageNode and GenerateVideoNode --- src/components/nodes/GenerateImageNode.tsx | 60 +++-- src/components/nodes/GenerateVideoNode.tsx | 18 ++ src/components/nodes/ModelParameters.tsx | 290 +++++++++++++++++++++ src/types/index.ts | 2 + 4 files changed, 350 insertions(+), 20 deletions(-) create mode 100644 src/components/nodes/ModelParameters.tsx diff --git a/src/components/nodes/GenerateImageNode.tsx b/src/components/nodes/GenerateImageNode.tsx index efc6ec6e..775bfff0 100644 --- a/src/components/nodes/GenerateImageNode.tsx +++ b/src/components/nodes/GenerateImageNode.tsx @@ -3,6 +3,7 @@ import { useCallback, useState, useEffect, useMemo } from "react"; import { Handle, Position, NodeProps, Node } from "@xyflow/react"; import { BaseNode } from "./BaseNode"; +import { ModelParameters } from "./ModelParameters"; import { useWorkflowStore, saveNanoBananaDefaults } from "@/store/workflowStore"; import { NanoBananaNodeData, AspectRatio, Resolution, ModelType, ProviderType, SelectedModel } from "@/types"; import { ProviderModel, ModelCapability } from "@/lib/providers/types"; @@ -187,6 +188,13 @@ export function GenerateImageNode({ id, data, selected }: NodeProps) => { + updateNodeData(id, { parameters }); + }, + [id, updateNodeData] + ); + const handleClearImage = useCallback(() => { updateNodeData(id, { outputImage: null, status: "idle", error: null }); }, [id, updateNodeData]); @@ -472,27 +480,39 @@ export function GenerateImageNode({ id, data, selected }: NodeProps ) : ( - + {isLoadingModels ? ( + + ) : externalModels.length === 0 ? ( + + ) : ( + <> + + {externalModels.map((m) => ( + + ))} + + )} + + + {/* Model-specific parameters for external providers */} + {nodeData.selectedModel?.modelId && ( + )} - + )} {/* Aspect ratio and resolution row - only for Gemini */} diff --git a/src/components/nodes/GenerateVideoNode.tsx b/src/components/nodes/GenerateVideoNode.tsx index 7875b959..1717d00a 100644 --- a/src/components/nodes/GenerateVideoNode.tsx +++ b/src/components/nodes/GenerateVideoNode.tsx @@ -3,6 +3,7 @@ import { useCallback, useState, useEffect, useMemo } from "react"; import { Handle, Position, NodeProps, Node } from "@xyflow/react"; import { BaseNode } from "./BaseNode"; +import { ModelParameters } from "./ModelParameters"; import { useWorkflowStore } from "@/store/workflowStore"; import { GenerateVideoNodeData, ProviderType, SelectedModel } from "@/types"; import { ProviderModel, ModelCapability } from "@/lib/providers/types"; @@ -101,6 +102,13 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps) => { + updateNodeData(id, { parameters }); + }, + [id, updateNodeData] + ); + const regenerateNode = useWorkflowStore((state) => state.regenerateNode); const isRunning = useWorkflowStore((state) => state.isRunning); @@ -263,6 +271,16 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps )} + + {/* Model-specific parameters */} + {nodeData.selectedModel?.modelId && ( + + )} ); diff --git a/src/components/nodes/ModelParameters.tsx b/src/components/nodes/ModelParameters.tsx new file mode 100644 index 00000000..471c8f00 --- /dev/null +++ b/src/components/nodes/ModelParameters.tsx @@ -0,0 +1,290 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { ProviderType } from "@/types"; +import { ModelParameter } from "@/lib/providers/types"; +import { useWorkflowStore } from "@/store/workflowStore"; + +interface ModelParametersProps { + modelId: string; + provider: ProviderType; + parameters: Record; + onParametersChange: (parameters: Record) => 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, +}: ModelParametersProps) { + const [schema, setSchema] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [isExpanded, setIsExpanded] = useState(false); + const providerSettings = useWorkflowStore((state) => state.providerSettings); + + // Fetch schema when modelId changes + useEffect(() => { + if (!modelId || provider === "gemini") { + setSchema([]); + return; + } + + const fetchSchema = async () => { + setIsLoading(true); + setError(null); + + try { + const headers: HeadersInit = {}; + if (providerSettings.providers.replicate?.apiKey) { + headers["X-Replicate-Key"] = providerSettings.providers.replicate.apiKey; + } + if (providerSettings.providers.fal?.apiKey) { + headers["X-Fal-Key"] = providerSettings.providers.fal.apiKey; + } + + const encodedModelId = encodeURIComponent(modelId); + const response = await fetch( + `/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(); + setSchema(data.parameters || []); + } 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, providerSettings]); + + 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") { + // Boolean: render as checkbox + return ( + + ); + } + + if (param.type === "number" || param.type === "integer") { + // Number: render as number input + const numValue = value !== undefined ? Number(value) : ""; + 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="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" + /> +
+ ); + } + + // 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="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" + /> +
+ ); +} diff --git a/src/types/index.ts b/src/types/index.ts index ba756069..c192fddd 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -145,6 +145,7 @@ export interface NanoBananaNodeData extends BaseNodeData { model: ModelType; selectedModel?: SelectedModel; // Multi-provider model selection (optional for backward compat) useGoogleSearch: boolean; // Only available for Nano Banana Pro + parameters?: Record; // Model-specific parameters for external providers status: NodeStatus; error: string | null; imageHistory: CarouselImageItem[]; // Carousel history (IDs only) @@ -159,6 +160,7 @@ export interface GenerateVideoNodeData extends BaseNodeData { outputVideo: string | null; // Video data URL or URL outputVideoRef?: string; // External video reference for storage optimization selectedModel?: SelectedModel; // Required for video generation (no legacy fallback) + parameters?: Record; // Model-specific parameters status: NodeStatus; error: string | null; }