You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
405 lines
13 KiB
405 lines
13 KiB
"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<string, unknown>;
|
|
onParametersChange: (parameters: Record<string, unknown>) => 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<ModelParameter[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(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 (
|
|
<div className="shrink-0">
|
|
{/* Collapsible header */}
|
|
<button
|
|
onClick={() => {
|
|
const newExpanded = !isExpanded;
|
|
setIsExpanded(newExpanded);
|
|
onExpandChange?.(newExpanded, schema.length);
|
|
}}
|
|
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">
|
|
{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) => {
|
|
const val = e.target.value;
|
|
if (val === "") {
|
|
onChange(undefined);
|
|
} else if (param.type === "integer") {
|
|
onChange(parseInt(val, 10));
|
|
} else if (param.type === "number") {
|
|
onChange(parseFloat(val));
|
|
} else if (param.type === "boolean") {
|
|
onChange(val === "true");
|
|
} else {
|
|
onChange(val);
|
|
}
|
|
}}
|
|
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"
|
|
>
|
|
<option value="">Default</option>
|
|
{param.enum.map((opt) => (
|
|
<option key={String(opt)} value={String(opt)}>
|
|
{String(opt)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<label
|
|
className="flex items-center gap-1.5 text-[9px] text-neutral-400 cursor-pointer"
|
|
title={param.description || undefined}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={effectiveValue}
|
|
onChange={(e) => onChange(e.target.checked)}
|
|
className="nodrag nopan 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 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 (
|
|
<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}
|
|
{hasMin && hasMax && (
|
|
<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={`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 && (
|
|
<span className="text-[8px] text-red-400">{validationError}</span>
|
|
)}
|
|
</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="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"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|