mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
workflow tab (#5680)
* new workflow tab UI - still using shared state with workflow editor tab * polish workflow details * remove workflow tab, add edit/view mode to workflow slice and get that working to switch between within editor tab * UI updates for view/edit mode * cleanup * add warning to view mode * lint * start with isTouched false * working on styling mode toggle * more UX iteration * lint * cleanup * save original field values to state, add indicator if they have been changed and give user choice to reset * lint * fix import and commit translation * dont switch to view mode when loading a workflow * warns before clearing editor * use folder icon * fix(ui): track do not erase value when resetting field value - When adding an exposed field, we need to add it to originalExposedFieldValues - When removing an exposed field, we need to remove it from originalExposedFieldValues - add `useFieldValue` and `useOriginalFieldValue` hooks to encapsulate related logic * feat(ui): use IconButton for workflow view/edit button * feat(ui): change icon for new workflow It was the same as the workflow tab icon, confusing bc you think it's going to somehow take you to the tab. * feat(ui): use render props for NewWorkflowConfirmationAlertDialog There was a lot of potentially sensitive logic shared between the new workflow button and menu items. Also, two instances of ConfirmationAlertDialog. Using a render prop deduplicates the logic & components * fix(ui): do not mark workflow touched when loading workflow This was occurring because the `nodesChanged` action is called by reactflow when loading a workflow. Specifically, it calculates and sets the node dimensions as it loads. The existing logic set `isTouched` whenever this action was called. The changes reactflow emits have types, and we can use the change types and data to determine if a change should result in the workflow being marked as touched. * chore(ui): lint * chore(ui): lint * delete empty file --------- Co-authored-by: Mary Hipp <maryhipp@Marys-MacBook-Air.local> Co-authored-by: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
This commit is contained in:
parent
85bbf65967
commit
9d6e4ff1fb
@ -175,6 +175,7 @@
|
|||||||
"statusUpscaling": "Upscaling",
|
"statusUpscaling": "Upscaling",
|
||||||
"statusUpscalingESRGAN": "Upscaling (ESRGAN)",
|
"statusUpscalingESRGAN": "Upscaling (ESRGAN)",
|
||||||
"template": "Template",
|
"template": "Template",
|
||||||
|
"toResolve": "To resolve",
|
||||||
"training": "Training",
|
"training": "Training",
|
||||||
"trainingDesc1": "A dedicated workflow for training your own embeddings and checkpoints using Textual Inversion and Dreambooth from the web interface.",
|
"trainingDesc1": "A dedicated workflow for training your own embeddings and checkpoints using Textual Inversion and Dreambooth from the web interface.",
|
||||||
"trainingDesc2": "InvokeAI already supports training custom embeddourings using Textual Inversion using the main script.",
|
"trainingDesc2": "InvokeAI already supports training custom embeddourings using Textual Inversion using the main script.",
|
||||||
@ -900,6 +901,7 @@
|
|||||||
"doesNotExist": "does not exist",
|
"doesNotExist": "does not exist",
|
||||||
"downloadWorkflow": "Download Workflow JSON",
|
"downloadWorkflow": "Download Workflow JSON",
|
||||||
"edge": "Edge",
|
"edge": "Edge",
|
||||||
|
"editMode": "Edit in Workflow Editor",
|
||||||
"enum": "Enum",
|
"enum": "Enum",
|
||||||
"enumDescription": "Enums are values that may be one of a number of options.",
|
"enumDescription": "Enums are values that may be one of a number of options.",
|
||||||
"executionStateCompleted": "Completed",
|
"executionStateCompleted": "Completed",
|
||||||
@ -995,6 +997,7 @@
|
|||||||
"problemReadingMetadata": "Problem reading metadata from image",
|
"problemReadingMetadata": "Problem reading metadata from image",
|
||||||
"problemReadingWorkflow": "Problem reading workflow from image",
|
"problemReadingWorkflow": "Problem reading workflow from image",
|
||||||
"problemSettingTitle": "Problem Setting Title",
|
"problemSettingTitle": "Problem Setting Title",
|
||||||
|
"resetToDefaultValue": "Reset to default value",
|
||||||
"reloadNodeTemplates": "Reload Node Templates",
|
"reloadNodeTemplates": "Reload Node Templates",
|
||||||
"removeLinearView": "Remove from Linear View",
|
"removeLinearView": "Remove from Linear View",
|
||||||
"newWorkflow": "New Workflow",
|
"newWorkflow": "New Workflow",
|
||||||
@ -1067,6 +1070,7 @@
|
|||||||
"vaeModelFieldDescription": "TODO",
|
"vaeModelFieldDescription": "TODO",
|
||||||
"validateConnections": "Validate Connections and Graph",
|
"validateConnections": "Validate Connections and Graph",
|
||||||
"validateConnectionsHelp": "Prevent invalid connections from being made, and invalid graphs from being invoked",
|
"validateConnectionsHelp": "Prevent invalid connections from being made, and invalid graphs from being invoked",
|
||||||
|
"viewMode": "Use in Linear View",
|
||||||
"unableToGetWorkflowVersion": "Unable to get workflow schema version",
|
"unableToGetWorkflowVersion": "Unable to get workflow schema version",
|
||||||
"unrecognizedWorkflowVersion": "Unrecognized workflow schema version {{version}}",
|
"unrecognizedWorkflowVersion": "Unrecognized workflow schema version {{version}}",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
|
@ -6,7 +6,6 @@ import { WorkflowMigrationError, WorkflowVersionError } from 'features/nodes/typ
|
|||||||
import { validateWorkflow } from 'features/nodes/util/workflow/validateWorkflow';
|
import { validateWorkflow } from 'features/nodes/util/workflow/validateWorkflow';
|
||||||
import { addToast } from 'features/system/store/systemSlice';
|
import { addToast } from 'features/system/store/systemSlice';
|
||||||
import { makeToast } from 'features/system/util/makeToast';
|
import { makeToast } from 'features/system/util/makeToast';
|
||||||
import { setActiveTab } from 'features/ui/store/uiSlice';
|
|
||||||
import { t } from 'i18next';
|
import { t } from 'i18next';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { fromZodError } from 'zod-validation-error';
|
import { fromZodError } from 'zod-validation-error';
|
||||||
@ -53,7 +52,6 @@ export const addWorkflowLoadRequestedListener = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(setActiveTab('nodes'));
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
$flow.get()?.fitView();
|
$flow.get()?.fitView();
|
||||||
});
|
});
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { IconButton } from '@invoke-ai/ui-library';
|
import { IconButton } from '@invoke-ai/ui-library';
|
||||||
import { createSelector } from '@reduxjs/toolkit';
|
import { createSelector } from '@reduxjs/toolkit';
|
||||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||||
|
import { useFieldValue } from 'features/nodes/hooks/useFieldValue';
|
||||||
import {
|
import {
|
||||||
selectWorkflowSlice,
|
selectWorkflowSlice,
|
||||||
workflowExposedFieldAdded,
|
workflowExposedFieldAdded,
|
||||||
@ -18,7 +19,7 @@ type Props = {
|
|||||||
const FieldLinearViewToggle = ({ nodeId, fieldName }: Props) => {
|
const FieldLinearViewToggle = ({ nodeId, fieldName }: Props) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const value = useFieldValue(nodeId, fieldName);
|
||||||
const selectIsExposed = useMemo(
|
const selectIsExposed = useMemo(
|
||||||
() =>
|
() =>
|
||||||
createSelector(selectWorkflowSlice, (workflow) => {
|
createSelector(selectWorkflowSlice, (workflow) => {
|
||||||
@ -30,8 +31,8 @@ const FieldLinearViewToggle = ({ nodeId, fieldName }: Props) => {
|
|||||||
const isExposed = useAppSelector(selectIsExposed);
|
const isExposed = useAppSelector(selectIsExposed);
|
||||||
|
|
||||||
const handleExposeField = useCallback(() => {
|
const handleExposeField = useCallback(() => {
|
||||||
dispatch(workflowExposedFieldAdded({ nodeId, fieldName }));
|
dispatch(workflowExposedFieldAdded({ nodeId, fieldName, value }));
|
||||||
}, [dispatch, fieldName, nodeId]);
|
}, [dispatch, fieldName, nodeId, value]);
|
||||||
|
|
||||||
const handleUnexposeField = useCallback(() => {
|
const handleUnexposeField = useCallback(() => {
|
||||||
dispatch(workflowExposedFieldRemoved({ nodeId, fieldName }));
|
dispatch(workflowExposedFieldRemoved({ nodeId, fieldName }));
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
import { Flex, Icon, IconButton, Spacer, Tooltip } from '@invoke-ai/ui-library';
|
import { Flex, Icon, IconButton, Spacer, Tooltip } from '@invoke-ai/ui-library';
|
||||||
import { useAppDispatch } from 'app/store/storeHooks';
|
import { useAppDispatch } from 'app/store/storeHooks';
|
||||||
import NodeSelectionOverlay from 'common/components/NodeSelectionOverlay';
|
import NodeSelectionOverlay from 'common/components/NodeSelectionOverlay';
|
||||||
|
import { useFieldOriginalValue } from 'features/nodes/hooks/useFieldOriginalValue';
|
||||||
import { useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode';
|
import { useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode';
|
||||||
import { workflowExposedFieldRemoved } from 'features/nodes/store/workflowSlice';
|
import { workflowExposedFieldRemoved } from 'features/nodes/store/workflowSlice';
|
||||||
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
|
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
|
||||||
import { memo, useCallback } from 'react';
|
import { memo, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { PiInfoBold, PiTrashSimpleBold } from 'react-icons/pi';
|
import { PiArrowCounterClockwiseBold, PiInfoBold, PiTrashSimpleBold } from 'react-icons/pi';
|
||||||
|
|
||||||
import EditableFieldTitle from './EditableFieldTitle';
|
import EditableFieldTitle from './EditableFieldTitle';
|
||||||
import FieldTooltipContent from './FieldTooltipContent';
|
import FieldTooltipContent from './FieldTooltipContent';
|
||||||
@ -19,8 +20,10 @@ type Props = {
|
|||||||
|
|
||||||
const LinearViewField = ({ nodeId, fieldName }: Props) => {
|
const LinearViewField = ({ nodeId, fieldName }: Props) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const { isValueChanged, onReset } = useFieldOriginalValue(nodeId, fieldName);
|
||||||
const { isMouseOverNode, handleMouseOut, handleMouseOver } = useMouseOverNode(nodeId);
|
const { isMouseOverNode, handleMouseOut, handleMouseOver } = useMouseOverNode(nodeId);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const handleRemoveField = useCallback(() => {
|
const handleRemoveField = useCallback(() => {
|
||||||
dispatch(workflowExposedFieldRemoved({ nodeId, fieldName }));
|
dispatch(workflowExposedFieldRemoved({ nodeId, fieldName }));
|
||||||
}, [dispatch, fieldName, nodeId]);
|
}, [dispatch, fieldName, nodeId]);
|
||||||
@ -39,6 +42,16 @@ const LinearViewField = ({ nodeId, fieldName }: Props) => {
|
|||||||
<Flex>
|
<Flex>
|
||||||
<EditableFieldTitle nodeId={nodeId} fieldName={fieldName} kind="input" />
|
<EditableFieldTitle nodeId={nodeId} fieldName={fieldName} kind="input" />
|
||||||
<Spacer />
|
<Spacer />
|
||||||
|
{isValueChanged && (
|
||||||
|
<IconButton
|
||||||
|
aria-label={t('nodes.resetToDefaultValue')}
|
||||||
|
tooltip={t('nodes.resetToDefaultValue')}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onReset}
|
||||||
|
icon={<PiArrowCounterClockwiseBold />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={<FieldTooltipContent nodeId={nodeId} fieldName={fieldName} kind="input" />}
|
label={<FieldTooltipContent nodeId={nodeId} fieldName={fieldName} kind="input" />}
|
||||||
openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
|
openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
|
||||||
|
@ -1,25 +1,23 @@
|
|||||||
import { Flex, Spacer } from '@invoke-ai/ui-library';
|
import { Flex, Spacer } from '@invoke-ai/ui-library';
|
||||||
|
import { useAppSelector } from 'app/store/storeHooks';
|
||||||
import AddNodeButton from 'features/nodes/components/flow/panels/TopPanel/AddNodeButton';
|
import AddNodeButton from 'features/nodes/components/flow/panels/TopPanel/AddNodeButton';
|
||||||
import ClearFlowButton from 'features/nodes/components/flow/panels/TopPanel/ClearFlowButton';
|
import ClearFlowButton from 'features/nodes/components/flow/panels/TopPanel/ClearFlowButton';
|
||||||
import SaveWorkflowButton from 'features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton';
|
import SaveWorkflowButton from 'features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton';
|
||||||
import UpdateNodesButton from 'features/nodes/components/flow/panels/TopPanel/UpdateNodesButton';
|
import UpdateNodesButton from 'features/nodes/components/flow/panels/TopPanel/UpdateNodesButton';
|
||||||
import WorkflowName from 'features/nodes/components/flow/panels/TopPanel/WorkflowName';
|
import { WorkflowName } from 'features/nodes/components/sidePanel/WorkflowName';
|
||||||
import WorkflowLibraryButton from 'features/workflowLibrary/components/WorkflowLibraryButton';
|
|
||||||
import WorkflowLibraryMenu from 'features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu';
|
import WorkflowLibraryMenu from 'features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu';
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
const TopCenterPanel = () => {
|
const TopCenterPanel = () => {
|
||||||
|
const name = useAppSelector((s) => s.workflow.name);
|
||||||
return (
|
return (
|
||||||
<Flex gap={2} top={2} left={2} right={2} position="absolute" alignItems="flex-start" pointerEvents="none">
|
<Flex gap={2} top={2} left={2} right={2} position="absolute" alignItems="flex-start" pointerEvents="none">
|
||||||
<Flex flexDir="column" gap="2">
|
<Flex gap="2">
|
||||||
<Flex gap="2">
|
<AddNodeButton />
|
||||||
<AddNodeButton />
|
|
||||||
<WorkflowLibraryButton />
|
|
||||||
</Flex>
|
|
||||||
<UpdateNodesButton />
|
<UpdateNodesButton />
|
||||||
</Flex>
|
</Flex>
|
||||||
<Spacer />
|
<Spacer />
|
||||||
<WorkflowName />
|
{!!name.length && <WorkflowName />}
|
||||||
<Spacer />
|
<Spacer />
|
||||||
<ClearFlowButton />
|
<ClearFlowButton />
|
||||||
<SaveWorkflowButton />
|
<SaveWorkflowButton />
|
||||||
|
@ -25,6 +25,7 @@ const UpdateNodesButton = () => {
|
|||||||
icon={<PiWarningBold />}
|
icon={<PiWarningBold />}
|
||||||
onClick={handleClickUpdateNodes}
|
onClick={handleClickUpdateNodes}
|
||||||
pointerEvents="auto"
|
pointerEvents="auto"
|
||||||
|
colorScheme="warning"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,15 +0,0 @@
|
|||||||
import { Text } from '@invoke-ai/ui-library';
|
|
||||||
import { useAppSelector } from 'app/store/storeHooks';
|
|
||||||
import { memo } from 'react';
|
|
||||||
|
|
||||||
const TopCenterPanel = () => {
|
|
||||||
const name = useAppSelector((s) => s.workflow.name);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text m={2} fontSize="lg" userSelect="none" noOfLines={1} wordBreak="break-all" fontWeight="semibold" opacity={0.8}>
|
|
||||||
{name}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default memo(TopCenterPanel);
|
|
@ -1,15 +0,0 @@
|
|||||||
import { Flex } from '@invoke-ai/ui-library';
|
|
||||||
import WorkflowLibraryButton from 'features/workflowLibrary/components/WorkflowLibraryButton';
|
|
||||||
import WorkflowLibraryMenu from 'features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu';
|
|
||||||
import { memo } from 'react';
|
|
||||||
|
|
||||||
const TopRightPanel = () => {
|
|
||||||
return (
|
|
||||||
<Flex gap={2} position="absolute" top={2} insetInlineEnd={2}>
|
|
||||||
<WorkflowLibraryButton />
|
|
||||||
<WorkflowLibraryMenu />
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default memo(TopRightPanel);
|
|
@ -0,0 +1,43 @@
|
|||||||
|
import { Flex, IconButton } from '@invoke-ai/ui-library';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||||
|
import { workflowModeChanged } from 'features/nodes/store/workflowSlice';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { PiEyeBold, PiPencilBold } from 'react-icons/pi';
|
||||||
|
|
||||||
|
export const ModeToggle = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const mode = useAppSelector((s) => s.workflow.mode);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const onClickEdit = useCallback(() => {
|
||||||
|
dispatch(workflowModeChanged('edit'));
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const onClickView = useCallback(() => {
|
||||||
|
dispatch(workflowModeChanged('view'));
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex justifyContent="flex-end">
|
||||||
|
{mode === 'view' && (
|
||||||
|
<IconButton
|
||||||
|
aria-label={t('nodes.editMode')}
|
||||||
|
tooltip={t('nodes.editMode')}
|
||||||
|
onClick={onClickEdit}
|
||||||
|
icon={<PiPencilBold />}
|
||||||
|
colorScheme="invokeBlue"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{mode === 'edit' && (
|
||||||
|
<IconButton
|
||||||
|
aria-label={t('nodes.viewMode')}
|
||||||
|
tooltip={t('nodes.viewMode')}
|
||||||
|
onClick={onClickView}
|
||||||
|
icon={<PiEyeBold />}
|
||||||
|
colorScheme="invokeBlue"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
@ -1,22 +1,37 @@
|
|||||||
import 'reactflow/dist/style.css';
|
import 'reactflow/dist/style.css';
|
||||||
|
|
||||||
import { Flex } from '@invoke-ai/ui-library';
|
import { Flex } from '@invoke-ai/ui-library';
|
||||||
|
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||||
|
import { useAppSelector } from 'app/store/storeHooks';
|
||||||
|
import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice';
|
||||||
import QueueControls from 'features/queue/components/QueueControls';
|
import QueueControls from 'features/queue/components/QueueControls';
|
||||||
import ResizeHandle from 'features/ui/components/tabs/ResizeHandle';
|
import ResizeHandle from 'features/ui/components/tabs/ResizeHandle';
|
||||||
import { usePanelStorage } from 'features/ui/hooks/usePanelStorage';
|
import { usePanelStorage } from 'features/ui/hooks/usePanelStorage';
|
||||||
|
import WorkflowLibraryButton from 'features/workflowLibrary/components/WorkflowLibraryButton';
|
||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
import { memo, useCallback, useRef } from 'react';
|
import { memo, useCallback, useRef } from 'react';
|
||||||
import type { ImperativePanelGroupHandle } from 'react-resizable-panels';
|
import type { ImperativePanelGroupHandle } from 'react-resizable-panels';
|
||||||
import { Panel, PanelGroup } from 'react-resizable-panels';
|
import { Panel, PanelGroup } from 'react-resizable-panels';
|
||||||
|
|
||||||
import InspectorPanel from './inspector/InspectorPanel';
|
import InspectorPanel from './inspector/InspectorPanel';
|
||||||
|
import { WorkflowViewMode } from './viewMode/WorkflowViewMode';
|
||||||
import WorkflowPanel from './workflow/WorkflowPanel';
|
import WorkflowPanel from './workflow/WorkflowPanel';
|
||||||
|
import { WorkflowMenu } from './WorkflowMenu';
|
||||||
|
import { WorkflowName } from './WorkflowName';
|
||||||
|
|
||||||
const panelGroupStyles: CSSProperties = { height: '100%', width: '100%' };
|
const panelGroupStyles: CSSProperties = { height: '100%', width: '100%' };
|
||||||
|
|
||||||
|
const selector = createMemoizedSelector(selectWorkflowSlice, (workflow) => {
|
||||||
|
return {
|
||||||
|
mode: workflow.mode,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const NodeEditorPanelGroup = () => {
|
const NodeEditorPanelGroup = () => {
|
||||||
|
const { mode } = useAppSelector(selector);
|
||||||
const panelGroupRef = useRef<ImperativePanelGroupHandle>(null);
|
const panelGroupRef = useRef<ImperativePanelGroupHandle>(null);
|
||||||
const panelStorage = usePanelStorage();
|
const panelStorage = usePanelStorage();
|
||||||
|
|
||||||
const handleDoubleClickHandle = useCallback(() => {
|
const handleDoubleClickHandle = useCallback(() => {
|
||||||
if (!panelGroupRef.current) {
|
if (!panelGroupRef.current) {
|
||||||
return;
|
return;
|
||||||
@ -27,22 +42,33 @@ const NodeEditorPanelGroup = () => {
|
|||||||
return (
|
return (
|
||||||
<Flex w="full" h="full" gap={2} flexDir="column">
|
<Flex w="full" h="full" gap={2} flexDir="column">
|
||||||
<QueueControls />
|
<QueueControls />
|
||||||
<PanelGroup
|
<Flex w="full" justifyContent="space-between" alignItems="center" gap="4" padding={1}>
|
||||||
ref={panelGroupRef}
|
<Flex justifyContent="space-between" alignItems="center" gap="4">
|
||||||
id="workflow-panel-group"
|
<WorkflowLibraryButton />
|
||||||
autoSaveId="workflow-panel-group"
|
<WorkflowName />
|
||||||
direction="vertical"
|
</Flex>
|
||||||
style={panelGroupStyles}
|
<WorkflowMenu />
|
||||||
storage={panelStorage}
|
</Flex>
|
||||||
>
|
|
||||||
<Panel id="workflow" collapsible minSize={25}>
|
{mode === 'view' && <WorkflowViewMode />}
|
||||||
<WorkflowPanel />
|
{mode === 'edit' && (
|
||||||
</Panel>
|
<PanelGroup
|
||||||
<ResizeHandle orientation="horizontal" onDoubleClick={handleDoubleClickHandle} />
|
ref={panelGroupRef}
|
||||||
<Panel id="inspector" collapsible minSize={25}>
|
id="workflow-panel-group"
|
||||||
<InspectorPanel />
|
autoSaveId="workflow-panel-group"
|
||||||
</Panel>
|
direction="vertical"
|
||||||
</PanelGroup>
|
style={panelGroupStyles}
|
||||||
|
storage={panelStorage}
|
||||||
|
>
|
||||||
|
<Panel id="workflow" collapsible minSize={25}>
|
||||||
|
<WorkflowPanel />
|
||||||
|
</Panel>
|
||||||
|
<ResizeHandle orientation="horizontal" onDoubleClick={handleDoubleClickHandle} />
|
||||||
|
<Panel id="inspector" collapsible minSize={25}>
|
||||||
|
<InspectorPanel />
|
||||||
|
</Panel>
|
||||||
|
</PanelGroup>
|
||||||
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -0,0 +1,26 @@
|
|||||||
|
import { Flex } from '@invoke-ai/ui-library';
|
||||||
|
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||||
|
import { useAppSelector } from 'app/store/storeHooks';
|
||||||
|
import SaveWorkflowButton from 'features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton';
|
||||||
|
import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice';
|
||||||
|
import { NewWorkflowButton } from 'features/workflowLibrary/components/NewWorkflowButton';
|
||||||
|
|
||||||
|
import { ModeToggle } from './ModeToggle';
|
||||||
|
|
||||||
|
const selector = createMemoizedSelector(selectWorkflowSlice, (workflow) => {
|
||||||
|
return {
|
||||||
|
mode: workflow.mode,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const WorkflowMenu = () => {
|
||||||
|
const { mode } = useAppSelector(selector);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex gap="2" alignItems="center">
|
||||||
|
{mode === 'edit' && <SaveWorkflowButton />}
|
||||||
|
<NewWorkflowButton />
|
||||||
|
<ModeToggle />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,37 @@
|
|||||||
|
import { Flex, Icon, Text, Tooltip } from '@invoke-ai/ui-library';
|
||||||
|
import { useAppSelector } from 'app/store/storeHooks';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { PiDotOutlineFill } from 'react-icons/pi';
|
||||||
|
|
||||||
|
import WorkflowInfoTooltipContent from './viewMode/WorkflowInfoTooltipContent';
|
||||||
|
import { WorkflowWarning } from './viewMode/WorkflowWarning';
|
||||||
|
|
||||||
|
export const WorkflowName = () => {
|
||||||
|
const { name, isTouched, mode } = useAppSelector((s) => s.workflow);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex gap="1" alignItems="center">
|
||||||
|
{name.length ? (
|
||||||
|
<Tooltip label={<WorkflowInfoTooltipContent />} placement="top">
|
||||||
|
<Text fontSize="lg" userSelect="none" noOfLines={1} wordBreak="break-all" fontWeight="semibold">
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Text fontSize="lg" fontStyle="italic" fontWeight="semibold">
|
||||||
|
{t('workflows.unnamedWorkflow')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isTouched && mode === 'edit' && (
|
||||||
|
<Tooltip label="Workflow has unsaved changes">
|
||||||
|
<Flex>
|
||||||
|
<Icon as={PiDotOutlineFill} boxSize="20px" sx={{ color: 'invokeYellow.500' }} />
|
||||||
|
</Flex>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<WorkflowWarning />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,53 @@
|
|||||||
|
import { Flex, FormLabel, Icon, IconButton, Spacer, Tooltip } from '@invoke-ai/ui-library';
|
||||||
|
import FieldTooltipContent from 'features/nodes/components/flow/nodes/Invocation/fields/FieldTooltipContent';
|
||||||
|
import InputFieldRenderer from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer';
|
||||||
|
import { useFieldLabel } from 'features/nodes/hooks/useFieldLabel';
|
||||||
|
import { useFieldOriginalValue } from 'features/nodes/hooks/useFieldOriginalValue';
|
||||||
|
import { useFieldTemplateTitle } from 'features/nodes/hooks/useFieldTemplateTitle';
|
||||||
|
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
|
||||||
|
import { t } from 'i18next';
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { PiArrowCounterClockwiseBold, PiInfoBold } from 'react-icons/pi';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
nodeId: string;
|
||||||
|
fieldName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WorkflowField = ({ nodeId, fieldName }: Props) => {
|
||||||
|
const label = useFieldLabel(nodeId, fieldName);
|
||||||
|
const fieldTemplateTitle = useFieldTemplateTitle(nodeId, fieldName, 'input');
|
||||||
|
const { isValueChanged, onReset } = useFieldOriginalValue(nodeId, fieldName);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex layerStyle="second" position="relative" borderRadius="base" w="full" p={4} gap="2" flexDir="column">
|
||||||
|
<Flex alignItems="center">
|
||||||
|
<FormLabel fontSize="sm">{label || fieldTemplateTitle}</FormLabel>
|
||||||
|
|
||||||
|
<Spacer />
|
||||||
|
{isValueChanged && (
|
||||||
|
<IconButton
|
||||||
|
aria-label={t('nodes.resetToDefaultValue')}
|
||||||
|
tooltip={t('nodes.resetToDefaultValue')}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onReset}
|
||||||
|
icon={<PiArrowCounterClockwiseBold />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Tooltip
|
||||||
|
label={<FieldTooltipContent nodeId={nodeId} fieldName={fieldName} kind="input" />}
|
||||||
|
openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<Flex h="24px" alignItems="center">
|
||||||
|
<Icon fontSize="md" color="base.300" as={PiInfoBold} />
|
||||||
|
</Flex>
|
||||||
|
</Tooltip>
|
||||||
|
</Flex>
|
||||||
|
<InputFieldRenderer nodeId={nodeId} fieldName={fieldName} />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(WorkflowField);
|
@ -0,0 +1,68 @@
|
|||||||
|
import { Box, Flex, Text } from '@invoke-ai/ui-library';
|
||||||
|
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||||
|
import { useAppSelector } from 'app/store/storeHooks';
|
||||||
|
import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice';
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const selector = createMemoizedSelector(selectWorkflowSlice, (workflow) => {
|
||||||
|
return {
|
||||||
|
name: workflow.name,
|
||||||
|
description: workflow.description,
|
||||||
|
notes: workflow.notes,
|
||||||
|
author: workflow.author,
|
||||||
|
tags: workflow.tags,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const WorkflowInfoTooltipContent = () => {
|
||||||
|
const { name, description, notes, author, tags } = useAppSelector(selector);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex flexDir="column" gap="2">
|
||||||
|
{!!name.length && (
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight="semibold">{t('nodes.workflowName')}</Text>
|
||||||
|
<Text opacity={0.7} fontStyle="oblique 5deg">
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{!!author.length && (
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight="semibold">{t('nodes.workflowAuthor')}</Text>
|
||||||
|
<Text opacity={0.7} fontStyle="oblique 5deg">
|
||||||
|
{author}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{!!tags.length && (
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight="semibold">{t('nodes.workflowTags')}</Text>
|
||||||
|
<Text opacity={0.7} fontStyle="oblique 5deg">
|
||||||
|
{tags}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{!!description.length && (
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight="semibold">{t('nodes.workflowDescription')}</Text>
|
||||||
|
<Text opacity={0.7} fontStyle="oblique 5deg">
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{!!notes.length && (
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight="semibold">{t('nodes.workflowNotes')}</Text>
|
||||||
|
<Text opacity={0.7} fontStyle="oblique 5deg">
|
||||||
|
{notes}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(WorkflowInfoTooltipContent);
|
@ -0,0 +1,39 @@
|
|||||||
|
import { Box, Flex } from '@invoke-ai/ui-library';
|
||||||
|
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||||
|
import { useAppSelector } from 'app/store/storeHooks';
|
||||||
|
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||||
|
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
|
||||||
|
import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice';
|
||||||
|
import { t } from 'i18next';
|
||||||
|
import { useGetOpenAPISchemaQuery } from 'services/api/endpoints/appInfo';
|
||||||
|
|
||||||
|
import WorkflowField from './WorkflowField';
|
||||||
|
|
||||||
|
const selector = createMemoizedSelector(selectWorkflowSlice, (workflow) => {
|
||||||
|
return {
|
||||||
|
fields: workflow.exposedFields,
|
||||||
|
name: workflow.name,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const WorkflowViewMode = () => {
|
||||||
|
const { isLoading } = useGetOpenAPISchemaQuery();
|
||||||
|
const { fields } = useAppSelector(selector);
|
||||||
|
return (
|
||||||
|
<Box position="relative" w="full" h="full">
|
||||||
|
<ScrollableContent>
|
||||||
|
<Flex position="relative" flexDir="column" alignItems="flex-start" p={1} gap={2} h="full" w="full">
|
||||||
|
{isLoading ? (
|
||||||
|
<IAINoContentFallback label={t('nodes.loadingNodes')} icon={null} />
|
||||||
|
) : fields.length ? (
|
||||||
|
fields.map(({ nodeId, fieldName }) => (
|
||||||
|
<WorkflowField key={`${nodeId}.${fieldName}`} nodeId={nodeId} fieldName={fieldName} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<IAINoContentFallback label={t('nodes.noFieldsLinearview')} icon={null} />
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</ScrollableContent>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,21 @@
|
|||||||
|
import { Flex, Icon, Tooltip } from '@invoke-ai/ui-library';
|
||||||
|
import { useGetNodesNeedUpdate } from 'features/nodes/hooks/useGetNodesNeedUpdate';
|
||||||
|
import { PiWarningBold } from 'react-icons/pi';
|
||||||
|
|
||||||
|
import { WorkflowWarningTooltip } from './WorkflowWarningTooltip';
|
||||||
|
|
||||||
|
export const WorkflowWarning = () => {
|
||||||
|
const nodesNeedUpdate = useGetNodesNeedUpdate();
|
||||||
|
|
||||||
|
if (!nodesNeedUpdate) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip label={<WorkflowWarningTooltip />}>
|
||||||
|
<Flex h="full" alignItems="center" gap="2">
|
||||||
|
<Icon color="warning.400" as={PiWarningBold} />
|
||||||
|
</Flex>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,20 @@
|
|||||||
|
import { Flex, Text } from '@invoke-ai/ui-library';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
export const WorkflowWarningTooltip = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex flexDir="column" gap="2">
|
||||||
|
<Flex flexDir="column" gap="2">
|
||||||
|
<Text fontWeight="semibold">{t('toast.loadedWithWarnings')}</Text>
|
||||||
|
<Flex flexDir="column">
|
||||||
|
<Text>{t('common.toResolve')}:</Text>
|
||||||
|
<Text>
|
||||||
|
{t('nodes.editMode')} >> {t('nodes.updateAllNodes')} >> {t('common.save')}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
@ -12,17 +12,17 @@ const WorkflowPanel = () => {
|
|||||||
<Flex layerStyle="first" flexDir="column" w="full" h="full" borderRadius="base" p={2} gap={2}>
|
<Flex layerStyle="first" flexDir="column" w="full" h="full" borderRadius="base" p={2} gap={2}>
|
||||||
<Tabs variant="line" display="flex" w="full" h="full" flexDir="column">
|
<Tabs variant="line" display="flex" w="full" h="full" flexDir="column">
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab>{t('common.linear')}</Tab>
|
|
||||||
<Tab>{t('common.details')}</Tab>
|
<Tab>{t('common.details')}</Tab>
|
||||||
|
<Tab>{t('common.linear')}</Tab>
|
||||||
<Tab>JSON</Tab>
|
<Tab>JSON</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
|
|
||||||
<TabPanels>
|
<TabPanels>
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
<WorkflowLinearTab />
|
<WorkflowGeneralTab />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
<WorkflowGeneralTab />
|
<WorkflowLinearTab />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
<WorkflowJSONTab />
|
<WorkflowJSONTab />
|
||||||
|
@ -0,0 +1,28 @@
|
|||||||
|
import { createSelector } from '@reduxjs/toolkit';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||||
|
import { useFieldValue } from 'features/nodes/hooks/useFieldValue';
|
||||||
|
import { fieldValueReset } from 'features/nodes/store/nodesSlice';
|
||||||
|
import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice';
|
||||||
|
import { isEqual } from 'lodash-es';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
export const useFieldOriginalValue = (nodeId: string, fieldName: string) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const selectOriginalExposedFieldValues = useMemo(
|
||||||
|
() =>
|
||||||
|
createSelector(
|
||||||
|
selectWorkflowSlice,
|
||||||
|
(workflow) =>
|
||||||
|
workflow.originalExposedFieldValues.find((v) => v.nodeId === nodeId && v.fieldName === fieldName)?.value
|
||||||
|
),
|
||||||
|
[nodeId, fieldName]
|
||||||
|
);
|
||||||
|
const originalValue = useAppSelector(selectOriginalExposedFieldValues);
|
||||||
|
const value = useFieldValue(nodeId, fieldName);
|
||||||
|
const isValueChanged = useMemo(() => !isEqual(value, originalValue), [value, originalValue]);
|
||||||
|
const onReset = useCallback(() => {
|
||||||
|
dispatch(fieldValueReset({ nodeId, fieldName, value: originalValue }));
|
||||||
|
}, [dispatch, fieldName, nodeId, originalValue]);
|
||||||
|
|
||||||
|
return { originalValue, isValueChanged, onReset };
|
||||||
|
};
|
@ -0,0 +1,23 @@
|
|||||||
|
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||||
|
import { useAppSelector } from 'app/store/storeHooks';
|
||||||
|
import { selectNodesSlice } from 'features/nodes/store/nodesSlice';
|
||||||
|
import { isInvocationNode } from 'features/nodes/types/invocation';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
export const useFieldValue = (nodeId: string, fieldName: string) => {
|
||||||
|
const selector = useMemo(
|
||||||
|
() =>
|
||||||
|
createMemoizedSelector(selectNodesSlice, (nodes) => {
|
||||||
|
const node = nodes.nodes.find((node) => node.id === nodeId);
|
||||||
|
if (!isInvocationNode(node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return node?.data.inputs[fieldName]?.value;
|
||||||
|
}),
|
||||||
|
[fieldName, nodeId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useAppSelector(selector);
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
@ -18,6 +18,7 @@ import type {
|
|||||||
MainModelFieldValue,
|
MainModelFieldValue,
|
||||||
SchedulerFieldValue,
|
SchedulerFieldValue,
|
||||||
SDXLRefinerModelFieldValue,
|
SDXLRefinerModelFieldValue,
|
||||||
|
StatefulFieldValue,
|
||||||
StringFieldValue,
|
StringFieldValue,
|
||||||
T2IAdapterModelFieldValue,
|
T2IAdapterModelFieldValue,
|
||||||
VAEModelFieldValue,
|
VAEModelFieldValue,
|
||||||
@ -36,6 +37,7 @@ import {
|
|||||||
zMainModelFieldValue,
|
zMainModelFieldValue,
|
||||||
zSchedulerFieldValue,
|
zSchedulerFieldValue,
|
||||||
zSDXLRefinerModelFieldValue,
|
zSDXLRefinerModelFieldValue,
|
||||||
|
zStatefulFieldValue,
|
||||||
zStringFieldValue,
|
zStringFieldValue,
|
||||||
zT2IAdapterModelFieldValue,
|
zT2IAdapterModelFieldValue,
|
||||||
zVAEModelFieldValue,
|
zVAEModelFieldValue,
|
||||||
@ -478,6 +480,9 @@ export const nodesSlice = createSlice({
|
|||||||
selectedEdgesChanged: (state, action: PayloadAction<string[]>) => {
|
selectedEdgesChanged: (state, action: PayloadAction<string[]>) => {
|
||||||
state.selectedEdges = action.payload;
|
state.selectedEdges = action.payload;
|
||||||
},
|
},
|
||||||
|
fieldValueReset: (state, action: FieldValueAction<StatefulFieldValue>) => {
|
||||||
|
fieldValueReducer(state, action, zStatefulFieldValue);
|
||||||
|
},
|
||||||
fieldStringValueChanged: (state, action: FieldValueAction<StringFieldValue>) => {
|
fieldStringValueChanged: (state, action: FieldValueAction<StringFieldValue>) => {
|
||||||
fieldValueReducer(state, action, zStringFieldValue);
|
fieldValueReducer(state, action, zStringFieldValue);
|
||||||
},
|
},
|
||||||
@ -760,6 +765,7 @@ export const {
|
|||||||
edgesChanged,
|
edgesChanged,
|
||||||
edgesDeleted,
|
edgesDeleted,
|
||||||
edgeUpdated,
|
edgeUpdated,
|
||||||
|
fieldValueReset,
|
||||||
fieldBoardValueChanged,
|
fieldBoardValueChanged,
|
||||||
fieldBooleanValueChanged,
|
fieldBooleanValueChanged,
|
||||||
fieldColorValueChanged,
|
fieldColorValueChanged,
|
||||||
@ -834,7 +840,6 @@ export const isAnyNodeOrEdgeMutation = isAnyOf(
|
|||||||
nodeIsOpenChanged,
|
nodeIsOpenChanged,
|
||||||
nodeLabelChanged,
|
nodeLabelChanged,
|
||||||
nodeNotesChanged,
|
nodeNotesChanged,
|
||||||
nodesChanged,
|
|
||||||
nodesDeleted,
|
nodesDeleted,
|
||||||
nodeUseCacheChanged,
|
nodeUseCacheChanged,
|
||||||
notesNodeValueChanged,
|
notesNodeValueChanged,
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import type { FieldType } from 'features/nodes/types/field';
|
import type { FieldIdentifier, FieldType, StatefulFieldValue } from 'features/nodes/types/field';
|
||||||
import type {
|
import type {
|
||||||
AnyNode,
|
AnyNode,
|
||||||
InvocationNodeEdge,
|
InvocationNodeEdge,
|
||||||
@ -33,9 +33,16 @@ export type NodesState = {
|
|||||||
selectionMode: SelectionMode;
|
selectionMode: SelectionMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WorkflowMode = 'edit' | 'view';
|
||||||
|
export type FieldIdentifierWithValue = FieldIdentifier & {
|
||||||
|
value: StatefulFieldValue;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkflowsState = Omit<WorkflowV2, 'nodes' | 'edges'> & {
|
export type WorkflowsState = Omit<WorkflowV2, 'nodes' | 'edges'> & {
|
||||||
_version: 1;
|
_version: 1;
|
||||||
isTouched: boolean;
|
isTouched: boolean;
|
||||||
|
mode: WorkflowMode;
|
||||||
|
originalExposedFieldValues: FieldIdentifierWithValue[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NodeTemplatesState = {
|
export type NodeTemplatesState = {
|
||||||
|
@ -2,11 +2,16 @@ import type { PayloadAction } from '@reduxjs/toolkit';
|
|||||||
import { createSlice } from '@reduxjs/toolkit';
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
import type { PersistConfig, RootState } from 'app/store/store';
|
import type { PersistConfig, RootState } from 'app/store/store';
|
||||||
import { workflowLoaded } from 'features/nodes/store/actions';
|
import { workflowLoaded } from 'features/nodes/store/actions';
|
||||||
import { isAnyNodeOrEdgeMutation, nodeEditorReset, nodesDeleted } from 'features/nodes/store/nodesSlice';
|
import { isAnyNodeOrEdgeMutation, nodeEditorReset, nodesChanged, nodesDeleted } from 'features/nodes/store/nodesSlice';
|
||||||
import type { WorkflowsState as WorkflowState } from 'features/nodes/store/types';
|
import type {
|
||||||
|
FieldIdentifierWithValue,
|
||||||
|
WorkflowMode,
|
||||||
|
WorkflowsState as WorkflowState,
|
||||||
|
} from 'features/nodes/store/types';
|
||||||
import type { FieldIdentifier } from 'features/nodes/types/field';
|
import type { FieldIdentifier } from 'features/nodes/types/field';
|
||||||
|
import { isInvocationNode } from 'features/nodes/types/invocation';
|
||||||
import type { WorkflowCategory, WorkflowV2 } from 'features/nodes/types/workflow';
|
import type { WorkflowCategory, WorkflowV2 } from 'features/nodes/types/workflow';
|
||||||
import { cloneDeep, isEqual, uniqBy } from 'lodash-es';
|
import { cloneDeep, isEqual, omit, uniqBy } from 'lodash-es';
|
||||||
|
|
||||||
export const blankWorkflow: Omit<WorkflowV2, 'nodes' | 'edges'> = {
|
export const blankWorkflow: Omit<WorkflowV2, 'nodes' | 'edges'> = {
|
||||||
name: '',
|
name: '',
|
||||||
@ -23,7 +28,9 @@ export const blankWorkflow: Omit<WorkflowV2, 'nodes' | 'edges'> = {
|
|||||||
|
|
||||||
export const initialWorkflowState: WorkflowState = {
|
export const initialWorkflowState: WorkflowState = {
|
||||||
_version: 1,
|
_version: 1,
|
||||||
isTouched: true,
|
isTouched: false,
|
||||||
|
mode: 'view',
|
||||||
|
originalExposedFieldValues: [],
|
||||||
...blankWorkflow,
|
...blankWorkflow,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -31,15 +38,25 @@ export const workflowSlice = createSlice({
|
|||||||
name: 'workflow',
|
name: 'workflow',
|
||||||
initialState: initialWorkflowState,
|
initialState: initialWorkflowState,
|
||||||
reducers: {
|
reducers: {
|
||||||
workflowExposedFieldAdded: (state, action: PayloadAction<FieldIdentifier>) => {
|
workflowModeChanged: (state, action: PayloadAction<WorkflowMode>) => {
|
||||||
|
state.mode = action.payload;
|
||||||
|
},
|
||||||
|
workflowExposedFieldAdded: (state, action: PayloadAction<FieldIdentifierWithValue>) => {
|
||||||
state.exposedFields = uniqBy(
|
state.exposedFields = uniqBy(
|
||||||
state.exposedFields.concat(action.payload),
|
state.exposedFields.concat(omit(action.payload, 'value')),
|
||||||
|
(field) => `${field.nodeId}-${field.fieldName}`
|
||||||
|
);
|
||||||
|
state.originalExposedFieldValues = uniqBy(
|
||||||
|
state.originalExposedFieldValues.concat(action.payload),
|
||||||
(field) => `${field.nodeId}-${field.fieldName}`
|
(field) => `${field.nodeId}-${field.fieldName}`
|
||||||
);
|
);
|
||||||
state.isTouched = true;
|
state.isTouched = true;
|
||||||
},
|
},
|
||||||
workflowExposedFieldRemoved: (state, action: PayloadAction<FieldIdentifier>) => {
|
workflowExposedFieldRemoved: (state, action: PayloadAction<FieldIdentifier>) => {
|
||||||
state.exposedFields = state.exposedFields.filter((field) => !isEqual(field, action.payload));
|
state.exposedFields = state.exposedFields.filter((field) => !isEqual(field, action.payload));
|
||||||
|
state.originalExposedFieldValues = state.originalExposedFieldValues.filter(
|
||||||
|
(field) => !isEqual(omit(field, 'value'), action.payload)
|
||||||
|
);
|
||||||
state.isTouched = true;
|
state.isTouched = true;
|
||||||
},
|
},
|
||||||
workflowNameChanged: (state, action: PayloadAction<string>) => {
|
workflowNameChanged: (state, action: PayloadAction<string>) => {
|
||||||
@ -78,15 +95,43 @@ export const workflowSlice = createSlice({
|
|||||||
workflowIDChanged: (state, action: PayloadAction<string>) => {
|
workflowIDChanged: (state, action: PayloadAction<string>) => {
|
||||||
state.id = action.payload;
|
state.id = action.payload;
|
||||||
},
|
},
|
||||||
workflowReset: () => cloneDeep(initialWorkflowState),
|
|
||||||
workflowSaved: (state) => {
|
workflowSaved: (state) => {
|
||||||
state.isTouched = false;
|
state.isTouched = false;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
extraReducers: (builder) => {
|
extraReducers: (builder) => {
|
||||||
builder.addCase(workflowLoaded, (state, action) => {
|
builder.addCase(workflowLoaded, (state, action) => {
|
||||||
const { nodes: _nodes, edges: _edges, ...workflowExtra } = action.payload;
|
const { nodes, edges: _edges, ...workflowExtra } = action.payload;
|
||||||
return { ...initialWorkflowState, ...cloneDeep(workflowExtra) };
|
|
||||||
|
const originalExposedFieldValues: FieldIdentifierWithValue[] = [];
|
||||||
|
|
||||||
|
workflowExtra.exposedFields.forEach((field) => {
|
||||||
|
const node = nodes.find((n) => n.id === field.nodeId);
|
||||||
|
|
||||||
|
if (!isInvocationNode(node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = node.data.inputs[field.fieldName];
|
||||||
|
|
||||||
|
if (!input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalExposedFieldValue = {
|
||||||
|
nodeId: field.nodeId,
|
||||||
|
fieldName: field.fieldName,
|
||||||
|
value: input.value,
|
||||||
|
};
|
||||||
|
originalExposedFieldValues.push(originalExposedFieldValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...cloneDeep(initialWorkflowState),
|
||||||
|
...cloneDeep(workflowExtra),
|
||||||
|
originalExposedFieldValues,
|
||||||
|
mode: state.mode,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.addCase(nodesDeleted, (state, action) => {
|
builder.addCase(nodesDeleted, (state, action) => {
|
||||||
@ -97,6 +142,29 @@ export const workflowSlice = createSlice({
|
|||||||
|
|
||||||
builder.addCase(nodeEditorReset, () => cloneDeep(initialWorkflowState));
|
builder.addCase(nodeEditorReset, () => cloneDeep(initialWorkflowState));
|
||||||
|
|
||||||
|
builder.addCase(nodesChanged, (state, action) => {
|
||||||
|
// Not all changes to nodes should result in the workflow being marked touched
|
||||||
|
const filteredChanges = action.payload.filter((change) => {
|
||||||
|
// We always want to mark the workflow as touched if a node is added, removed, or reset
|
||||||
|
if (['add', 'remove', 'reset'].includes(change.type)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position changes can change the position and the dragging status of the node - ignore if the change doesn't
|
||||||
|
// affect the position
|
||||||
|
if (change.type === 'position' && (change.position || change.positionAbsolute)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This change isn't relevant
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filteredChanges.length > 0) {
|
||||||
|
state.isTouched = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
builder.addMatcher(isAnyNodeOrEdgeMutation, (state) => {
|
builder.addMatcher(isAnyNodeOrEdgeMutation, (state) => {
|
||||||
state.isTouched = true;
|
state.isTouched = true;
|
||||||
});
|
});
|
||||||
@ -104,6 +172,7 @@ export const workflowSlice = createSlice({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
|
workflowModeChanged,
|
||||||
workflowExposedFieldAdded,
|
workflowExposedFieldAdded,
|
||||||
workflowExposedFieldRemoved,
|
workflowExposedFieldRemoved,
|
||||||
workflowNameChanged,
|
workflowNameChanged,
|
||||||
@ -115,7 +184,6 @@ export const {
|
|||||||
workflowVersionChanged,
|
workflowVersionChanged,
|
||||||
workflowContactChanged,
|
workflowContactChanged,
|
||||||
workflowIDChanged,
|
workflowIDChanged,
|
||||||
workflowReset,
|
|
||||||
workflowSaved,
|
workflowSaved,
|
||||||
} = workflowSlice.actions;
|
} = workflowSlice.actions;
|
||||||
|
|
||||||
|
@ -1,13 +1,28 @@
|
|||||||
|
import { Box, Flex } from '@invoke-ai/ui-library';
|
||||||
|
import { useAppSelector } from 'app/store/storeHooks';
|
||||||
|
import CurrentImageDisplay from 'features/gallery/components/CurrentImage/CurrentImageDisplay';
|
||||||
import NodeEditor from 'features/nodes/components/NodeEditor';
|
import NodeEditor from 'features/nodes/components/NodeEditor';
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { ReactFlowProvider } from 'reactflow';
|
import { ReactFlowProvider } from 'reactflow';
|
||||||
|
|
||||||
const NodesTab = () => {
|
const NodesTab = () => {
|
||||||
return (
|
const mode = useAppSelector((s) => s.workflow.mode);
|
||||||
<ReactFlowProvider>
|
|
||||||
<NodeEditor />
|
if (mode === 'edit') {
|
||||||
</ReactFlowProvider>
|
return (
|
||||||
);
|
<ReactFlowProvider>
|
||||||
|
<NodeEditor />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Box layerStyle="first" position="relative" w="full" h="full" p={2} borderRadius="base">
|
||||||
|
<Flex w="full" h="full">
|
||||||
|
<CurrentImageDisplay />
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(NodesTab);
|
export default memo(NodesTab);
|
||||||
|
@ -0,0 +1,26 @@
|
|||||||
|
import { IconButton } from '@invoke-ai/ui-library';
|
||||||
|
import { NewWorkflowConfirmationAlertDialog } from 'features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog';
|
||||||
|
import { memo, useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { PiFilePlusBold } from 'react-icons/pi';
|
||||||
|
|
||||||
|
export const NewWorkflowButton = memo(() => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const renderButton = useCallback(
|
||||||
|
(onClick: () => void) => (
|
||||||
|
<IconButton
|
||||||
|
aria-label={t('nodes.newWorkflow')}
|
||||||
|
tooltip={t('nodes.newWorkflow')}
|
||||||
|
icon={<PiFilePlusBold />}
|
||||||
|
onClick={onClick}
|
||||||
|
pointerEvents="auto"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <NewWorkflowConfirmationAlertDialog renderButton={renderButton} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
NewWorkflowButton.displayName = 'NewWorkflowButton';
|
@ -0,0 +1,63 @@
|
|||||||
|
import { ConfirmationAlertDialog, Flex, Text, useDisclosure } from '@invoke-ai/ui-library';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||||
|
import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
|
||||||
|
import { workflowModeChanged } from 'features/nodes/store/workflowSlice';
|
||||||
|
import { addToast } from 'features/system/store/systemSlice';
|
||||||
|
import { makeToast } from 'features/system/util/makeToast';
|
||||||
|
import { memo, useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
renderButton: (onClick: () => void) => JSX.Element;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NewWorkflowConfirmationAlertDialog = memo((props: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||||
|
const isTouched = useAppSelector((s) => s.workflow.isTouched);
|
||||||
|
|
||||||
|
const handleNewWorkflow = useCallback(() => {
|
||||||
|
dispatch(nodeEditorReset());
|
||||||
|
dispatch(workflowModeChanged('edit'));
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
addToast(
|
||||||
|
makeToast({
|
||||||
|
title: t('workflows.newWorkflowCreated'),
|
||||||
|
status: 'success',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
}, [dispatch, onClose, t]);
|
||||||
|
|
||||||
|
const onClick = useCallback(() => {
|
||||||
|
if (!isTouched) {
|
||||||
|
handleNewWorkflow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onOpen();
|
||||||
|
}, [handleNewWorkflow, isTouched, onOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{props.renderButton(onClick)}
|
||||||
|
|
||||||
|
<ConfirmationAlertDialog
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t('nodes.newWorkflow')}
|
||||||
|
acceptCallback={handleNewWorkflow}
|
||||||
|
>
|
||||||
|
<Flex flexDir="column" gap={2}>
|
||||||
|
<Text>{t('nodes.newWorkflowDesc')}</Text>
|
||||||
|
<Text variant="subtext">{t('nodes.newWorkflowDesc2')}</Text>
|
||||||
|
</Flex>
|
||||||
|
</ConfirmationAlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
NewWorkflowConfirmationAlertDialog.displayName = 'NewWorkflowConfirmationAlertDialog';
|
@ -2,7 +2,7 @@ import { IconButton, useDisclosure } from '@invoke-ai/ui-library';
|
|||||||
import { WorkflowLibraryModalContext } from 'features/workflowLibrary/context/WorkflowLibraryModalContext';
|
import { WorkflowLibraryModalContext } from 'features/workflowLibrary/context/WorkflowLibraryModalContext';
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { PiBooksBold } from 'react-icons/pi';
|
import { PiFolderOpenBold } from 'react-icons/pi';
|
||||||
|
|
||||||
import WorkflowLibraryModal from './WorkflowLibraryModal';
|
import WorkflowLibraryModal from './WorkflowLibraryModal';
|
||||||
|
|
||||||
@ -15,7 +15,7 @@ const WorkflowLibraryButton = () => {
|
|||||||
<IconButton
|
<IconButton
|
||||||
aria-label={t('workflows.workflowLibrary')}
|
aria-label={t('workflows.workflowLibrary')}
|
||||||
tooltip={t('workflows.workflowLibrary')}
|
tooltip={t('workflows.workflowLibrary')}
|
||||||
icon={<PiBooksBold />}
|
icon={<PiFolderOpenBold />}
|
||||||
onClick={disclosure.onOpen}
|
onClick={disclosure.onOpen}
|
||||||
pointerEvents="auto"
|
pointerEvents="auto"
|
||||||
/>
|
/>
|
||||||
|
@ -1,60 +1,22 @@
|
|||||||
import { ConfirmationAlertDialog, Flex, MenuItem, Text, useDisclosure } from '@invoke-ai/ui-library';
|
import { MenuItem } from '@invoke-ai/ui-library';
|
||||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
import { NewWorkflowConfirmationAlertDialog } from 'features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog';
|
||||||
import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
|
|
||||||
import { addToast } from 'features/system/store/systemSlice';
|
|
||||||
import { makeToast } from 'features/system/util/makeToast';
|
|
||||||
import { memo, useCallback } from 'react';
|
import { memo, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { PiFlowArrowBold } from 'react-icons/pi';
|
import { PiFilePlusBold } from 'react-icons/pi';
|
||||||
|
|
||||||
const NewWorkflowMenuItem = () => {
|
export const NewWorkflowMenuItem = memo(() => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
|
||||||
const isTouched = useAppSelector((s) => s.workflow.isTouched);
|
|
||||||
|
|
||||||
const handleNewWorkflow = useCallback(() => {
|
const renderButton = useCallback(
|
||||||
dispatch(nodeEditorReset());
|
(onClick: () => void) => (
|
||||||
|
<MenuItem as="button" icon={<PiFilePlusBold />} onClick={onClick}>
|
||||||
dispatch(
|
|
||||||
addToast(
|
|
||||||
makeToast({
|
|
||||||
title: t('workflows.newWorkflowCreated'),
|
|
||||||
status: 'success',
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
onClose();
|
|
||||||
}, [dispatch, onClose, t]);
|
|
||||||
|
|
||||||
const onClick = useCallback(() => {
|
|
||||||
if (!isTouched) {
|
|
||||||
handleNewWorkflow();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onOpen();
|
|
||||||
}, [handleNewWorkflow, isTouched, onOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<MenuItem as="button" icon={<PiFlowArrowBold />} onClick={onClick}>
|
|
||||||
{t('nodes.newWorkflow')}
|
{t('nodes.newWorkflow')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
),
|
||||||
<ConfirmationAlertDialog
|
[t]
|
||||||
isOpen={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={t('nodes.newWorkflow')}
|
|
||||||
acceptCallback={handleNewWorkflow}
|
|
||||||
>
|
|
||||||
<Flex flexDir="column" gap={2}>
|
|
||||||
<Text>{t('nodes.newWorkflowDesc')}</Text>
|
|
||||||
<Text variant="subtext">{t('nodes.newWorkflowDesc2')}</Text>
|
|
||||||
</Flex>
|
|
||||||
</ConfirmationAlertDialog>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
export default memo(NewWorkflowMenuItem);
|
return <NewWorkflowConfirmationAlertDialog renderButton={renderButton} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
NewWorkflowMenuItem.displayName = 'NewWorkflowMenuItem';
|
||||||
|
@ -8,7 +8,7 @@ import {
|
|||||||
useGlobalMenuClose,
|
useGlobalMenuClose,
|
||||||
} from '@invoke-ai/ui-library';
|
} from '@invoke-ai/ui-library';
|
||||||
import DownloadWorkflowMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/DownloadWorkflowMenuItem';
|
import DownloadWorkflowMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/DownloadWorkflowMenuItem';
|
||||||
import NewWorkflowMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/NewWorkflowMenuItem';
|
import { NewWorkflowMenuItem } from 'features/workflowLibrary/components/WorkflowLibraryMenu/NewWorkflowMenuItem';
|
||||||
import SaveWorkflowAsMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowAsMenuItem';
|
import SaveWorkflowAsMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowAsMenuItem';
|
||||||
import SaveWorkflowMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowMenuItem';
|
import SaveWorkflowMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowMenuItem';
|
||||||
import SettingsMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/SettingsMenuItem';
|
import SettingsMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/SettingsMenuItem';
|
||||||
|
Loading…
Reference in New Issue
Block a user