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.
 
 

494 lines
20 KiB

"use client";
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";
import { useReactFlow } from "@xyflow/react";
import { useI18n } from "@/i18n";
import { buildDefaultGenerationNodeData } from "@/utils/defaultGenerationNodeModel";
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() {
const pane = document.querySelector(".react-flow");
if (pane) {
const rect = pane.getBoundingClientRect();
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
};
}
return { x: window.innerWidth / 2, y: window.innerHeight / 2 };
}
function GenerateComboButton() {
const { t } = useI18n();
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const addNode = useWorkflowStore((state) => state.addNode);
const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
const { screenToFlowPosition } = useReactFlow();
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);
const handleAddNode = async (type: NodeType) => {
const center = getPaneCenter();
const position = screenToFlowPosition({
x: center.x + Math.random() * 100 - 50,
y: center.y + Math.random() * 100 - 50,
});
const nodeId = addNode(type, position, await buildDefaultGenerationNodeData(type));
selectSingleNode(nodeId);
setIsOpen(false);
};
const handleDragStart = (event: React.DragEvent, type: NodeType) => {
event.dataTransfer.setData("application/node-type", type);
event.dataTransfer.effectAllowed = "copy";
setIsOpen(false);
};
return (
<div className="relative" ref={menuRef}>
<button
onClick={() => setIsOpen(!isOpen)}
title={t("toolbar.addNode")}
aria-label={t("toolbar.addNode")}
aria-expanded={isOpen}
className="flex h-8 w-8 items-center justify-center rounded-lg bg-neutral-200 text-neutral-950 shadow-sm transition-colors hover:bg-neutral-100"
>
<span className={`text-[18px] leading-none transition-transform duration-200 ${isOpen ? "rotate-45" : "rotate-0"}`}>
<PlusOutlined />
</span>
<span className="sr-only">{t("toolbar.addNode")}</span>
</button>
{isOpen && (
<GenerateNodeMenu
draggable
onSelect={handleAddNode}
onDragStart={handleDragStart}
className="absolute left-full top-0 ml-2 min-w-[140px] overflow-visible outline-none"
/>
)}
</div>
);
}
function ResourceImageButton() {
const { t } = useI18n();
const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false);
const addNode = useWorkflowStore((state) => state.addNode);
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
const { screenToFlowPosition } = useReactFlow();
const handleSelectAsset = (asset: PopiAssetItem) => {
const preview = getPopiImageAssetPreview(asset);
if (!preview) return;
const center = getPaneCenter();
const position = screenToFlowPosition({
x: center.x + Math.random() * 100 - 50,
y: center.y + Math.random() * 100 - 50,
});
const nodeId = addNode("imageInput", position, {
image: preview.imageUrl || preview.previewImageUrl,
imageRef: undefined,
assetId: preview.assetId,
previewImage: preview.previewImageUrl,
assetDetailLoading: true,
assetDetailError: null,
filename: preview.filename,
dimensions: preview.dimensions,
});
selectSingleNode(nodeId);
setIsAssetPickerOpen(false);
const applyResolvedAsset = (
resolved: NonNullable<Awaited<ReturnType<typeof resolvePopiImageAsset>>>,
dimensions: { width: number; height: number } | null
) => {
updateNodeData(nodeId, {
image: resolved.imageUrl || resolved.previewImageUrl,
imageRef: undefined,
assetId: resolved.assetId,
previewImage: resolved.previewImageUrl,
assetDetailLoading: false,
assetDetailError: null,
filename: resolved.filename,
dimensions,
});
};
void resolvePopiImageAsset(asset)
.then((resolved) => {
if (!resolved) {
updateNodeData(nodeId, {
assetDetailLoading: false,
assetDetailError: null,
});
return;
}
if (resolved.dimensions || typeof Image === "undefined") {
applyResolvedAsset(resolved, resolved.dimensions);
return;
}
const image = new Image();
image.onload = () => applyResolvedAsset(resolved, { width: image.width, height: image.height });
image.onerror = () => applyResolvedAsset(resolved, preview.dimensions);
image.src = resolved.previewImageUrl;
})
.catch((error: unknown) => {
updateNodeData(nodeId, {
assetDetailLoading: false,
assetDetailError: error instanceof Error ? error.message : "Failed to load asset detail",
});
});
};
return (
<>
<button
onClick={() => setIsAssetPickerOpen(true)}
title={t("imageInput.fromAssets")}
aria-label={t("imageInput.fromAssets")}
className="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-300 transition-colors hover:bg-neutral-700 hover:text-neutral-100"
>
<PictureOutlined className="text-[18px]" />
<span className="sr-only">{t("imageInput.fromAssets")}</span>
</button>
{isAssetPickerOpen && (
<AssetPickerModal
kind="image"
onClose={() => setIsAssetPickerOpen(false)}
onSelect={handleSelectAsset}
/>
)}
</>
);
}
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">
<GenerateComboButton />
<ResourceImageButton />
<button
onClick={() => setModelSearchOpen(true)}
title={t("toolbar.browseModels")}
aria-label={t("toolbar.allModels")}
className="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-300 transition-colors hover:bg-neutral-700 hover:text-neutral-100"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 7h16M4 12h16M4 17h16" />
<path strokeLinecap="round" strokeLinejoin="round" d="M8 4v16M16 4v16" />
</svg>
<span className="sr-only">{t("toolbar.allModels")}</span>
</button>
<button
onClick={toggleEdgeStyle}
title={t("toolbar.switchConnectors", { style: edgeStyle === "angular" ? t("toolbar.curved") : t("toolbar.angular") })}
className="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-300 transition-colors hover:bg-neutral-700 hover:text-neutral-100"
>
{edgeStyle === "angular" ? (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 12h4l4-8 4 8h4" />
</svg>
) : (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 12c0 0 4-8 8-8s8 8 8 8" />
</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
isOpen={modelSearchOpen}
onClose={() => setModelSearchOpen(false)}
initialProvider={modelSearchProvider}
/>
</div>
);
}