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.
 
 

1163 lines
45 KiB

"use client";
import { useMemo, useState, useCallback, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { Node } from "@xyflow/react";
import { useWorkflowStore, saveNanoBananaDefaults, useProviderApiKeys } from "@/store/workflowStore";
import { NodeType, NanoBananaNodeData, LLMGenerateNodeData, GenerateVideoNodeData, Generate3DNodeData, GenerateAudioNodeData, EaseCurveNodeData, ConditionalSwitchNodeData, AspectRatio, Resolution, ModelType, MODEL_DISPLAY_NAMES, ProviderType, SelectedModel, LLMProvider, LLMModelType, MatchMode, ConditionalSwitchRule } from "@/types";
import { ProviderModel, ModelCapability } from "@/lib/providers/types";
import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog";
import { ModelParameters } from "./ModelParameters";
import { CubicBezierEditor } from "@/components/CubicBezierEditor";
import { deduplicatedFetch } from "@/utils/deduplicatedFetch";
import { evaluateRule } from "@/store/utils/ruleEvaluation";
import { EASING_PRESETS, getPresetBezier, getEasingBezier } from "@/lib/easing-presets";
import { getAllEasingNames, getEasingFunction } from "@/lib/easing-functions";
import { getModelPageUrl, getProviderDisplayName } from "@/utils/providerUrls";
import { useInlineParameters } from "@/hooks/useInlineParameters";
// List of node types that have configurable parameters
const CONFIGURABLE_NODE_TYPES: NodeType[] = [
"nanoBanana",
"generateVideo",
"generate3d",
"generateAudio",
"llmGenerate",
"easeCurve",
"conditionalSwitch",
];
// Generation node types that can use inline parameters
const GENERATION_NODE_TYPES: NodeType[] = [
"nanoBanana",
"generateVideo",
"generate3d",
"generateAudio",
"llmGenerate",
];
// Base 10 aspect ratios (all Gemini image models)
const BASE_ASPECT_RATIOS: AspectRatio[] = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"];
// Extended 14 aspect ratios (Nano Banana 2 adds extreme ratios)
const EXTENDED_ASPECT_RATIOS: AspectRatio[] = ["1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"];
// Resolutions per model
const RESOLUTIONS_PRO: Resolution[] = ["1K", "2K", "4K"];
const RESOLUTIONS_NB2: Resolution[] = ["512", "1K", "2K", "4K"];
// Hardcoded Gemini image models
const GEMINI_IMAGE_MODELS: { value: ModelType; label: string }[] = [
{ value: "nano-banana", label: "Nano Banana" },
{ value: "nano-banana-2", label: "Nano Banana 2" },
{ value: "nano-banana-pro", label: "Nano Banana Pro" },
];
// LLM providers and models
const LLM_PROVIDERS: { value: LLMProvider; label: string }[] = [
{ value: "google", label: "Google" },
{ value: "openai", label: "OpenAI" },
{ value: "anthropic", label: "Anthropic" },
];
const LLM_MODELS: Record<LLMProvider, { value: LLMModelType; label: string }[]> = {
google: [
{ value: "gemini-3-flash-preview", label: "Gemini 3 Flash" },
{ value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
{ value: "gemini-3-pro-preview", label: "Gemini 3.0 Pro" },
{ value: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro" },
],
openai: [
{ value: "gpt-4.1-mini", label: "GPT-4.1 Mini" },
{ value: "gpt-4.1-nano", label: "GPT-4.1 Nano" },
],
anthropic: [
{ value: "claude-sonnet-4.5", label: "Claude Sonnet 4.5" },
{ value: "claude-haiku-4.5", label: "Claude Haiku 4.5" },
{ value: "claude-opus-4.6", label: "Claude Opus 4.6" },
],
};
// Image/video/audio/3d generation capabilities
const IMAGE_CAPABILITIES: ModelCapability[] = ["text-to-image", "image-to-image"];
const VIDEO_CAPABILITIES: ModelCapability[] = ["text-to-video", "image-to-video"];
const AUDIO_CAPABILITIES: ModelCapability[] = ["text-to-audio"];
const MODEL_3D_CAPABILITIES: ModelCapability[] = ["text-to-3d", "image-to-3d"];
// Easing names
const ALL_EASING_NAMES = getAllEasingNames();
const PRESET_NAMES = new Set(EASING_PRESETS);
// Generate SVG polyline for easing preview
function generateEasingPolyline(
easingName: string,
width: number,
height: number,
samples: number = 20
): string {
const fn = getEasingFunction(easingName);
return Array.from({ length: samples + 1 }, (_, i) => {
const t = i / samples;
const y = fn(t);
return `${(t * width).toFixed(1)},${((1 - y) * height).toFixed(1)}`;
}).join(" ");
}
/**
* Fixed-position control panel on the right side of viewport
* Displays controls for the currently selected node
*/
export function ControlPanel() {
const selectedNode = useWorkflowStore((state) => {
const selected = state.nodes.filter((n) => n.selected);
return selected.length === 1 ? selected[0] : null;
});
const { inlineParametersEnabled } = useInlineParameters();
// Check if the selected node is configurable
const isConfigurable = selectedNode && CONFIGURABLE_NODE_TYPES.includes(selectedNode.type as NodeType);
// If no single node selected or not configurable, hide panel
if (!selectedNode || !isConfigurable) {
return null;
}
// Check if this is a generation node
const isGenerationNode = selectedNode &&
GENERATION_NODE_TYPES.includes(selectedNode.type as NodeType);
// Hide for generation nodes when inline parameters enabled
if (isGenerationNode && inlineParametersEnabled) {
return null;
}
return (
<div className="fixed top-0 right-6 h-screen z-[90] flex items-center pointer-events-none">
<div
className="w-80 bg-neutral-800 border border-neutral-700 rounded-xl max-h-[80vh] overflow-y-auto pointer-events-auto transition-opacity duration-200 nowheel"
style={{
boxShadow: [
'-1px 0 2px rgba(0,0,0,0.18)',
'-2px 0 4px rgba(0,0,0,0.15)',
'-4px 0 8px rgba(0,0,0,0.12)',
'-8px 0 16px rgba(0,0,0,0.10)',
'-16px 0 32px rgba(0,0,0,0.08)',
'-32px 0 64px rgba(0,0,0,0.06)',
].join(', '),
}}
>
<div className="p-4">
{/* Header */}
<h3 className="text-sm font-medium text-neutral-200">
{getNodeTypeTitle(selectedNode.type as NodeType)}
</h3>
{/* Node-specific controls */}
<div className="space-y-4 mt-4">
{selectedNode.type === "nanoBanana" && (
<GenerateImageControls node={selectedNode} />
)}
{selectedNode.type === "generateVideo" && (
<GenerateVideoControls node={selectedNode} />
)}
{selectedNode.type === "generate3d" && (
<Generate3DControls node={selectedNode} />
)}
{selectedNode.type === "generateAudio" && (
<GenerateAudioControls node={selectedNode} />
)}
{selectedNode.type === "llmGenerate" && (
<LLMControls node={selectedNode} />
)}
{selectedNode.type === "easeCurve" && (
<EaseCurveControls node={selectedNode} />
)}
{selectedNode.type === "conditionalSwitch" && (
<ConditionalSwitchControls node={selectedNode} />
)}
</div>
</div>
</div>
</div>
);
}
function getNodeTypeTitle(type: NodeType): string {
const titles: Record<string, string> = {
nanoBanana: "Generate Image Settings",
generateVideo: "Generate Video Settings",
generate3d: "Generate 3D Settings",
generateAudio: "Generate Audio Settings",
llmGenerate: "LLM Settings",
easeCurve: "Ease Curve Settings",
conditionalSwitch: "Conditional Switch Settings",
};
return titles[type] || "Settings";
}
// Generate Image Controls
function GenerateImageControls({ node }: { node: Node }) {
const nodeData = node.data as NanoBananaNodeData;
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const isRunning = useWorkflowStore((state) => state.isRunning);
const { replicateApiKey, falApiKey, kieApiKey, replicateEnabled, kieEnabled } = useProviderApiKeys();
const [externalModels, setExternalModels] = useState<ProviderModel[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [modelsFetchError, setModelsFetchError] = useState<string | null>(null);
const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false);
const currentProvider: ProviderType = nodeData.selectedModel?.provider || "gemini";
// Get enabled providers
const enabledProviders = useMemo(() => {
const providers: { id: ProviderType; name: string }[] = [];
providers.push({ id: "gemini", name: "Gemini" });
providers.push({ id: "fal", name: "fal.ai" });
if (replicateEnabled && replicateApiKey) {
providers.push({ id: "replicate", name: "Replicate" });
}
if (kieEnabled && kieApiKey) {
providers.push({ id: "kie", name: "Kie.ai" });
}
return providers;
}, [replicateEnabled, replicateApiKey, kieEnabled, kieApiKey]);
// Fetch models from external providers
const fetchModels = useCallback(async () => {
if (currentProvider === "gemini") {
setExternalModels([]);
setModelsFetchError(null);
return;
}
setIsLoadingModels(true);
setModelsFetchError(null);
try {
const capabilities = IMAGE_CAPABILITIES.join(",");
const headers: HeadersInit = {};
if (replicateApiKey) headers["X-Replicate-Key"] = replicateApiKey;
if (falApiKey) headers["X-Fal-Key"] = falApiKey;
if (kieApiKey) headers["X-Kie-Key"] = kieApiKey;
const response = await deduplicatedFetch(`/api/models?provider=${currentProvider}&capabilities=${capabilities}`, { headers });
if (response.ok) {
const data = await response.json();
setExternalModels(data.models || []);
setModelsFetchError(null);
} else {
const errorData = await response.json().catch(() => ({}));
const errorMsg = errorData.error || `Failed to load models (${response.status})`;
setExternalModels([]);
setModelsFetchError(errorMsg);
}
} catch (error) {
console.error("Failed to fetch models:", error);
setExternalModels([]);
setModelsFetchError("Failed to load models. Check your connection.");
} finally {
setIsLoadingModels(false);
}
}, [currentProvider, replicateApiKey, falApiKey, kieApiKey]);
useEffect(() => {
fetchModels();
}, [fetchModels]);
const handleProviderChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const provider = e.target.value as ProviderType;
if (provider === "gemini") {
const newSelectedModel: SelectedModel = {
provider: "gemini",
modelId: nodeData.model || "nano-banana-pro",
displayName: GEMINI_IMAGE_MODELS.find(m => m.value === (nodeData.model || "nano-banana-pro"))?.label || "Nano Banana Pro",
};
updateNodeData(node.id, { selectedModel: newSelectedModel, parameters: {} });
} else {
const newSelectedModel: SelectedModel = {
provider,
modelId: "",
displayName: "Select model...",
};
updateNodeData(node.id, { selectedModel: newSelectedModel, parameters: {} });
}
},
[node.id, nodeData.model, updateNodeData]
);
const handleExternalModelChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const modelId = e.target.value;
const model = externalModels.find(m => m.id === modelId);
if (model) {
const newSelectedModel: SelectedModel = {
provider: currentProvider,
modelId: model.id,
displayName: model.name,
capabilities: model.capabilities,
};
updateNodeData(node.id, { selectedModel: newSelectedModel, parameters: {} });
}
},
[node.id, currentProvider, externalModels, updateNodeData]
);
const handleModelChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const model = e.target.value as ModelType;
updateNodeData(node.id, { model });
saveNanoBananaDefaults({ model });
const newSelectedModel: SelectedModel = {
provider: "gemini",
modelId: model,
displayName: GEMINI_IMAGE_MODELS.find(m => m.value === model)?.label || model,
};
updateNodeData(node.id, { selectedModel: newSelectedModel });
},
[node.id, updateNodeData]
);
const handleAspectRatioChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const aspectRatio = e.target.value as AspectRatio;
updateNodeData(node.id, { aspectRatio });
saveNanoBananaDefaults({ aspectRatio });
},
[node.id, updateNodeData]
);
const handleResolutionChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const resolution = e.target.value as Resolution;
updateNodeData(node.id, { resolution });
saveNanoBananaDefaults({ resolution });
},
[node.id, updateNodeData]
);
const handleGoogleSearchToggle = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const useGoogleSearch = e.target.checked;
updateNodeData(node.id, { useGoogleSearch });
saveNanoBananaDefaults({ useGoogleSearch });
},
[node.id, updateNodeData]
);
const handleImageSearchToggle = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const useImageSearch = e.target.checked;
updateNodeData(node.id, { useImageSearch });
saveNanoBananaDefaults({ useImageSearch });
},
[node.id, updateNodeData]
);
const handleParametersChange = useCallback(
(parameters: Record<string, unknown>) => {
updateNodeData(node.id, { parameters });
},
[node.id, updateNodeData]
);
const handleBrowseModelSelect = useCallback((model: ProviderModel) => {
const newSelectedModel: SelectedModel = {
provider: model.provider,
modelId: model.id,
displayName: model.name,
capabilities: model.capabilities,
};
updateNodeData(node.id, { selectedModel: newSelectedModel, parameters: {} });
setIsBrowseDialogOpen(false);
}, [node.id, updateNodeData]);
const isGeminiProvider = currentProvider === "gemini";
const currentModelId = isGeminiProvider ? (nodeData.selectedModel?.modelId || nodeData.model) : null;
const supportsResolution = currentModelId === "nano-banana-pro" || currentModelId === "nano-banana-2";
const aspectRatios = currentModelId === "nano-banana-2" ? EXTENDED_ASPECT_RATIOS : BASE_ASPECT_RATIOS;
const resolutions = currentModelId === "nano-banana-2" ? RESOLUTIONS_NB2 : RESOLUTIONS_PRO;
const hasExternalProviders = !!(replicateEnabled && replicateApiKey) || true;
return (
<>
<div className="space-y-3">
{/* Model name + provider with link — sits directly under title divider */}
<div className="border-t border-neutral-700 pt-3">
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<div className="text-sm text-neutral-100 truncate">
{nodeData.selectedModel?.displayName || GEMINI_IMAGE_MODELS.find(m => m.value === nodeData.model)?.label || "Select model..."}
</div>
<div className="flex items-center gap-1 mt-0.5">
<span className="text-[10px] text-neutral-500 truncate">
{enabledProviders.find(p => p.id === currentProvider)?.name || currentProvider}
</span>
{nodeData.selectedModel?.modelId && (
<a
href={getModelPageUrl(currentProvider, nodeData.selectedModel.modelId) || "#"}
target="_blank"
rel="noopener noreferrer"
className="text-neutral-500 hover:text-neutral-300 transition-colors"
title={`View on ${getProviderDisplayName(currentProvider)}`}
onClick={(e) => {
if (!getModelPageUrl(currentProvider, nodeData.selectedModel?.modelId || "")) {
e.preventDefault();
}
}}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
</div>
</div>
<button
onClick={() => setIsBrowseDialogOpen(true)}
className="nodrag nopan shrink-0 px-3 py-1.5 text-xs bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 transition-colors"
>
Browse
</button>
</div>
</div>
{/* Gemini-specific controls */}
{isGeminiProvider && (
<>
<div>
<label className="block text-xs font-medium text-neutral-300 mb-1">Aspect Ratio</label>
<select
value={nodeData.aspectRatio || "1:1"}
onChange={handleAspectRatioChange}
className="nodrag nopan w-full px-2 py-1 text-xs bg-neutral-700 border border-neutral-600 rounded text-neutral-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{aspectRatios.map(ar => (
<option key={ar} value={ar}>{ar}</option>
))}
</select>
</div>
{supportsResolution && (
<div>
<label className="block text-xs font-medium text-neutral-300 mb-1">Resolution</label>
<select
value={nodeData.resolution || "1K"}
onChange={handleResolutionChange}
className="nodrag nopan w-full px-2 py-1 text-xs bg-neutral-700 border border-neutral-600 rounded text-neutral-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{resolutions.map(res => (
<option key={res} value={res}>{res}</option>
))}
</select>
</div>
)}
{(currentModelId === "nano-banana-pro" || currentModelId === "nano-banana-2") && (
<div className="flex items-center">
<input
type="checkbox"
id={`google-search-${node.id}`}
checked={nodeData.useGoogleSearch || false}
onChange={handleGoogleSearchToggle}
className="nodrag nopan w-3 h-3 text-blue-600 bg-neutral-700 border-neutral-600 rounded focus:ring-blue-500"
/>
<label htmlFor={`google-search-${node.id}`} className="ml-2 text-xs text-neutral-300">
Google Search
</label>
</div>
)}
{currentModelId === "nano-banana-2" && (
<div className="flex items-center">
<input
type="checkbox"
id={`image-search-${node.id}`}
checked={nodeData.useImageSearch || false}
onChange={handleImageSearchToggle}
className="nodrag nopan w-3 h-3 text-blue-600 bg-neutral-700 border-neutral-600 rounded focus:ring-blue-500"
/>
<label htmlFor={`image-search-${node.id}`} className="ml-2 text-xs text-neutral-300">
Image Search
</label>
</div>
)}
</>
)}
{/* External provider parameters */}
{!isGeminiProvider && nodeData.selectedModel?.modelId && (
<ModelParameters
modelId={nodeData.selectedModel.modelId}
provider={currentProvider}
parameters={nodeData.parameters || {}}
onParametersChange={handleParametersChange}
/>
)}
</div>
<div className="flex justify-end">
<button
onClick={() => regenerateNode(node.id)}
disabled={isRunning}
className="nodrag nopan inline-flex items-center gap-1.5 px-3 py-1.5 text-xs bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 disabled:opacity-40 disabled:pointer-events-none transition-colors"
>
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg>
{isRunning ? "Running..." : "Run"}
</button>
</div>
{isBrowseDialogOpen && (
<ModelSearchDialog
isOpen={isBrowseDialogOpen}
onClose={() => setIsBrowseDialogOpen(false)}
onModelSelected={handleBrowseModelSelect}
initialCapabilityFilter="image"
/>
)}
</>
);
}
// Generate Video Controls
function GenerateVideoControls({ node }: { node: Node }) {
const nodeData = node.data as GenerateVideoNodeData;
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const isRunning = useWorkflowStore((state) => state.isRunning);
const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false);
const currentProvider: ProviderType = nodeData.selectedModel?.provider || "fal";
const handleParametersChange = useCallback(
(parameters: Record<string, unknown>) => {
updateNodeData(node.id, { parameters });
},
[node.id, updateNodeData]
);
const handleBrowseModelSelect = useCallback((model: ProviderModel) => {
const newSelectedModel: SelectedModel = {
provider: model.provider,
modelId: model.id,
displayName: model.name,
capabilities: model.capabilities,
};
updateNodeData(node.id, { selectedModel: newSelectedModel, parameters: {} });
setIsBrowseDialogOpen(false);
}, [node.id, updateNodeData]);
return (
<>
<div className="space-y-3">
{/* Model name + provider with link */}
<div className="border-t border-neutral-700 pt-3">
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<div className="text-sm text-neutral-100 truncate">
{nodeData.selectedModel?.displayName || "Select model..."}
</div>
<div className="flex items-center gap-1 mt-0.5">
<span className="text-[10px] text-neutral-500 truncate">
{getProviderDisplayName(currentProvider)}
</span>
{nodeData.selectedModel?.modelId && (
<a
href={getModelPageUrl(currentProvider, nodeData.selectedModel.modelId) || "#"}
target="_blank"
rel="noopener noreferrer"
className="text-neutral-500 hover:text-neutral-300 transition-colors"
title={`View on ${getProviderDisplayName(currentProvider)}`}
onClick={(e) => {
if (!getModelPageUrl(currentProvider, nodeData.selectedModel?.modelId || "")) {
e.preventDefault();
}
}}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
</div>
</div>
<button
onClick={() => setIsBrowseDialogOpen(true)}
className="nodrag nopan shrink-0 px-3 py-1.5 text-xs bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 transition-colors"
>
Browse
</button>
</div>
</div>
{nodeData.selectedModel?.modelId && (
<ModelParameters
modelId={nodeData.selectedModel.modelId}
provider={currentProvider}
parameters={nodeData.parameters || {}}
onParametersChange={handleParametersChange}
/>
)}
</div>
<div className="flex justify-end">
<button
onClick={() => regenerateNode(node.id)}
disabled={isRunning}
className="nodrag nopan inline-flex items-center gap-1.5 px-3 py-1.5 text-xs bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 disabled:opacity-40 disabled:pointer-events-none transition-colors"
>
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg>
{isRunning ? "Running..." : "Run"}
</button>
</div>
{isBrowseDialogOpen && (
<ModelSearchDialog
isOpen={isBrowseDialogOpen}
onClose={() => setIsBrowseDialogOpen(false)}
onModelSelected={handleBrowseModelSelect}
initialCapabilityFilter="video"
/>
)}
</>
);
}
// Generate 3D Controls
function Generate3DControls({ node }: { node: Node }) {
const nodeData = node.data as Generate3DNodeData;
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const isRunning = useWorkflowStore((state) => state.isRunning);
const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false);
const currentProvider: ProviderType = nodeData.selectedModel?.provider || "fal";
const handleParametersChange = useCallback(
(parameters: Record<string, unknown>) => {
updateNodeData(node.id, { parameters });
},
[node.id, updateNodeData]
);
const handleBrowseModelSelect = useCallback((model: ProviderModel) => {
updateNodeData(node.id, {
selectedModel: {
provider: model.provider,
modelId: model.id,
displayName: model.name,
capabilities: model.capabilities,
},
parameters: {}
});
setIsBrowseDialogOpen(false);
}, [node.id, updateNodeData]);
return (
<>
<div className="space-y-3">
{/* Model name + provider with link */}
<div className="border-t border-neutral-700 pt-3">
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<div className="text-sm text-neutral-100 truncate">
{nodeData.selectedModel?.displayName || "Select model..."}
</div>
<div className="flex items-center gap-1 mt-0.5">
<span className="text-[10px] text-neutral-500 truncate">
{getProviderDisplayName(currentProvider)}
</span>
{nodeData.selectedModel?.modelId && (
<a
href={getModelPageUrl(currentProvider, nodeData.selectedModel.modelId) || "#"}
target="_blank"
rel="noopener noreferrer"
className="text-neutral-500 hover:text-neutral-300 transition-colors"
title={`View on ${getProviderDisplayName(currentProvider)}`}
onClick={(e) => {
if (!getModelPageUrl(currentProvider, nodeData.selectedModel?.modelId || "")) {
e.preventDefault();
}
}}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
</div>
</div>
<button
onClick={() => setIsBrowseDialogOpen(true)}
className="nodrag nopan shrink-0 px-3 py-1.5 text-xs bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 transition-colors"
>
Browse
</button>
</div>
</div>
{nodeData.selectedModel?.modelId && (
<ModelParameters
modelId={nodeData.selectedModel.modelId}
provider={currentProvider}
parameters={nodeData.parameters || {}}
onParametersChange={handleParametersChange}
/>
)}
</div>
<div className="flex justify-end">
<button
onClick={() => regenerateNode(node.id)}
disabled={isRunning}
className="nodrag nopan inline-flex items-center gap-1.5 px-3 py-1.5 text-xs bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 disabled:opacity-40 disabled:pointer-events-none transition-colors"
>
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg>
{isRunning ? "Running..." : "Run"}
</button>
</div>
{isBrowseDialogOpen && (
<ModelSearchDialog
isOpen={isBrowseDialogOpen}
onClose={() => setIsBrowseDialogOpen(false)}
onModelSelected={handleBrowseModelSelect}
initialCapabilityFilter="3d"
/>
)}
</>
);
}
// Generate Audio Controls
function GenerateAudioControls({ node }: { node: Node }) {
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const isRunning = useWorkflowStore((state) => state.isRunning);
return (
<div className="space-y-3">
<div className="text-xs text-neutral-400">
Audio generation settings
</div>
<div className="flex justify-end">
<button
onClick={() => regenerateNode(node.id)}
disabled={isRunning}
className="nodrag nopan inline-flex items-center gap-1.5 px-3 py-1.5 text-xs bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 disabled:opacity-40 disabled:pointer-events-none transition-colors"
>
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg>
{isRunning ? "Running..." : "Run"}
</button>
</div>
</div>
);
}
// LLM Controls
function LLMControls({ node }: { node: Node }) {
const nodeData = node.data as LLMGenerateNodeData;
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const isRunning = useWorkflowStore((state) => state.isRunning);
const handleProviderChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const newProvider = e.target.value as LLMProvider;
const firstModelForProvider = LLM_MODELS[newProvider][0].value;
const updates: Partial<LLMGenerateNodeData> = {
provider: newProvider,
model: firstModelForProvider,
};
if (newProvider === "anthropic" && nodeData.temperature > 1) {
updates.temperature = 1;
}
updateNodeData(node.id, updates);
},
[node.id, updateNodeData, nodeData.temperature]
);
const handleModelChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
updateNodeData(node.id, { model: e.target.value as LLMModelType });
},
[node.id, updateNodeData]
);
const handleTemperatureChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
updateNodeData(node.id, { temperature: parseFloat(e.target.value) });
},
[node.id, updateNodeData]
);
const handleMaxTokensChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
updateNodeData(node.id, { maxTokens: parseInt(e.target.value, 10) });
},
[node.id, updateNodeData]
);
const provider = nodeData.provider || "google";
const availableModels = LLM_MODELS[provider] || LLM_MODELS.google;
return (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-neutral-300 mb-1">Provider</label>
<select
value={provider}
onChange={handleProviderChange}
className="nodrag nopan w-full px-2 py-1 text-xs bg-neutral-700 border border-neutral-600 rounded text-neutral-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{LLM_PROVIDERS.map(p => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-300 mb-1">Model</label>
<select
value={nodeData.model || availableModels[0].value}
onChange={handleModelChange}
className="nodrag nopan w-full px-2 py-1 text-xs bg-neutral-700 border border-neutral-600 rounded text-neutral-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{availableModels.map(m => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-300 mb-1">
Temperature: {nodeData.temperature?.toFixed(2) || "0.70"}
</label>
<input
type="range"
min="0"
max={provider === "anthropic" ? "1" : "2"}
step="0.01"
value={nodeData.temperature || 0.7}
onChange={handleTemperatureChange}
className="nodrag nopan w-full h-1 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-neutral-300 mb-1">
Max Tokens: {(nodeData.maxTokens || 2048).toLocaleString()}
</label>
<input
type="range"
min="256"
max="16384"
step="256"
value={nodeData.maxTokens || 2048}
onChange={handleMaxTokensChange}
className="nodrag nopan w-full h-1 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
</div>
<div className="flex justify-end">
<button
onClick={() => regenerateNode(node.id)}
disabled={isRunning}
className="nodrag nopan inline-flex items-center gap-1.5 px-3 py-1.5 text-xs bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 disabled:opacity-40 disabled:pointer-events-none transition-colors"
>
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg>
{isRunning ? "Running..." : "Run"}
</button>
</div>
</div>
);
}
// Ease Curve Controls
function EaseCurveControls({ node }: { node: Node }) {
const nodeData = node.data as EaseCurveNodeData;
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const isRunning = useWorkflowStore((state) => state.isRunning);
const edges = useWorkflowStore((state) => state.edges);
const removeEdge = useWorkflowStore((state) => state.removeEdge);
const [showPresets, setShowPresets] = useState(false);
const presetsButtonRef = useRef<HTMLButtonElement>(null);
const inheritedEdge = useMemo(() => {
return edges.find((e) => e.target === node.id && e.targetHandle === "easeCurve") || null;
}, [edges, node.id]);
const isInherited = !!inheritedEdge;
const handleBreakInheritance = useCallback(() => {
if (inheritedEdge) {
removeEdge(inheritedEdge.id);
updateNodeData(node.id, { inheritedFrom: null });
}
}, [inheritedEdge, removeEdge, node.id, updateNodeData]);
const handleBezierChange = useCallback(
(value: [number, number, number, number]) => {
updateNodeData(node.id, { bezierHandles: value, easingPreset: null });
},
[node.id, updateNodeData]
);
const handleSelectEasing = useCallback(
(name: string) => {
updateNodeData(node.id, {
easingPreset: name,
bezierHandles: getEasingBezier(name),
});
setShowPresets(false);
},
[node.id, updateNodeData]
);
const handleDurationChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseFloat(e.target.value);
updateNodeData(node.id, { outputDuration: isNaN(val) ? 1.5 : Math.max(0.1, Math.min(30, val)) });
},
[node.id, updateNodeData]
);
const editorEasingCurve = useMemo(() => {
if (!nodeData.easingPreset) return undefined;
return generateEasingPolyline(nodeData.easingPreset, 100, 100, 50);
}, [nodeData.easingPreset]);
const presetThumbnails = useMemo(() => {
return ALL_EASING_NAMES.map((name) => ({
name,
polyline: generateEasingPolyline(name, 36, 36),
isPreset: PRESET_NAMES.has(name as any),
}));
}, []);
return (
<div className="space-y-3 relative">
{isInherited && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-neutral-900/80 backdrop-blur-sm rounded z-10">
<p className="text-sm text-neutral-200 font-medium">Settings inherited</p>
<p className="text-[11px] text-neutral-400 mt-1">Break connection to edit manually</p>
<button
className="nodrag nopan mt-3 px-3 py-1.5 bg-white/10 hover:bg-white/20 border border-white/20 rounded text-xs text-neutral-200 transition-colors"
onClick={handleBreakInheritance}
>
Control manually
</button>
</div>
)}
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-xs font-medium text-neutral-300">Easing Function</label>
<button
ref={presetsButtonRef}
onClick={() => setShowPresets(!showPresets)}
className="nodrag nopan text-xs px-2 py-0.5 bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 transition-colors"
>
Presets
</button>
</div>
<CubicBezierEditor
value={nodeData.bezierHandles || [0.42, 0, 0.58, 1]}
onChange={handleBezierChange}
onCommit={handleBezierChange}
easingCurve={editorEasingCurve}
/>
{nodeData.easingPreset && (
<div className="text-xs text-neutral-400 mt-1">
Current: {nodeData.easingPreset}
</div>
)}
</div>
<div>
<label className="block text-xs font-medium text-neutral-300 mb-1">
Output Duration: {nodeData.outputDuration?.toFixed(1) || "1.5"}s
</label>
<input
type="number"
min="0.1"
max="30"
step="0.1"
value={nodeData.outputDuration || 1.5}
onChange={handleDurationChange}
className="nodrag nopan w-full px-2 py-1 text-xs bg-neutral-700 border border-neutral-600 rounded text-neutral-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div className="flex justify-end">
<button
onClick={() => regenerateNode(node.id)}
disabled={isRunning}
className="nodrag nopan inline-flex items-center gap-1.5 px-3 py-1.5 text-xs bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 disabled:opacity-40 disabled:pointer-events-none transition-colors"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
{isRunning ? "Applying..." : "Apply"}
</button>
</div>
{showPresets && typeof document !== 'undefined' && createPortal(
<div
className="fixed z-[100] bg-neutral-800 border border-neutral-600 rounded-lg shadow-xl p-2 max-h-[60vh] overflow-y-auto nowheel"
style={{
top: presetsButtonRef.current?.getBoundingClientRect().bottom || 0,
right: window.innerWidth - (presetsButtonRef.current?.getBoundingClientRect().left || 0),
width: 280,
}}
>
<div className="grid grid-cols-4 gap-1">
{presetThumbnails.map(({ name, polyline }) => (
<button
key={name}
onClick={() => handleSelectEasing(name)}
className="nodrag nopan p-1 bg-neutral-900 hover:bg-neutral-700 rounded flex flex-col items-center gap-1 transition-colors"
title={name}
>
<svg width="36" height="36" viewBox="0 0 36 36" className="flex-shrink-0">
<polyline
points={polyline}
fill="none"
stroke="#a3a3a3"
strokeWidth="1.5"
/>
</svg>
<span className="text-[8px] text-neutral-400 text-center break-words w-full">
{name}
</span>
</button>
))}
</div>
</div>,
document.body
)}
</div>
);
}
// Conditional Switch Controls
function ConditionalSwitchControls({ node }: { node: Node }) {
const nodeData = node.data as ConditionalSwitchNodeData;
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const [editingId, setEditingId] = useState<string | null>(null);
const handleRuleValueChange = useCallback(
(ruleId: string, newValue: string) => {
const updatedRules = nodeData.rules.map((rule) =>
rule.id === ruleId ? { ...rule, value: newValue } : rule
);
updateNodeData(node.id, { rules: updatedRules, evaluationPaused: false });
},
[node.id, nodeData.rules, updateNodeData]
);
const handleModeChange = useCallback(
(ruleId: string, newMode: MatchMode) => {
const updatedRules = nodeData.rules.map((rule) =>
rule.id === ruleId ? { ...rule, mode: newMode } : rule
);
updateNodeData(node.id, { rules: updatedRules, evaluationPaused: false });
},
[node.id, nodeData.rules, updateNodeData]
);
const handleLabelEdit = useCallback(
(ruleId: string, newLabel: string) => {
const updatedRules = nodeData.rules.map((rule) =>
rule.id === ruleId ? { ...rule, label: newLabel } : rule
);
updateNodeData(node.id, { rules: updatedRules });
setEditingId(null);
},
[node.id, nodeData.rules, updateNodeData]
);
const handleDelete = useCallback(
(ruleId: string) => {
if (nodeData.rules.length <= 1) return;
const updatedRules = nodeData.rules.filter((rule) => rule.id !== ruleId);
updateNodeData(node.id, { rules: updatedRules });
},
[node.id, nodeData.rules, updateNodeData]
);
const handleAddRule = useCallback(() => {
const newRule: ConditionalSwitchRule = {
id: "rule-" + Math.random().toString(36).slice(2, 9),
value: "",
mode: "contains",
label: `Rule ${nodeData.rules.length + 1}`,
isMatched: false,
};
updateNodeData(node.id, { rules: [...nodeData.rules, newRule] });
}, [node.id, nodeData.rules, updateNodeData]);
return (
<div className="space-y-2">
{nodeData.rules.map((rule, index) => (
<div key={rule.id} className="border border-neutral-600 rounded p-2 space-y-2">
<div className="flex items-center justify-between">
<input
type="text"
value={editingId === rule.id ? rule.label : rule.label || `Rule ${index + 1}`}
onChange={(e) => handleLabelEdit(rule.id, e.target.value)}
onFocus={() => setEditingId(rule.id)}
onBlur={() => setEditingId(null)}
className="nodrag nopan flex-1 px-1 py-0.5 text-xs bg-transparent border-none text-neutral-200 focus:outline-none"
/>
{nodeData.rules.length > 1 && (
<button
onClick={() => handleDelete(rule.id)}
className="nodrag nopan text-neutral-500 hover:text-red-400"
title="Delete rule"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
<select
value={rule.mode}
onChange={(e) => handleModeChange(rule.id, e.target.value as MatchMode)}
className="nodrag nopan w-full px-2 py-1 text-xs bg-neutral-700 border border-neutral-600 rounded text-neutral-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="exact">Exact match</option>
<option value="contains">Contains</option>
<option value="starts-with">Starts with</option>
<option value="ends-with">Ends with</option>
</select>
<input
type="text"
value={rule.value}
onChange={(e) => handleRuleValueChange(rule.id, e.target.value)}
placeholder="Enter match value"
className="nodrag nopan w-full px-2 py-1 text-xs bg-neutral-700 border border-neutral-600 rounded text-neutral-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
{rule.isMatched !== undefined && (
<div className="flex items-center gap-1">
<div className={`w-2 h-2 rounded-full ${rule.isMatched ? 'bg-green-500' : 'bg-neutral-600'}`} />
<span className="text-xs text-neutral-400">
{rule.isMatched ? 'Matched' : 'Not matched'}
</span>
</div>
)}
</div>
))}
<button
onClick={handleAddRule}
className="nodrag nopan w-full px-2 py-1 text-xs bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 transition-colors"
>
+ Add Rule
</button>
</div>
);
}