"use client"; import { useState, useEffect } from "react"; import { generateWorkflowId, useWorkflowStore } from "@/store/workflowStore"; import { ProviderType, ProviderSettings, NodeDefaultsConfig, LLMProvider, LLMModelType } from "@/types"; import { CanvasNavigationSettings, PanMode, ZoomMode, SelectionMode } from "@/types/canvas"; import { EnvStatusResponse } from "@/app/api/env-status/route"; import { loadNodeDefaults, saveNodeDefaults } from "@/store/utils/localStorage"; import { ProviderModel } from "@/lib/providers/types"; import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog"; import { useInlineParameters } from "@/hooks/useInlineParameters"; // LLM provider and model options (mirrored from LLMGenerateNode) const LLM_PROVIDERS: { value: LLMProvider; label: string }[] = [ { value: "google", label: "Google" }, { value: "openai", label: "OpenAI" }, { value: "anthropic", label: "Anthropic" }, ]; const LLM_MODELS: Record = { 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" }, ], }; // Provider icons const GeminiIcon = () => ( ); const ReplicateIcon = () => ( ); const FalIcon = () => ( ); const WaveSpeedIcon = () => ( ); // Get provider icon component const getProviderIcon = (provider: ProviderType) => { switch (provider) { case "gemini": return ; case "replicate": return ; case "fal": return ; case "wavespeed": return ; default: return null; } }; interface ProjectSetupModalProps { isOpen: boolean; onClose: () => void; onSave: (id: string, name: string, directoryPath: string) => void; mode: "new" | "settings"; } export function ProjectSetupModal({ isOpen, onClose, onSave, mode, }: ProjectSetupModalProps) { const sanitizeProjectFolderName = (projectName: string): string => { return projectName .trim() .replace(/[<>:"/\\|?*\x00-\x1F]/g, "_") .replace(/\.+$/g, "") .trim(); }; const joinPathForPlatform = (basePath: string, folderName: string): string => { const trimmedBase = basePath.trim(); const separator = /^[A-Za-z]:[\\\/]/.test(trimmedBase) || trimmedBase.startsWith("\\\\") ? "\\" : "/"; const endsWithSeparator = trimmedBase.endsWith("/") || trimmedBase.endsWith("\\"); return `${trimmedBase}${endsWithSeparator ? "" : separator}${folderName}`; }; const getPathBasename = (fullPath: string): string => { const withoutTrailingSeparator = fullPath.trim().replace(/[\\/]+$/, ""); const parts = withoutTrailingSeparator.split(/[\\/]/); return parts[parts.length - 1] || ""; }; const ensureProjectSubfolderPath = (basePath: string, projectName: string): string => { const trimmedBase = basePath.trim(); const sanitizedFolder = sanitizeProjectFolderName(projectName); if (!sanitizedFolder) return trimmedBase; const basename = getPathBasename(trimmedBase); if (basename.toLowerCase() === sanitizedFolder.toLowerCase()) { return trimmedBase; } return joinPathForPlatform(trimmedBase, sanitizedFolder); }; const { workflowName, saveDirectoryPath, useExternalImageStorage, setUseExternalImageStorage, providerSettings, updateProviderApiKey, toggleProvider, maxConcurrentCalls, setMaxConcurrentCalls, canvasNavigationSettings, updateCanvasNavigationSettings, } = useWorkflowStore(); // Inline parameters hook const { inlineParametersEnabled, setInlineParameters } = useInlineParameters(); // Tab state const [activeTab, setActiveTab] = useState<"project" | "providers" | "nodeDefaults" | "canvas">("project"); // Project tab state const [name, setName] = useState(""); const [directoryPath, setDirectoryPath] = useState(""); const [externalStorage, setExternalStorage] = useState(true); const [isValidating, setIsValidating] = useState(false); const [isBrowsing, setIsBrowsing] = useState(false); const [error, setError] = useState(null); // Provider tab state const [localProviders, setLocalProviders] = useState(providerSettings); const [showApiKey, setShowApiKey] = useState>({ gemini: false, openai: false, anthropic: false, replicate: false, fal: false, kie: false, wavespeed: false, }); const [overrideActive, setOverrideActive] = useState>({ gemini: false, openai: false, anthropic: false, replicate: false, fal: false, kie: false, wavespeed: false, }); const [envStatus, setEnvStatus] = useState(null); // Node defaults tab state const [localNodeDefaults, setLocalNodeDefaults] = useState({}); const [showImageModelDialog, setShowImageModelDialog] = useState(false); const [showVideoModelDialog, setShowVideoModelDialog] = useState(false); // Canvas tab state const [localCanvasSettings, setLocalCanvasSettings] = useState(canvasNavigationSettings); // Pre-fill when opening in settings mode useEffect(() => { if (isOpen) { // Reset to project tab when opening if (mode === "new") { setActiveTab("project"); } if (mode === "settings") { setName(workflowName || ""); setDirectoryPath(saveDirectoryPath || ""); setExternalStorage(useExternalImageStorage); } else if (mode === "new") { setName(""); setDirectoryPath(""); setExternalStorage(true); } // Sync local providers state setLocalProviders(providerSettings); setShowApiKey({ gemini: false, openai: false, anthropic: false, replicate: false, fal: false, kie: false, wavespeed: false }); // Initialize override as active if user already has a key set setOverrideActive({ gemini: !!providerSettings.providers.gemini?.apiKey, openai: !!providerSettings.providers.openai?.apiKey, anthropic: !!providerSettings.providers.anthropic?.apiKey, replicate: !!providerSettings.providers.replicate?.apiKey, fal: !!providerSettings.providers.fal?.apiKey, kie: !!providerSettings.providers.kie?.apiKey, wavespeed: !!providerSettings.providers.wavespeed?.apiKey, }); setError(null); // Load node defaults setLocalNodeDefaults(loadNodeDefaults()); setShowImageModelDialog(false); setShowVideoModelDialog(false); // Sync canvas settings setLocalCanvasSettings(canvasNavigationSettings); // Fetch env status fetch("/api/env-status") .then((res) => res.json()) .then((data: EnvStatusResponse) => setEnvStatus(data)) .catch(() => setEnvStatus(null)); } }, [isOpen, mode, workflowName, saveDirectoryPath, useExternalImageStorage, providerSettings, canvasNavigationSettings]); const handleBrowse = async () => { setIsBrowsing(true); setError(null); try { const response = await fetch("/api/browse-directory"); const result = await response.json(); if (!result.success) { setError(result.error || "Failed to open directory picker"); return; } if (result.cancelled) { return; } if (result.path) { setDirectoryPath(result.path); } } catch (err) { setError( `Failed to open directory picker: ${err instanceof Error ? err.message : "Unknown error"}` ); } finally { setIsBrowsing(false); } }; const handleSaveProject = async () => { if (!name.trim()) { setError("Project name is required"); return; } if (!directoryPath.trim()) { setError("Project directory is required"); return; } const fullProjectPath = ensureProjectSubfolderPath(directoryPath, name); if (!(fullProjectPath.startsWith("/") || /^[A-Za-z]:[\\\/]/.test(fullProjectPath) || fullProjectPath.startsWith("\\\\"))) { setError("Project directory must be an absolute path (starting with /, a drive letter, or a UNC path)"); return; } setIsValidating(true); setError(null); try { // Validate path shape when it already exists const response = await fetch( `/api/workflow?path=${encodeURIComponent(fullProjectPath)}` ); const result = await response.json(); if (result.exists && !result.isDirectory) { setError("Project path is not a directory"); setIsValidating(false); return; } const id = mode === "new" ? generateWorkflowId() : useWorkflowStore.getState().workflowId || generateWorkflowId(); // Update external storage setting setUseExternalImageStorage(externalStorage); onSave(id, name.trim(), fullProjectPath); setIsValidating(false); } catch (err) { setError( `Failed to validate directory: ${err instanceof Error ? err.message : "Unknown error"}` ); setIsValidating(false); } }; const handleSaveProviders = () => { // Save each provider's settings const providerIds: ProviderType[] = ["gemini", "openai", "anthropic", "replicate", "fal", "kie", "wavespeed"]; for (const providerId of providerIds) { const local = localProviders.providers[providerId]; const current = providerSettings.providers[providerId]; if (!local || !current) continue; // Update enabled state if changed if (local.enabled !== current.enabled) { toggleProvider(providerId, local.enabled); } // Update API key if changed if (local.apiKey !== current.apiKey) { updateProviderApiKey(providerId, local.apiKey); } } onClose(); }; const handleSaveNodeDefaults = () => { saveNodeDefaults(localNodeDefaults); onClose(); }; const handleSaveCanvas = () => { updateCanvasNavigationSettings(localCanvasSettings); onClose(); }; const handleSave = () => { if (activeTab === "project") { handleSaveProject(); } else if (activeTab === "providers") { handleSaveProviders(); } else if (activeTab === "canvas") { handleSaveCanvas(); } else { handleSaveNodeDefaults(); } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !isValidating && !isBrowsing) { handleSave(); } if (e.key === "Escape") { onClose(); } }; const updateLocalProvider = ( providerId: ProviderType, updates: { enabled?: boolean; apiKey?: string | null } ) => { setLocalProviders((prev) => ({ providers: { ...prev.providers, [providerId]: { ...prev.providers[providerId], ...updates, }, }, })); }; if (!isOpen) return null; return (
e.stopPropagation()} >
e.stopPropagation()} onKeyDown={handleKeyDown} >

{mode === "new" ? "New Project" : "Project Settings"}

{/* Tab Bar */}
{/* Scrollable tab content area */}
{/* Project Tab Content */} {activeTab === "project" && (
setName(e.target.value)} placeholder="my-project" autoFocus className="w-full px-3 py-2 bg-neutral-900 border border-neutral-600 rounded-lg text-neutral-100 text-sm focus:outline-none focus:border-neutral-500" />
setDirectoryPath(e.target.value)} placeholder="/Users/username/projects/my-project" className="flex-1 px-3 py-2 bg-neutral-900 border border-neutral-600 rounded-lg text-neutral-100 text-sm focus:outline-none focus:border-neutral-500" />

Workflow files and images will be saved here. Subfolders for inputs and generations will be auto-created.

{error &&

{error}

}
)} {/* Providers Tab Content */} {activeTab === "providers" && (
{/* Gemini Provider */}
Google Gemini {envStatus?.gemini && !overrideActive.gemini ? (
Configured via .env
) : (
updateLocalProvider("gemini", { apiKey: e.target.value || null })} placeholder="AIza..." className="w-48 px-2 py-1 bg-neutral-800 border border-neutral-600 rounded-lg text-neutral-100 text-xs focus:outline-none focus:border-neutral-500" /> {envStatus?.gemini && ( )}
)}
{/* OpenAI Provider */}
OpenAI {envStatus?.openai && !overrideActive.openai ? (
Configured via .env
) : (
updateLocalProvider("openai", { apiKey: e.target.value || null })} placeholder="sk-..." className="w-48 px-2 py-1 bg-neutral-800 border border-neutral-600 rounded-lg text-neutral-100 text-xs focus:outline-none focus:border-neutral-500" /> {envStatus?.openai && ( )}
)}
{/* Anthropic Provider */}
Anthropic {envStatus?.anthropic && !overrideActive.anthropic ? (
Configured via .env
) : (
updateLocalProvider("anthropic", { apiKey: e.target.value || null })} placeholder="sk-ant-..." className="w-48 px-2 py-1 bg-neutral-800 border border-neutral-600 rounded-lg text-neutral-100 text-xs focus:outline-none focus:border-neutral-500" /> {envStatus?.anthropic && ( )}
)}
{/* Replicate Provider */}
Replicate {envStatus?.replicate && !overrideActive.replicate ? (
Configured via .env
) : (
updateLocalProvider("replicate", { apiKey: e.target.value || null })} placeholder="r8_..." className="w-48 px-2 py-1 bg-neutral-800 border border-neutral-600 rounded-lg text-neutral-100 text-xs focus:outline-none focus:border-neutral-500" /> {envStatus?.replicate && ( )}
)}
{/* fal.ai Provider */}
fal.ai {envStatus?.fal && !overrideActive.fal ? (
Configured via .env
) : (
updateLocalProvider("fal", { apiKey: e.target.value || null })} placeholder="..." className="w-48 px-2 py-1 bg-neutral-800 border border-neutral-600 rounded-lg text-neutral-100 text-xs focus:outline-none focus:border-neutral-500" /> {envStatus?.fal && ( )}
)}
{/* Kie.ai Provider */}
Kie.ai {envStatus?.kie && !overrideActive.kie ? (
Configured via .env
) : (
updateLocalProvider("kie", { apiKey: e.target.value || null })} placeholder="..." className="w-48 px-2 py-1 bg-neutral-800 border border-neutral-600 rounded-lg text-neutral-100 text-xs focus:outline-none focus:border-neutral-500" /> {envStatus?.kie && ( )}
)}
{/* WaveSpeed Provider */}
WaveSpeed {envStatus?.wavespeed && !overrideActive.wavespeed ? (
Configured via .env
) : (
updateLocalProvider("wavespeed", { apiKey: e.target.value || null })} placeholder="..." className="w-48 px-2 py-1 bg-neutral-800 border border-neutral-600 rounded-lg text-neutral-100 text-xs focus:outline-none focus:border-neutral-500" /> {envStatus?.wavespeed && ( )}
)}

Add API keys via .env.local for better security. Keys added here override .env and are stored in your browser.

)} {/* Node Defaults Tab Content */} {activeTab === "nodeDefaults" && (
{/* GenerateImage Section */}
Default Image Model
{localNodeDefaults.generateImage?.selectedModel ? ( <>
{getProviderIcon(localNodeDefaults.generateImage.selectedModel.provider)} {localNodeDefaults.generateImage.selectedModel.displayName}
) : ( <> System default (Gemini nano-banana-pro) )}
{/* GenerateVideo Section */}
Default Video Model
{localNodeDefaults.generateVideo?.selectedModel ? ( <>
{getProviderIcon(localNodeDefaults.generateVideo.selectedModel.provider)} {localNodeDefaults.generateVideo.selectedModel.displayName}
) : ( <> None set (select on first use) )}
{/* LLM Section */}
Default LLM Settings {localNodeDefaults.llm && ( )}
{!localNodeDefaults.llm ? (

Using system defaults (Google Gemini 3 Flash)

) : null} {/* Provider dropdown */}
{/* Model dropdown */}
{/* Temperature slider */}
{ setLocalNodeDefaults(prev => ({ ...prev, llm: { ...prev.llm, temperature: parseFloat(e.target.value) } })); }} className="flex-1 h-1 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-neutral-400" />
{/* Max Tokens slider */}
{ setLocalNodeDefaults(prev => ({ ...prev, llm: { ...prev.llm, maxTokens: parseInt(e.target.value, 10) } })); }} className="flex-1 h-1 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-neutral-400" />
{/* Execution Section */}
Execution Settings {/* Concurrency slider */}
setMaxConcurrentCalls(parseInt(e.target.value, 10))} className="flex-1 h-1 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-neutral-400" />

Maximum number of nodes to execute in parallel during workflow execution. Higher values may improve speed but increase API rate limit risk.

These defaults are applied when creating nodes via keyboard shortcuts (Shift+G, Shift+L, etc).

)} {/* Canvas Tab Content */} {activeTab === "canvas" && (

Configure how you navigate and interact with the canvas.

{/* Pan Mode */}
Pan Mode

{localCanvasSettings.panMode === "space" && "Hold Space and drag to pan"} {localCanvasSettings.panMode === "middleMouse" && "Click and drag with middle mouse button"} {localCanvasSettings.panMode === "always" && "Pan without holding any keys"}

{([ { value: "space" as PanMode, label: "Space + Drag" }, { value: "middleMouse" as PanMode, label: "Middle Mouse" }, { value: "always" as PanMode, label: "Always On" }, ] as const).map((option) => ( ))}
{/* Zoom Mode */}
Zoom Mode

{localCanvasSettings.zoomMode === "altScroll" && "Hold Alt and scroll to zoom"} {localCanvasSettings.zoomMode === "ctrlScroll" && "Hold Ctrl/Cmd and scroll to zoom"} {localCanvasSettings.zoomMode === "scroll" && "Scroll to zoom without modifier keys"}

{([ { value: "altScroll" as ZoomMode, label: "Alt + Scroll" }, { value: "ctrlScroll" as ZoomMode, label: "Ctrl + Scroll" }, { value: "scroll" as ZoomMode, label: "Scroll" }, ] as const).map((option) => ( ))}
{/* Selection Mode */}
Selection Mode

{localCanvasSettings.selectionMode === "click" && "Click to select nodes"} {localCanvasSettings.selectionMode === "altDrag" && "Hold Alt and drag to select"} {localCanvasSettings.selectionMode === "shiftDrag" && "Hold Shift and drag to select"}

{([ { value: "click" as SelectionMode, label: "Click" }, { value: "altDrag" as SelectionMode, label: "Alt + Drag" }, { value: "shiftDrag" as SelectionMode, label: "Shift + Drag" }, ] as const).map((option) => ( ))}
)}
{/* Fixed footer */}
{/* Model Selection Dialogs */} {showImageModelDialog && ( setShowImageModelDialog(false)} onModelSelected={(model: ProviderModel) => { setLocalNodeDefaults(prev => ({ ...prev, generateImage: { ...prev.generateImage, selectedModel: { provider: model.provider, modelId: model.id, displayName: model.name, } } })); setShowImageModelDialog(false); }} initialCapabilityFilter="image" /> )} {showVideoModelDialog && ( setShowVideoModelDialog(false)} onModelSelected={(model: ProviderModel) => { setLocalNodeDefaults(prev => ({ ...prev, generateVideo: { ...prev.generateVideo, selectedModel: { provider: model.provider, modelId: model.id, displayName: model.name, } } })); setShowVideoModelDialog(false); }} initialCapabilityFilter="video" /> )}
); }