|
|
|
@ -1,6 +1,6 @@ |
|
|
|
"use client"; |
|
|
|
|
|
|
|
import { useEffect, useRef, useState } from "react"; |
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
|
|
|
import { CloseOutlined, PictureOutlined, PlusOutlined } from "@ant-design/icons"; |
|
|
|
import { useWorkflowStore } from "@/store/workflowStore"; |
|
|
|
import { NodeType } from "@/types"; |
|
|
|
@ -11,6 +11,7 @@ import { GenerateNodeMenu } from "./GenerateNodeMenu"; |
|
|
|
import { ModelSearchDialog } from "./modals/ModelSearchDialog"; |
|
|
|
import { AssetPickerModal, PopiAssetItem } from "./AssetPickerModal"; |
|
|
|
import { getPopiImageAssetPreview, resolvePopiImageAsset } from "@/lib/popiAssetApi"; |
|
|
|
import { useFTUXStore, TutorialStep } from "@/store/ftuxStore"; |
|
|
|
|
|
|
|
// Get the center of the React Flow pane in screen coordinates
|
|
|
|
function getPaneCenter() { |
|
|
|
@ -195,16 +196,131 @@ function ResourceImageButton() { |
|
|
|
|
|
|
|
export function FloatingActionBar() { |
|
|
|
const { t } = useI18n(); |
|
|
|
const nodes = useWorkflowStore((state) => state.nodes) ?? []; |
|
|
|
const isRunning = useWorkflowStore((state) => state.isRunning) ?? false; |
|
|
|
const currentNodeIds = useWorkflowStore((state) => state.currentNodeIds) ?? []; |
|
|
|
const executeWorkflow = useWorkflowStore((state) => state.executeWorkflow) ?? (async () => {}); |
|
|
|
const regenerateNode = useWorkflowStore((state) => state.regenerateNode) ?? (async () => {}); |
|
|
|
const executeSelectedNodes = useWorkflowStore((state) => state.executeSelectedNodes) ?? (async () => {}); |
|
|
|
const stopWorkflow = useWorkflowStore((state) => state.stopWorkflow) ?? (() => {}); |
|
|
|
const mockTutorialExecution = useWorkflowStore((state) => state.mockTutorialExecution) ?? (async () => {}); |
|
|
|
const validateWorkflow = useWorkflowStore((state) => state.validateWorkflow); |
|
|
|
const edgeStyle = useWorkflowStore((state) => state.edgeStyle); |
|
|
|
const setEdgeStyle = useWorkflowStore((state) => state.setEdgeStyle); |
|
|
|
const setModelSearchOpen = useWorkflowStore((state) => state.setModelSearchOpen); |
|
|
|
const modelSearchOpen = useWorkflowStore((state) => state.modelSearchOpen); |
|
|
|
const modelSearchProvider = useWorkflowStore((state) => state.modelSearchProvider); |
|
|
|
const [tutorialActive, setTutorialActive] = useState(false); |
|
|
|
const [currentTutorialStep, setCurrentTutorialStep] = useState(0); |
|
|
|
const [tutorialSteps, setTutorialSteps] = useState<TutorialStep[]>([]); |
|
|
|
const [runMenuOpen, setRunMenuOpen] = useState(false); |
|
|
|
const runMenuRef = useRef<HTMLDivElement>(null); |
|
|
|
const { valid, errors } = validateWorkflow?.() ?? { valid: true, errors: [] }; |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
const unsubscribe = useFTUXStore.subscribe((state) => { |
|
|
|
setTutorialActive(state.tutorialActive); |
|
|
|
setCurrentTutorialStep(state.currentTutorialStep); |
|
|
|
setTutorialSteps(state.tutorialSteps); |
|
|
|
}); |
|
|
|
|
|
|
|
const currentState = useFTUXStore.getState(); |
|
|
|
setTutorialActive(currentState.tutorialActive); |
|
|
|
setCurrentTutorialStep(currentState.currentTutorialStep); |
|
|
|
setTutorialSteps(currentState.tutorialSteps); |
|
|
|
|
|
|
|
return unsubscribe; |
|
|
|
}, []); |
|
|
|
|
|
|
|
const selectedNodes = useMemo(() => nodes.filter((node) => node.selected), [nodes]); |
|
|
|
const selectedNode = useMemo(() => selectedNodes.length === 1 ? selectedNodes[0] : null, [selectedNodes]); |
|
|
|
|
|
|
|
const isRunOptionsTutorialStep = useMemo(() => { |
|
|
|
if (!tutorialActive || tutorialSteps.length === 0) return false; |
|
|
|
const currentStep = tutorialSteps[currentTutorialStep]; |
|
|
|
return currentStep?.id === "explain-run-options"; |
|
|
|
}, [currentTutorialStep, tutorialActive, tutorialSteps]); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
const handleClickOutside = (event: MouseEvent) => { |
|
|
|
if (runMenuRef.current && !runMenuRef.current.contains(event.target as Node) && !isRunOptionsTutorialStep) { |
|
|
|
setRunMenuOpen(false); |
|
|
|
} |
|
|
|
}; |
|
|
|
|
|
|
|
if (runMenuOpen) { |
|
|
|
document.addEventListener("mousedown", handleClickOutside); |
|
|
|
} |
|
|
|
|
|
|
|
return () => { |
|
|
|
document.removeEventListener("mousedown", handleClickOutside); |
|
|
|
}; |
|
|
|
}, [isRunOptionsTutorialStep, runMenuOpen]); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
if (isRunOptionsTutorialStep) { |
|
|
|
setRunMenuOpen(true); |
|
|
|
} |
|
|
|
}, [isRunOptionsTutorialStep]); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
if (!tutorialActive || tutorialSteps.length === 0) return; |
|
|
|
const currentStep = tutorialSteps[currentTutorialStep]; |
|
|
|
if ( |
|
|
|
currentStep?.id === "run-workflow" || |
|
|
|
currentStep?.id === "demonstrate-downstream" || |
|
|
|
currentStep?.id === "demonstrate-complete" |
|
|
|
) { |
|
|
|
setRunMenuOpen(false); |
|
|
|
} |
|
|
|
}, [currentTutorialStep, tutorialActive, tutorialSteps]); |
|
|
|
|
|
|
|
const runningNodeCount = currentNodeIds.length; |
|
|
|
const getRunningLabel = useCallback(() => { |
|
|
|
if (runningNodeCount === 0) return t("toolbar.running"); |
|
|
|
if (runningNodeCount === 1) { |
|
|
|
const node = nodes.find((item) => item.id === currentNodeIds[0]); |
|
|
|
const nodeName = node?.data?.customTitle || node?.type || "node"; |
|
|
|
return t("toolbar.runningNode", { node: nodeName }); |
|
|
|
} |
|
|
|
return t("toolbar.runningNodes", { count: runningNodeCount }); |
|
|
|
}, [currentNodeIds, nodes, runningNodeCount, t]); |
|
|
|
|
|
|
|
const toggleEdgeStyle = () => { |
|
|
|
setEdgeStyle(edgeStyle === "angular" ? "curved" : "angular"); |
|
|
|
}; |
|
|
|
|
|
|
|
const handleRunClick = useCallback(() => { |
|
|
|
const ftuxState = useFTUXStore.getState(); |
|
|
|
const currentStep = ftuxState.tutorialSteps[ftuxState.currentTutorialStep]; |
|
|
|
|
|
|
|
if (isRunning) { |
|
|
|
stopWorkflow(); |
|
|
|
} else if (ftuxState.tutorialActive && currentStep?.id === "run-workflow") { |
|
|
|
void mockTutorialExecution(); |
|
|
|
} else { |
|
|
|
void executeWorkflow(); |
|
|
|
} |
|
|
|
}, [executeWorkflow, isRunning, mockTutorialExecution, stopWorkflow]); |
|
|
|
|
|
|
|
const handleRunFromSelected = useCallback(() => { |
|
|
|
if (!selectedNode) return; |
|
|
|
void executeWorkflow(selectedNode.id); |
|
|
|
setRunMenuOpen(false); |
|
|
|
}, [executeWorkflow, selectedNode]); |
|
|
|
|
|
|
|
const handleRunSelectedOnly = useCallback(() => { |
|
|
|
if (!selectedNode) return; |
|
|
|
void regenerateNode(selectedNode.id); |
|
|
|
setRunMenuOpen(false); |
|
|
|
}, [regenerateNode, selectedNode]); |
|
|
|
|
|
|
|
const handleRunSelectedNodes = useCallback(() => { |
|
|
|
if (selectedNodes.length === 0) return; |
|
|
|
void executeSelectedNodes(selectedNodes.map((node) => node.id)); |
|
|
|
setRunMenuOpen(false); |
|
|
|
}, [executeSelectedNodes, selectedNodes]); |
|
|
|
|
|
|
|
return ( |
|
|
|
<div className="fixed left-5 top-1/2 z-50 -translate-y-1/2"> |
|
|
|
<div className="flex flex-col items-center gap-2 bg-neutral-800/95 rounded-2xl shadow-lg border border-neutral-700/80 px-1.5 py-1.5"> |
|
|
|
@ -239,6 +355,133 @@ export function FloatingActionBar() { |
|
|
|
</svg> |
|
|
|
)} |
|
|
|
</button> |
|
|
|
|
|
|
|
<div className="relative flex items-center" ref={runMenuRef}> |
|
|
|
<button |
|
|
|
onClick={handleRunClick} |
|
|
|
disabled={!valid && !isRunning} |
|
|
|
title={!valid ? errors.join("\n") : isRunning ? t("toolbar.stop") : t("toolbar.run")} |
|
|
|
data-tutorial="floating-run-button" |
|
|
|
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-colors ${ |
|
|
|
isRunning |
|
|
|
? "bg-neutral-100 text-neutral-950 hover:bg-neutral-200" |
|
|
|
: valid |
|
|
|
? "bg-neutral-100 text-neutral-950 hover:bg-neutral-200" |
|
|
|
: "cursor-not-allowed bg-neutral-700 text-neutral-500" |
|
|
|
}`}
|
|
|
|
> |
|
|
|
{isRunning ? ( |
|
|
|
<> |
|
|
|
<svg className="h-4 w-4 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> |
|
|
|
<span className="sr-only" title={getRunningLabel()}> |
|
|
|
{runningNodeCount > 1 ? t("toolbar.nodesCount", { count: runningNodeCount }) : t("toolbar.stop")} |
|
|
|
</span> |
|
|
|
</> |
|
|
|
) : ( |
|
|
|
<> |
|
|
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24"> |
|
|
|
<path d="M8 5v14l11-7z" /> |
|
|
|
</svg> |
|
|
|
<span className="sr-only">{t("toolbar.run")}</span> |
|
|
|
</> |
|
|
|
)} |
|
|
|
</button> |
|
|
|
|
|
|
|
{!isRunning && valid && ( |
|
|
|
<button |
|
|
|
onClick={() => setRunMenuOpen(!runMenuOpen)} |
|
|
|
data-tutorial="floating-run-dropdown" |
|
|
|
className="absolute -bottom-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full border border-neutral-600 bg-neutral-700 text-neutral-200 transition-colors hover:bg-neutral-600" |
|
|
|
title={t("toolbar.runOptions")} |
|
|
|
aria-label={t("toolbar.runOptions")} |
|
|
|
> |
|
|
|
<svg |
|
|
|
className={`h-2.5 w-2.5 transition-transform ${runMenuOpen ? "rotate-180" : ""}`} |
|
|
|
fill="none" |
|
|
|
viewBox="0 0 24 24" |
|
|
|
stroke="currentColor" |
|
|
|
strokeWidth={2.5} |
|
|
|
> |
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" /> |
|
|
|
</svg> |
|
|
|
</button> |
|
|
|
)} |
|
|
|
|
|
|
|
{runMenuOpen && !isRunning && ( |
|
|
|
<div |
|
|
|
data-tutorial="floating-run-menu" |
|
|
|
className="absolute bottom-0 left-full ml-2 min-w-[180px] overflow-hidden rounded-lg border border-neutral-700 bg-neutral-800 shadow-xl" |
|
|
|
> |
|
|
|
<button |
|
|
|
onClick={() => { |
|
|
|
void executeWorkflow(); |
|
|
|
setRunMenuOpen(false); |
|
|
|
}} |
|
|
|
className="flex w-full items-center gap-2 px-3 py-2 text-left text-[11px] font-medium text-neutral-300 transition-colors hover:bg-neutral-700 hover:text-neutral-100" |
|
|
|
> |
|
|
|
<svg className="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 24 24"> |
|
|
|
<path d="M8 5v14l11-7z" /> |
|
|
|
</svg> |
|
|
|
{t("toolbar.runEntire")} |
|
|
|
</button> |
|
|
|
<button |
|
|
|
onClick={handleRunFromSelected} |
|
|
|
disabled={!selectedNode} |
|
|
|
title={!selectedNode ? t("toolbar.selectSingleNode") : undefined} |
|
|
|
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-[11px] font-medium transition-colors ${ |
|
|
|
selectedNode |
|
|
|
? "text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100" |
|
|
|
: "cursor-not-allowed text-neutral-500" |
|
|
|
}`}
|
|
|
|
> |
|
|
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> |
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13 5l7 7-7 7M5 5l7 7-7 7" /> |
|
|
|
</svg> |
|
|
|
{t("toolbar.runFromSelected")} |
|
|
|
</button> |
|
|
|
<button |
|
|
|
onClick={handleRunSelectedOnly} |
|
|
|
disabled={!selectedNode} |
|
|
|
title={!selectedNode ? t("toolbar.selectSingleNode") : undefined} |
|
|
|
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-[11px] font-medium transition-colors ${ |
|
|
|
selectedNode |
|
|
|
? "text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100" |
|
|
|
: "cursor-not-allowed text-neutral-500" |
|
|
|
}`}
|
|
|
|
> |
|
|
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> |
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z" /> |
|
|
|
</svg> |
|
|
|
{t("toolbar.runSelectedOnly")} |
|
|
|
</button> |
|
|
|
<button |
|
|
|
onClick={handleRunSelectedNodes} |
|
|
|
disabled={selectedNodes.length === 0} |
|
|
|
title={selectedNodes.length === 0 ? t("toolbar.selectOneOrMoreNodes") : t("toolbar.runSelectedNodesCount", { count: selectedNodes.length })} |
|
|
|
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-[11px] font-medium transition-colors ${ |
|
|
|
selectedNodes.length > 0 |
|
|
|
? "text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100" |
|
|
|
: "cursor-not-allowed text-neutral-500" |
|
|
|
}`}
|
|
|
|
> |
|
|
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> |
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z" /> |
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.75 9.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V9.653z" /> |
|
|
|
</svg> |
|
|
|
{selectedNodes.length > 0 |
|
|
|
? t("toolbar.runSelectedNodesCount", { count: selectedNodes.length }) |
|
|
|
: t("toolbar.runSelectedNodes")} |
|
|
|
</button> |
|
|
|
</div> |
|
|
|
)} |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
|
|
|
|
<ModelSearchDialog |
|
|
|
|