Browse Source

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
handoff-20260429-1057
shrimbly 6 months ago
parent
commit
1e60f18140
  1. 60
      src/components/nodes/GenerateImageNode.tsx
  2. 18
      src/components/nodes/GenerateVideoNode.tsx
  3. 290
      src/components/nodes/ModelParameters.tsx
  4. 2
      src/types/index.ts

60
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<NanoBananaNo
[id, updateNodeData]
);
const handleParametersChange = useCallback(
(parameters: Record<string, unknown>) => {
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<NanoBananaNo
))}
</select>
) : (
<select
value={nodeData.selectedModel?.modelId || ""}
onChange={handleExternalModelChange}
disabled={isLoadingModels}
className="w-full text-[10px] py-1 px-1.5 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300 shrink-0 disabled:opacity-50"
>
{isLoadingModels ? (
<option value="">Loading models...</option>
) : externalModels.length === 0 ? (
<option value="">No models available</option>
) : (
<>
<option value="">Select model...</option>
{externalModels.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</>
<>
<select
value={nodeData.selectedModel?.modelId || ""}
onChange={handleExternalModelChange}
disabled={isLoadingModels}
className="w-full text-[10px] py-1 px-1.5 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300 shrink-0 disabled:opacity-50"
>
{isLoadingModels ? (
<option value="">Loading models...</option>
) : externalModels.length === 0 ? (
<option value="">No models available</option>
) : (
<>
<option value="">Select model...</option>
{externalModels.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</>
)}
</select>
{/* Model-specific parameters for external providers */}
{nodeData.selectedModel?.modelId && (
<ModelParameters
modelId={nodeData.selectedModel.modelId}
provider={currentProvider}
parameters={nodeData.parameters || {}}
onParametersChange={handleParametersChange}
/>
)}
</select>
</>
)}
{/* Aspect ratio and resolution row - only for Gemini */}

18
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<GenerateVide
updateNodeData(id, { outputVideo: null, status: "idle", error: null });
}, [id, updateNodeData]);
const handleParametersChange = useCallback(
(parameters: Record<string, unknown>) => {
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<GenerateVide
</>
)}
</select>
{/* Model-specific parameters */}
{nodeData.selectedModel?.modelId && (
<ModelParameters
modelId={nodeData.selectedModel.modelId}
provider={currentProvider}
parameters={nodeData.parameters || {}}
onParametersChange={handleParametersChange}
/>
)}
</div>
</BaseNode>
);

290
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<string, unknown>;
onParametersChange: (parameters: Record<string, unknown>) => 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<ModelParameter[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="shrink-0">
{/* Collapsible header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between text-[10px] text-neutral-400 hover:text-neutral-300 transition-colors py-0.5"
>
<span className="flex items-center gap-1">
<svg
className={`w-2.5 h-2.5 transition-transform ${isExpanded ? "rotate-90" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
Parameters
{Object.keys(parameters).length > 0 && (
<span className="text-neutral-500">({Object.keys(parameters).length})</span>
)}
</span>
{isLoading && (
<svg className="w-2.5 h-2.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)}
</button>
{/* Parameter inputs (when expanded) */}
{isExpanded && (
<div className="mt-1 space-y-1.5 max-h-[160px] overflow-y-auto">
{error ? (
<span className="text-[9px] text-red-400">{error}</span>
) : isLoading ? (
<span className="text-[9px] text-neutral-500">Loading parameters...</span>
) : schema.length === 0 ? (
<span className="text-[9px] text-neutral-500">No parameters available</span>
) : (
schema.map((param) => (
<ParameterInput
key={param.name}
param={param}
value={parameters[param.name]}
onChange={(value) => handleParameterChange(param.name, value)}
/>
))
)}
</div>
)}
</div>
);
}
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 (
<div className="flex flex-col gap-0.5">
<label
className="text-[9px] text-neutral-400"
title={param.description || undefined}
>
{displayName}
</label>
<select
value={(value as string) ?? ""}
onChange={(e) => onChange(e.target.value || 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"
>
<option value="">Default</option>
{param.enum.map((opt) => (
<option key={String(opt)} value={String(opt)}>
{String(opt)}
</option>
))}
</select>
</div>
);
}
if (param.type === "boolean") {
// Boolean: render as checkbox
return (
<label
className="flex items-center gap-1.5 text-[9px] text-neutral-400 cursor-pointer"
title={param.description || undefined}
>
<input
type="checkbox"
checked={Boolean(value)}
onChange={(e) => onChange(e.target.checked ? true : undefined)}
className="w-2.5 h-2.5 rounded border-neutral-700 bg-neutral-900/50 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") {
// Number: render as number input
const numValue = value !== undefined ? Number(value) : "";
return (
<div className="flex flex-col gap-0.5">
<label
className="text-[9px] text-neutral-400 flex items-center gap-1"
title={param.description || undefined}
>
{displayName}
{param.minimum !== undefined && param.maximum !== undefined && (
<span className="text-neutral-500">
({param.minimum}-{param.maximum})
</span>
)}
</label>
<input
type="number"
value={numValue}
min={param.minimum}
max={param.maximum}
step={param.type === "integer" ? 1 : 0.1}
onChange={(e) => {
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"
/>
</div>
);
}
// Skip array type for now (complex)
if (param.type === "array") {
return null;
}
// Default: string input
return (
<div className="flex flex-col gap-0.5">
<label
className="text-[9px] text-neutral-400"
title={param.description || undefined}
>
{displayName}
</label>
<input
type="text"
value={(value as string) ?? ""}
onChange={(e) => 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"
/>
</div>
);
}

2
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<string, unknown>; // 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<string, unknown>; // Model-specific parameters
status: NodeStatus;
error: string | null;
}

Loading…
Cancel
Save