diff --git a/src/app/page.tsx b/src/app/page.tsx index ca3a1329..6e2aff08 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -36,9 +36,9 @@ export default function Home() { // Client-side only FTUX check (SSR-safe) useEffect(() => { - // TODO: REVERT BEFORE PRODUCTION - Testing override to always show FTUX - setShowFTUX(true); - // Production code: if (!getFTUXCompleted()) { setShowFTUX(true); } + if (!getFTUXCompleted()) { + setShowFTUX(true); + } }, []); const handleFTUXComplete = () => { diff --git a/src/components/FloatingActionBar.tsx b/src/components/FloatingActionBar.tsx index 1cc5457a..f270caee 100644 --- a/src/components/FloatingActionBar.tsx +++ b/src/components/FloatingActionBar.tsx @@ -457,7 +457,7 @@ export function FloatingActionBar() { // Normal execution executeWorkflow(); } - }, [isRunning, stopWorkflow, executeWorkflow]); + }, [isRunning, stopWorkflow, executeWorkflow, mockTutorialExecution]); const handleRunFromSelected = () => { if (selectedNode) { diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 797140cd..ab6c6190 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -312,6 +312,7 @@ export function Header() { onClick={handleNewProject} className="relative p-1.5 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 rounded transition-colors" title="Save project" + data-tutorial="save-button" > state.clearWorkflow); const setHoveredNodeId = useWorkflowStore((state) => state.setHoveredNodeId); const openAnnotationModal = useAnnotationStore((state) => state.openModal); - const { screenToFlowPosition, getViewport, zoomIn, zoomOut, setViewport, setCenter, fitView } = useReactFlow(); + const { screenToFlowPosition, getViewport, zoomIn, zoomOut, setViewport, setCenter } = useReactFlow(); const { show: showToast } = useToast(); const [isDragOver, setIsDragOver] = useState(false); const [dropType, setDropType] = useState<"image" | "audio" | "workflow" | "node" | null>(null); @@ -2488,7 +2488,7 @@ export function WorkflowCanvas() { initialCapabilityFilter={fallbackDialogState.capability} showClearOption onClearSelection={() => { - updateNodeData(fallbackDialogState.nodeId, { fallbackModel: undefined }); + updateNodeData(fallbackDialogState.nodeId, { fallbackModel: undefined, fallbackParameters: undefined }); setFallbackDialogState(null); }} onModelSelected={(model) => { diff --git a/src/components/nodes/BaseNode.tsx b/src/components/nodes/BaseNode.tsx index b1bc3dbe..dcdcf141 100644 --- a/src/components/nodes/BaseNode.tsx +++ b/src/components/nodes/BaseNode.tsx @@ -293,7 +293,7 @@ export function BaseNode({ ? `relative flex flex-col w-full h-full overflow-visible bg-neutral-800 rounded-lg ${selected ? "ring-2 ring-blue-500/40 shadow-lg shadow-blue-500/25" : ""}` : "contents"} onDoubleClick={handleResizeHandleDblClick} - data-tutorial={dataTutorial} + data-tutorial={hasExpandedSettings ? dataTutorial : undefined} > { if (e.buttons !== 0 || isPanningRef.current || isDraggingNodeRef.current) return; setHoveredNodeId(id); diff --git a/src/components/nodes/GenerateImageNode.tsx b/src/components/nodes/GenerateImageNode.tsx index cf19d1d6..8d021b25 100644 --- a/src/components/nodes/GenerateImageNode.tsx +++ b/src/components/nodes/GenerateImageNode.tsx @@ -68,6 +68,12 @@ export function GenerateImageNode({ id, data, selected }: NodeProps("primary"); + useEffect(() => { + if (!nodeData.fallbackModel && settingsTab === "fallback") { + setSettingsTab("primary"); + } + }, [nodeData.fallbackModel, settingsTab]); + // Inline parameters infrastructure const { inlineParametersEnabled } = useInlineParameters(); diff --git a/src/components/nodes/GenerateVideoNode.tsx b/src/components/nodes/GenerateVideoNode.tsx index 9dbf4944..03165235 100644 --- a/src/components/nodes/GenerateVideoNode.tsx +++ b/src/components/nodes/GenerateVideoNode.tsx @@ -56,6 +56,13 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps("primary"); + + useEffect(() => { + if (!nodeData.fallbackModel && settingsTab === "fallback") { + setSettingsTab("primary"); + } + }, [nodeData.fallbackModel, settingsTab]); + const videoBlobUrl = useVideoBlobUrl(nodeData.outputVideo ?? null); const videoAutoplayRef = useVideoAutoplay(id, selected); diff --git a/src/components/nodes/LLMFallbackPopover.tsx b/src/components/nodes/LLMFallbackPopover.tsx index 3db7c3fb..66cb1bbf 100644 --- a/src/components/nodes/LLMFallbackPopover.tsx +++ b/src/components/nodes/LLMFallbackPopover.tsx @@ -88,7 +88,7 @@ export function LLMFallbackPopover({ nodeId, onClose }: LLMFallbackPopoverProps) }; const handleRemove = () => { - updateNodeData(nodeId, { fallbackModel: undefined }); + updateNodeData(nodeId, { fallbackModel: undefined, fallbackParameters: undefined }); onClose(); }; diff --git a/src/components/nodes/LLMGenerateNode.tsx b/src/components/nodes/LLMGenerateNode.tsx index e22e2979..411ec6f5 100644 --- a/src/components/nodes/LLMGenerateNode.tsx +++ b/src/components/nodes/LLMGenerateNode.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Handle, Position, NodeProps, Node } from "@xyflow/react"; import { BaseNode } from "./BaseNode"; import { useWorkflowStore } from "@/store/workflowStore"; @@ -57,6 +57,12 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps("primary"); + useEffect(() => { + if (!nodeData.fallbackModel && settingsTab === "fallback") { + setSettingsTab("primary"); + } + }, [nodeData.fallbackModel, settingsTab]); + const handleCopyOutput = useCallback(async () => { if (nodeData.outputText) { try { diff --git a/src/components/nodes/OutputGalleryNode.tsx b/src/components/nodes/OutputGalleryNode.tsx index b6f73101..4c97c3fb 100644 --- a/src/components/nodes/OutputGalleryNode.tsx +++ b/src/components/nodes/OutputGalleryNode.tsx @@ -82,19 +82,24 @@ export function OutputGalleryNode({ id, data, selected }: NodeProps img !== item.src); - updateNodeData(id, { images: filtered }); + const images = [...(nodeData.images || [])]; + const imgIndex = images.indexOf(item.src); + if (imgIndex !== -1) images.splice(imgIndex, 1); + updateNodeData(id, { images }); } else { - const filtered = (nodeData.videos || []).filter((vid) => vid !== item.src); - updateNodeData(id, { videos: filtered }); + const videos = [...(nodeData.videos || [])]; + const vidIndex = videos.indexOf(item.src); + if (vidIndex !== -1) videos.splice(vidIndex, 1); + updateNodeData(id, { videos }); } // Adjust lightbox after removal if (lightboxIndex !== null) { - if (displayMedia.length <= 1) { + const newLength = displayMedia.length - 1; + if (newLength <= 0) { setLightboxIndex(null); - } else if (lightboxIndex >= displayMedia.length - 1) { - setLightboxIndex(displayMedia.length - 2); + } else if (lightboxIndex >= newLength) { + setLightboxIndex(newLength - 1); } } }, [displayMedia, nodeData.images, nodeData.videos, updateNodeData, id, lightboxIndex]); diff --git a/src/components/onboarding/FTUXApiKeysStep.tsx b/src/components/onboarding/FTUXApiKeysStep.tsx index 14d01de9..ace8d11c 100644 --- a/src/components/onboarding/FTUXApiKeysStep.tsx +++ b/src/components/onboarding/FTUXApiKeysStep.tsx @@ -75,6 +75,7 @@ const providers: ProviderInfo[] = [ export function FTUXApiKeysStep({}: FTUXStepProps) { const updateProviderApiKey = useWorkflowStore((state) => state.updateProviderApiKey); + const providerSettings = useWorkflowStore((state) => state.providerSettings); const [envStatus, setEnvStatus] = useState(null); const [showKey, setShowKey] = useState>({ gemini: false, @@ -85,14 +86,21 @@ export function FTUXApiKeysStep({}: FTUXStepProps) { kie: false, wavespeed: false, }); - const [localKeys, setLocalKeys] = useState>({ - gemini: "", - openai: "", - anthropic: "", - replicate: "", - fal: "", - kie: "", - wavespeed: "", + const [localKeys, setLocalKeys] = useState>(() => { + const keys: Record = { + gemini: "", + openai: "", + anthropic: "", + replicate: "", + fal: "", + kie: "", + wavespeed: "", + }; + for (const id of Object.keys(keys) as ProviderType[]) { + const saved = providerSettings.providers[id]?.apiKey; + if (saved) keys[id] = saved; + } + return keys; }); useEffect(() => { diff --git a/src/store/execution/generate3dExecutor.ts b/src/store/execution/generate3dExecutor.ts index 6ac9322c..468980de 100644 --- a/src/store/execution/generate3dExecutor.ts +++ b/src/store/execution/generate3dExecutor.ts @@ -184,6 +184,11 @@ export async function executeGenerate3D( displayName: "", }; + if (!primaryModel.modelId) { + updateNodeData(node.id, { status: "error", error: "No model selected" }); + throw new Error("No model selected"); + } + await runWithFallback({ nodeId: node.id, primary: primaryModel, @@ -191,6 +196,6 @@ export async function executeGenerate3D( fallbackParameters: nodeData.fallbackParameters, updateNodeData, runOnce, - clearOutput: { outputVideo: null }, + clearOutput: { output3dUrl: null }, }); } diff --git a/src/store/ftuxStore.ts b/src/store/ftuxStore.ts index 2b450dae..982b28ac 100644 --- a/src/store/ftuxStore.ts +++ b/src/store/ftuxStore.ts @@ -212,7 +212,6 @@ const initialTutorialSteps: TutorialStep[] = [ waitForClick: true, links: [ { text: "Join our Discord community", url: "https://discord.gg/node-banana" }, - { text: "View documentation", url: "https://docs.nodebanana.com" }, ], completed: false, }, @@ -240,15 +239,25 @@ export const useFTUXStore = create((set, get) => ({ loadTutorialSampleImage: async () => { try { const response = await fetch("/sample-images/owl.jpg"); + if (!response.ok) { + console.error("Failed to load tutorial sample image: HTTP", response.status); + set({ tutorialSampleImage: null }); + return; + } const blob = await response.blob(); const reader = new FileReader(); reader.onloadend = () => { const base64 = reader.result as string; set({ tutorialSampleImage: base64 }); }; + reader.onerror = () => { + console.error("Failed to read tutorial sample image as data URL"); + set({ tutorialSampleImage: null }); + }; reader.readAsDataURL(blob); } catch (error) { console.error("Failed to load tutorial sample image:", error); + set({ tutorialSampleImage: null }); } }, diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 5ee65ca6..9f95e051 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -1534,9 +1534,16 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ } } + // Remap startLevel to prefixLevels index (original index may not match after partitioning) + let prefixStartLevel = 0; + if (startFromNodeId) { + const idx = prefixLevels.findIndex((l) => l.nodeIds.includes(startFromNodeId)); + prefixStartLevel = idx !== -1 ? idx : 0; + } + // Execute prefix once logger.info('node.execution', 'Executing prefix levels', { count: prefixLevels.length }); - await executeLevels(prefixLevels, startLevel); + await executeLevels(prefixLevels, prefixStartLevel); // Execute loop N times const loopCount = loopEdge.data?.loopCount ?? 3; diff --git a/src/types/nodes.ts b/src/types/nodes.ts index 901dafae..5feb68fd 100644 --- a/src/types/nodes.ts +++ b/src/types/nodes.ts @@ -334,7 +334,7 @@ export interface OutputNodeData extends BaseNodeData { export interface OutputGalleryNodeData extends BaseNodeData { images: string[]; // Array of base64 data URLs from connected nodes imageRefs?: string[]; // External storage refs for images - videos: string[]; // Array of video URLs from connected nodes + videos?: string[]; // Array of video URLs from connected nodes videoRefs?: string[]; // External storage refs for videos } diff --git a/src/utils/mediaStorage.ts b/src/utils/mediaStorage.ts index 27909e46..e8884712 100644 --- a/src/utils/mediaStorage.ts +++ b/src/utils/mediaStorage.ts @@ -455,7 +455,7 @@ async function externalizeNodeMedia( // Externalize gallery videos for (let i = 0; i < (d.videos?.length || 0); i++) { - const vid = d.videos[i]; + const vid = d.videos![i]; const existingRef = galleryVideoRefs[i]; if (existingRef && isDataUrl(vid)) { // Already has ref, just keep it @@ -1084,9 +1084,9 @@ async function hydrateNodeMedia( newData = { ...d, images: filteredImages, - imageRefs: filteredImageRefs.some(r => r !== "") ? filteredImageRefs : d.imageRefs, + imageRefs: filteredImageRefs, videos: filteredVideos, - videoRefs: filteredVideoRefs.some(r => r !== "") ? filteredVideoRefs : d.videoRefs, + videoRefs: filteredVideoRefs, }; break; }