Compare commits

...

5 Commits

Author SHA1 Message Date
2c979d1b68 wip 2023-11-17 18:06:26 +11:00
7b93b5e928 Merge branch 'main' into feat/arbitrary-field-types 2023-11-17 15:00:24 +11:00
dc44debbab fix(ui): fix ts error with custom fields 2023-11-17 12:09:15 +11:00
5ce2dc3a58 feat(ui): fix tooltips for custom types
We need to hold onto the original type of the field so they don't all just show up as "Unknown".
2023-11-17 12:01:39 +11:00
27fd9071ba feat(ui): add support for custom field types
Node authors may now create their own arbitrary/custom field types. Any pydantic model is supported.

Two notes:
1. Your field type's class name must be unique.

Suggest prefixing fields with something related to the node pack as a kind of namespace.

2. Custom field types function as connection-only fields.

For example, if your custom field has string attributes, you will not get a text input for that attribute when you give a node a field with your custom type.

This is the same behaviour as other complex fields that don't have custom UIs in the workflow editor - like, say, a string collection.
2023-11-17 11:32:35 +11:00
19 changed files with 422 additions and 123 deletions

View File

@ -49,6 +49,7 @@
"back": "Back", "back": "Back",
"batch": "Batch Manager", "batch": "Batch Manager",
"cancel": "Cancel", "cancel": "Cancel",
"clickToEdit": "Click to Edit",
"close": "Close", "close": "Close",
"on": "On", "on": "On",
"communityLabel": "Community", "communityLabel": "Community",
@ -853,7 +854,7 @@
"noConnectionData": "No connection data", "noConnectionData": "No connection data",
"noConnectionInProgress": "No connection in progress", "noConnectionInProgress": "No connection in progress",
"node": "Node", "node": "Node",
"nodeOutputs": "Node Outputs", "nodeOutputs": "Node Results",
"nodeSearch": "Search for nodes", "nodeSearch": "Search for nodes",
"nodeTemplate": "Node Template", "nodeTemplate": "Node Template",
"nodeType": "Node Type", "nodeType": "Node Type",
@ -863,9 +864,9 @@
"noMatchingNodes": "No matching nodes", "noMatchingNodes": "No matching nodes",
"noNodeSelected": "No node selected", "noNodeSelected": "No node selected",
"nodeOpacity": "Node Opacity", "nodeOpacity": "Node Opacity",
"noOutputRecorded": "No outputs recorded", "noOutputRecorded": "No results recorded",
"noOutputSchemaName": "No output schema name found in ref object", "noOutputSchemaName": "No output schema name found in ref object",
"notes": "Notes", "notes": "Node Notes",
"notesDescription": "Add notes about your workflow", "notesDescription": "Add notes about your workflow",
"oNNXModelField": "ONNX Model", "oNNXModelField": "ONNX Model",
"oNNXModelFieldDescription": "ONNX model field.", "oNNXModelFieldDescription": "ONNX model field.",
@ -943,7 +944,12 @@
"workflowValidation": "Workflow Validation Error", "workflowValidation": "Workflow Validation Error",
"workflowVersion": "Version", "workflowVersion": "Version",
"zoomInNodes": "Zoom In", "zoomInNodes": "Zoom In",
"zoomOutNodes": "Zoom Out" "zoomOutNodes": "Zoom Out",
"tabDetails": "Details",
"tabNotes": "Notes",
"tabResults": "Results",
"tabData": "Data",
"tabTemplate": "Template"
}, },
"parameters": { "parameters": {
"aspectRatio": "Aspect Ratio", "aspectRatio": "Aspect Ratio",

View File

@ -1,15 +1,15 @@
import { FormControl, FormLabel } from '@chakra-ui/react'; import { FormControl, FormLabel, Flex } from '@chakra-ui/react';
import { useAppDispatch } from 'app/store/storeHooks'; import { useAppDispatch } from 'app/store/storeHooks';
import IAITextarea from 'common/components/IAITextarea'; import IAITextarea from 'common/components/IAITextarea';
import { useNodeData } from 'features/nodes/hooks/useNodeData'; import { useNodeNotes } from 'features/nodes/hooks/useNodeNotes';
import { nodeNotesChanged } from 'features/nodes/store/nodesSlice'; import { nodeNotesChanged } from 'features/nodes/store/nodesSlice';
import { isInvocationNodeData } from 'features/nodes/types/types'; import { isNil } from 'lodash-es';
import { ChangeEvent, memo, useCallback } from 'react'; import { ChangeEvent, memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const NotesTextarea = ({ nodeId }: { nodeId: string }) => { const NotesTextarea = ({ nodeId }: { nodeId: string }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const data = useNodeData(nodeId); const notes = useNodeNotes(nodeId);
const { t } = useTranslation(); const { t } = useTranslation();
const handleNotesChanged = useCallback( const handleNotesChanged = useCallback(
(e: ChangeEvent<HTMLTextAreaElement>) => { (e: ChangeEvent<HTMLTextAreaElement>) => {
@ -17,16 +17,17 @@ const NotesTextarea = ({ nodeId }: { nodeId: string }) => {
}, },
[dispatch, nodeId] [dispatch, nodeId]
); );
if (!isInvocationNodeData(data)) { if (isNil(notes)) {
return null; return null;
} }
return ( return (
<FormControl> <FormControl as={Flex} sx={{ flexDir: 'column', h: 'full' }}>
<FormLabel>{t('nodes.notes')}</FormLabel> <FormLabel>{t('nodes.notes')}</FormLabel>
<IAITextarea <IAITextarea
value={data?.notes} value={notes}
onChange={handleNotesChanged} onChange={handleNotesChanged}
rows={10} resize="none"
h="full"
/> />
</FormControl> </FormControl>
); );

View File

@ -47,8 +47,8 @@ const FieldHandle = (props: FieldHandleProps) => {
isConnectionStartField, isConnectionStartField,
connectionError, connectionError,
} = props; } = props;
const { name, type } = fieldTemplate; const { name, type, originalType } = fieldTemplate;
const { color: typeColor, title } = FIELDS[type]; const { color: typeColor } = FIELDS[type];
const styles: CSSProperties = useMemo(() => { const styles: CSSProperties = useMemo(() => {
const isCollectionType = COLLECTION_TYPES.includes(type); const isCollectionType = COLLECTION_TYPES.includes(type);
@ -102,13 +102,18 @@ const FieldHandle = (props: FieldHandleProps) => {
const tooltip = useMemo(() => { const tooltip = useMemo(() => {
if (isConnectionInProgress && isConnectionStartField) { if (isConnectionInProgress && isConnectionStartField) {
return title; return originalType;
} }
if (isConnectionInProgress && connectionError) { if (isConnectionInProgress && connectionError) {
return connectionError ?? title; return connectionError ?? originalType;
} }
return title; return originalType;
}, [connectionError, isConnectionInProgress, isConnectionStartField, title]); }, [
connectionError,
isConnectionInProgress,
isConnectionStartField,
originalType,
]);
return ( return (
<Tooltip <Tooltip

View File

@ -1,7 +1,6 @@
import { Flex, Text } from '@chakra-ui/react'; import { Flex, Text } from '@chakra-ui/react';
import { useFieldData } from 'features/nodes/hooks/useFieldData'; import { useFieldData } from 'features/nodes/hooks/useFieldData';
import { useFieldTemplate } from 'features/nodes/hooks/useFieldTemplate'; import { useFieldTemplate } from 'features/nodes/hooks/useFieldTemplate';
import { FIELDS } from 'features/nodes/types/constants';
import { import {
isInputFieldTemplate, isInputFieldTemplate,
isInputFieldValue, isInputFieldValue,
@ -9,7 +8,6 @@ import {
import { startCase } from 'lodash-es'; import { startCase } from 'lodash-es';
import { memo, useMemo } from 'react'; import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
interface Props { interface Props {
nodeId: string; nodeId: string;
fieldName: string; fieldName: string;
@ -49,7 +47,7 @@ const FieldTooltipContent = ({ nodeId, fieldName, kind }: Props) => {
{fieldTemplate.description} {fieldTemplate.description}
</Text> </Text>
)} )}
{fieldTemplate && <Text>Type: {FIELDS[fieldTemplate.type].title}</Text>} {fieldTemplate && <Text>Type: {fieldTemplate.originalType}</Text>}
{isInputTemplate && <Text>Input: {startCase(fieldTemplate.input)}</Text>} {isInputTemplate && <Text>Input: {startCase(fieldTemplate.input)}</Text>}
</Flex> </Flex>
); );

View File

@ -28,6 +28,10 @@ const NodeTitle = ({ nodeId, title }: Props) => {
const [localTitle, setLocalTitle] = useState(''); const [localTitle, setLocalTitle] = useState('');
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (newTitle: string) => { async (newTitle: string) => {
if (!newTitle.trim()) {
setLocalTitle(label || templateTitle || t('nodes.problemSettingTitle'));
return;
}
dispatch(nodeLabelChanged({ nodeId, label: newTitle })); dispatch(nodeLabelChanged({ nodeId, label: newTitle }));
setLocalTitle( setLocalTitle(
label || title || templateTitle || t('nodes.problemSettingTitle') label || title || templateTitle || t('nodes.problemSettingTitle')

View File

@ -22,9 +22,8 @@ import { memo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FaSync } from 'react-icons/fa'; import { FaSync } from 'react-icons/fa';
import { Node } from 'reactflow'; import { Node } from 'reactflow';
import NotesTextarea from '../../flow/nodes/Invocation/NotesTextarea';
import ScrollableContent from '../ScrollableContent'; import ScrollableContent from '../ScrollableContent';
import EditableNodeTitle from './details/EditableNodeTitle'; import InputFields from './details/InputFields';
const selector = createSelector( const selector = createSelector(
stateSelector, stateSelector,
@ -82,42 +81,23 @@ const Content = (props: {
sx={{ sx={{
flexDir: 'column', flexDir: 'column',
position: 'relative', position: 'relative',
p: 1,
gap: 2, gap: 2,
w: 'full', w: 'full',
}} }}
> >
<EditableNodeTitle nodeId={props.node.data.id} /> <FormControl>
<HStack> <FormLabel>Type</FormLabel>
<FormControl> <Text fontSize="sm" fontWeight={600}>
<FormLabel>Node Type</FormLabel> {props.template.title} ({props.template.type})
<Text fontSize="sm" fontWeight={600}> </Text>
{props.template.title} </FormControl>
</Text> <FormControl>
</FormControl> <FormLabel>Description</FormLabel>
<Flex <Text fontSize="sm" fontWeight={600}>
flexDir="row" {props.template.description}
alignItems="center" </Text>
justifyContent="space-between" </FormControl>
w="full" <InputFields nodeId={props.node.id} />
>
<FormControl isInvalid={needsUpdate}>
<FormLabel>Node Version</FormLabel>
<Text fontSize="sm" fontWeight={600}>
{props.node.data.version}
</Text>
</FormControl>
{needsUpdate && (
<IAIIconButton
aria-label={t('nodes.updateNode')}
tooltip={t('nodes.updateNode')}
icon={<FaSync />}
onClick={updateNode}
/>
)}
</Flex>
</HStack>
<NotesTextarea nodeId={props.node.data.id} />
</Flex> </Flex>
</ScrollableContent> </ScrollableContent>
</Box> </Box>

View File

@ -0,0 +1,43 @@
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { isInvocationNode } from 'features/nodes/types/types';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import NotesTextarea from '../../flow/nodes/Invocation/NotesTextarea';
const selector = createSelector(
stateSelector,
({ nodes }) => {
const lastSelectedNodeId =
nodes.selectedNodes[nodes.selectedNodes.length - 1];
const lastSelectedNode = nodes.nodes.find(
(node) => node.id === lastSelectedNodeId
);
if (!isInvocationNode(lastSelectedNode)) {
return;
}
return lastSelectedNode.id;
},
defaultSelectorOptions
);
const InspectorNotesTab = () => {
const nodeId = useAppSelector(selector);
const { t } = useTranslation();
if (!nodeId) {
return (
<IAINoContentFallback label={t('nodes.noNodeSelected')} icon={null} />
);
}
return <NotesTextarea nodeId={nodeId} />;
};
export default memo(InspectorNotesTab);

View File

@ -6,13 +6,41 @@ import {
TabPanels, TabPanels,
Tabs, Tabs,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { isInvocationNode } from 'features/nodes/types/types';
import { memo } from 'react'; import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import InspectorDataTab from './InspectorDataTab'; import InspectorDataTab from './InspectorDataTab';
import InspectorOutputsTab from './InspectorOutputsTab';
import InspectorTemplateTab from './InspectorTemplateTab';
import InspectorDetailsTab from './InspectorDetailsTab'; import InspectorDetailsTab from './InspectorDetailsTab';
import InspectorNotesTab from './InspectorNotesTab';
import InspectorResultsTab from './InspectorResultsTab';
import InspectorTemplateTab from './InspectorTemplateTab';
import EditableNodeTitle from './details/EditableNodeTitle';
const selector = createSelector(
stateSelector,
({ nodes }) => {
const lastSelectedNodeId =
nodes.selectedNodes[nodes.selectedNodes.length - 1];
const lastSelectedNode = nodes.nodes.find(
(node) => node.id === lastSelectedNodeId
);
if (!isInvocationNode(lastSelectedNode)) {
return;
}
return lastSelectedNode.id;
},
defaultSelectorOptions
);
const InspectorPanel = () => { const InspectorPanel = () => {
const { t } = useTranslation();
const nodeId = useAppSelector(selector);
return ( return (
<Flex <Flex
layerStyle="first" layerStyle="first"
@ -25,15 +53,17 @@ const InspectorPanel = () => {
gap: 2, gap: 2,
}} }}
> >
<EditableNodeTitle nodeId={nodeId} />
<Tabs <Tabs
variant="line" variant="line"
sx={{ display: 'flex', flexDir: 'column', w: 'full', h: 'full' }} sx={{ display: 'flex', flexDir: 'column', w: 'full', h: 'full' }}
> >
<TabList> <TabList>
<Tab>Details</Tab> <Tab>{t('nodes.tabDetails')}</Tab>
<Tab>Outputs</Tab> <Tab>{t('nodes.tabNotes')}</Tab>
<Tab>Data</Tab> <Tab>{t('nodes.tabResults')}</Tab>
<Tab>Template</Tab> <Tab>{t('nodes.tabData')}</Tab>
<Tab>{t('nodes.tabTemplate')}</Tab>
</TabList> </TabList>
<TabPanels> <TabPanels>
@ -41,7 +71,10 @@ const InspectorPanel = () => {
<InspectorDetailsTab /> <InspectorDetailsTab />
</TabPanel> </TabPanel>
<TabPanel> <TabPanel>
<InspectorOutputsTab /> <InspectorNotesTab />
</TabPanel>
<TabPanel>
<InspectorResultsTab />
</TabPanel> </TabPanel>
<TabPanel> <TabPanel>
<InspectorDataTab /> <InspectorDataTab />

View File

@ -39,7 +39,7 @@ const selector = createSelector(
defaultSelectorOptions defaultSelectorOptions
); );
const InspectorOutputsTab = () => { const InspectorResultsTab = () => {
const { node, template, nes } = useAppSelector(selector); const { node, template, nes } = useAppSelector(selector);
const { t } = useTranslation(); const { t } = useTranslation();
@ -91,6 +91,6 @@ const InspectorOutputsTab = () => {
); );
}; };
export default memo(InspectorOutputsTab); export default memo(InspectorResultsTab);
const getKey = (result: AnyResult, i: number) => `${result.type}-${i}`; const getKey = (result: AnyResult, i: number) => `${result.type}-${i}`;

View File

@ -3,20 +3,89 @@ import {
EditableInput, EditableInput,
EditablePreview, EditablePreview,
Flex, Flex,
Text,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useAppDispatch } from 'app/store/storeHooks'; import { useAppDispatch } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton';
import { useNodeLabel } from 'features/nodes/hooks/useNodeLabel'; import { useNodeLabel } from 'features/nodes/hooks/useNodeLabel';
import { useNodeTemplateTitle } from 'features/nodes/hooks/useNodeTemplateTitle'; import { useNodeTemplateTitle } from 'features/nodes/hooks/useNodeTemplateTitle';
import { useNodeVersion } from 'features/nodes/hooks/useNodeVersion';
import { nodeLabelChanged } from 'features/nodes/store/nodesSlice'; import { nodeLabelChanged } from 'features/nodes/store/nodesSlice';
import { memo, useCallback, useEffect, useState } from 'react'; import { memo, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FaSync } from 'react-icons/fa';
type Props = { type EditableNodeTitleProps = {
nodeId: string; nodeId?: string;
title?: string;
}; };
const EditableNodeTitle = ({ nodeId, title }: Props) => { const EditableNodeTitle = (props: EditableNodeTitleProps) => {
if (!props.nodeId) {
return (
<Text
sx={{
fontWeight: 600,
px: 1,
color: 'base.700',
_dark: { color: 'base.200' },
}}
>
No node selected
</Text>
);
}
return (
<Flex
sx={{
justifyContent: 'space-between',
alignItems: 'center',
px: 1,
color: 'base.700',
_dark: { color: 'base.200' },
}}
>
<EditableTitle nodeId={props.nodeId} />
<Version nodeId={props.nodeId} />
</Flex>
);
};
type VersionProps = {
nodeId: string;
};
const Version = memo(({ nodeId }: VersionProps) => {
const { version, needsUpdate, updateNode } = useNodeVersion(nodeId);
const { t } = useTranslation();
return (
<Flex alignItems="center" gap={1}>
<Text variant={needsUpdate ? 'error' : 'subtext'} fontWeight={600}>
v{version}
</Text>
{needsUpdate && (
<IAIIconButton
size="sm"
aria-label={t('nodes.updateNode')}
tooltip={t('nodes.updateNode')}
icon={<FaSync />}
variant="link"
onClick={updateNode}
/>
)}
</Flex>
);
});
Version.displayName = 'Version';
type EditableTitleProps = {
nodeId: string;
};
const EditableTitle = memo(({ nodeId }: EditableTitleProps) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const label = useNodeLabel(nodeId); const label = useNodeLabel(nodeId);
const templateTitle = useNodeTemplateTitle(nodeId); const templateTitle = useNodeTemplateTitle(nodeId);
@ -25,12 +94,14 @@ const EditableNodeTitle = ({ nodeId, title }: Props) => {
const [localTitle, setLocalTitle] = useState(''); const [localTitle, setLocalTitle] = useState('');
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (newTitle: string) => { async (newTitle: string) => {
if (!newTitle.trim()) {
setLocalTitle(label || templateTitle || t('nodes.problemSettingTitle'));
return;
}
dispatch(nodeLabelChanged({ nodeId, label: newTitle })); dispatch(nodeLabelChanged({ nodeId, label: newTitle }));
setLocalTitle( setLocalTitle(label || templateTitle || t('nodes.problemSettingTitle'));
label || title || templateTitle || t('nodes.problemSettingTitle')
);
}, },
[dispatch, nodeId, title, templateTitle, label, t] [dispatch, nodeId, templateTitle, label, t]
); );
const handleChange = useCallback((newTitle: string) => { const handleChange = useCallback((newTitle: string) => {
@ -39,36 +110,28 @@ const EditableNodeTitle = ({ nodeId, title }: Props) => {
useEffect(() => { useEffect(() => {
// Another component may change the title; sync local title with global state // Another component may change the title; sync local title with global state
setLocalTitle( setLocalTitle(label || templateTitle || t('nodes.problemSettingTitle'));
label || title || templateTitle || t('nodes.problemSettingTitle') }, [label, templateTitle, t]);
);
}, [label, templateTitle, title, t]);
return ( return (
<Flex <Editable
sx={{ as={Flex}
w: 'full', value={localTitle}
h: 'full', onChange={handleChange}
alignItems: 'center', onSubmit={handleSubmit}
justifyContent: 'center', w="full"
}}
> >
<Editable <EditablePreview p={0} fontWeight={600} noOfLines={1} />
as={Flex} <EditableInput
value={localTitle} p={0}
onChange={handleChange} className="nodrag"
onSubmit={handleSubmit} fontWeight={700}
w="full" _focusVisible={{ boxShadow: 'none' }}
fontWeight={600} />
> </Editable>
<EditablePreview noOfLines={1} />
<EditableInput
className="nodrag"
_focusVisible={{ boxShadow: 'none' }}
/>
</Editable>
</Flex>
); );
}; });
EditableTitle.displayName = 'EditableTitle';
export default memo(EditableNodeTitle); export default memo(EditableNodeTitle);

View File

@ -0,0 +1,20 @@
import { FormControl, FormLabel, Text } from '@chakra-ui/react';
import { useNodeInputFields } from 'features/nodes/hooks/useNodeInputFields';
import { memo } from 'react';
type Props = { nodeId: string };
const InputFields = ({ nodeId }: Props) => {
const inputs = useNodeInputFields(nodeId);
return (
<div>
{inputs?.map(({ fieldData, fieldTemplate }) => (
<FormControl key={fieldData.id}>
<FormLabel>{fieldData.label || fieldTemplate.title}</FormLabel>
<Text>{fieldData.type}</Text>
</FormControl>
))}
</div>
);
};
export default memo(InputFields);

View File

@ -0,0 +1,56 @@
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { useMemo } from 'react';
import {
InputFieldTemplate,
InputFieldValue,
isInvocationNode,
} from '../types/types';
export const useNodeInputFields = (
nodeId: string
): { fieldData: InputFieldValue; fieldTemplate: InputFieldTemplate }[] => {
const selector = useMemo(
() =>
createSelector(
stateSelector,
({ nodes }) => {
const node = nodes.nodes.find((node) => node.id === nodeId);
if (!isInvocationNode(node)) {
return [];
}
const template = nodes.nodeTemplates[node.data.type];
if (!template) {
return [];
}
const inputs = Object.values(node.data.inputs).reduce<
{
fieldData: InputFieldValue;
fieldTemplate: InputFieldTemplate;
}[]
>((acc, fieldData) => {
const fieldTemplate = template.inputs[fieldData.name];
if (fieldTemplate) {
acc.push({
fieldData,
fieldTemplate,
});
}
return acc;
}, []);
return inputs;
},
defaultSelectorOptions
),
[nodeId]
);
const inputs = useAppSelector(selector);
return inputs;
};

View File

@ -0,0 +1,28 @@
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { useMemo } from 'react';
import { isInvocationNode } from '../types/types';
export const useNodeNotes = (nodeId: string) => {
const selector = useMemo(
() =>
createSelector(
stateSelector,
({ nodes }) => {
const node = nodes.nodes.find((node) => node.id === nodeId);
if (!isInvocationNode(node)) {
return;
}
return node.data.notes;
},
defaultSelectorOptions
),
[nodeId]
);
const nodeNotes = useAppSelector(selector);
return nodeNotes;
};

View File

@ -1,10 +1,12 @@
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { useAppToaster } from 'app/components/Toaster';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { satisfies } from 'compare-versions'; import { satisfies } from 'compare-versions';
import { cloneDeep, defaultsDeep } from 'lodash-es'; import { cloneDeep, defaultsDeep } from 'lodash-es';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Node } from 'reactflow'; import { Node } from 'reactflow';
import { AnyInvocationType } from 'services/events/types'; import { AnyInvocationType } from 'services/events/types';
import { nodeReplaced } from '../store/nodesSlice'; import { nodeReplaced } from '../store/nodesSlice';
@ -16,8 +18,6 @@ import {
isInvocationNode, isInvocationNode,
zParsedSemver, zParsedSemver,
} from '../types/types'; } from '../types/types';
import { useAppToaster } from 'app/components/Toaster';
import { useTranslation } from 'react-i18next';
export const getNeedsUpdate = ( export const getNeedsUpdate = (
node?: Node<NodeData>, node?: Node<NodeData>,
@ -115,5 +115,17 @@ export const useNodeVersion = (nodeId: string) => {
dispatch(nodeReplaced({ nodeId: updatedNode.id, node: updatedNode })); dispatch(nodeReplaced({ nodeId: updatedNode.id, node: updatedNode }));
}, [dispatch, node, nodeTemplate, t, toast]); }, [dispatch, node, nodeTemplate, t, toast]);
return { needsUpdate, mayUpdate, updateNode: _updateNode }; const version = useMemo(() => {
if (!isInvocationNode(node)) {
return '';
}
return node.data.version;
}, [node]);
return {
needsUpdate,
mayUpdate,
updateNode: _updateNode,
version,
};
}; };

View File

@ -156,6 +156,11 @@ export const FIELDS: Record<FieldType, FieldUIConfig> = {
description: 'Any field type is accepted.', description: 'Any field type is accepted.',
title: 'Any', title: 'Any',
}, },
Unknown: {
color: 'gray.500',
description: 'Unknown field type is accepted.',
title: 'Unknown',
},
MetadataField: { MetadataField: {
color: 'gray.500', color: 'gray.500',
description: 'A metadata dict.', description: 'A metadata dict.',

View File

@ -133,6 +133,7 @@ export const zFieldType = z.enum([
'UNetField', 'UNetField',
'VaeField', 'VaeField',
'VaeModelField', 'VaeModelField',
'Unknown',
]); ]);
export type FieldType = z.infer<typeof zFieldType>; export type FieldType = z.infer<typeof zFieldType>;
@ -190,6 +191,7 @@ export type OutputFieldTemplate = {
type: FieldType; type: FieldType;
title: string; title: string;
description: string; description: string;
originalType: string; // used for custom types
} & _OutputField; } & _OutputField;
export const zInputFieldValueBase = zFieldValueBase.extend({ export const zInputFieldValueBase = zFieldValueBase.extend({
@ -789,6 +791,11 @@ export const zAnyInputFieldValue = zInputFieldValueBase.extend({
value: z.any().optional(), value: z.any().optional(),
}); });
export const zUnknownInputFieldValue = zInputFieldValueBase.extend({
type: z.literal('Unknown'),
value: z.any().optional(),
});
export const zInputFieldValue = z.discriminatedUnion('type', [ export const zInputFieldValue = z.discriminatedUnion('type', [
zAnyInputFieldValue, zAnyInputFieldValue,
zBoardInputFieldValue, zBoardInputFieldValue,
@ -846,6 +853,7 @@ export const zInputFieldValue = z.discriminatedUnion('type', [
zMetadataItemPolymorphicInputFieldValue, zMetadataItemPolymorphicInputFieldValue,
zMetadataInputFieldValue, zMetadataInputFieldValue,
zMetadataCollectionInputFieldValue, zMetadataCollectionInputFieldValue,
zUnknownInputFieldValue,
]); ]);
export type InputFieldValue = z.infer<typeof zInputFieldValue>; export type InputFieldValue = z.infer<typeof zInputFieldValue>;
@ -856,6 +864,7 @@ export type InputFieldTemplateBase = {
description: string; description: string;
required: boolean; required: boolean;
fieldKind: 'input'; fieldKind: 'input';
originalType: string; // used for custom types
} & _InputField; } & _InputField;
export type AnyInputFieldTemplate = InputFieldTemplateBase & { export type AnyInputFieldTemplate = InputFieldTemplateBase & {
@ -863,6 +872,11 @@ export type AnyInputFieldTemplate = InputFieldTemplateBase & {
default: undefined; default: undefined;
}; };
export type UnknownInputFieldTemplate = InputFieldTemplateBase & {
type: 'Unknown';
default: undefined;
};
export type IntegerInputFieldTemplate = InputFieldTemplateBase & { export type IntegerInputFieldTemplate = InputFieldTemplateBase & {
type: 'integer'; type: 'integer';
default: number; default: number;
@ -1259,7 +1273,8 @@ export type InputFieldTemplate =
| MetadataItemCollectionInputFieldTemplate | MetadataItemCollectionInputFieldTemplate
| MetadataInputFieldTemplate | MetadataInputFieldTemplate
| MetadataItemPolymorphicInputFieldTemplate | MetadataItemPolymorphicInputFieldTemplate
| MetadataCollectionInputFieldTemplate; | MetadataCollectionInputFieldTemplate
| UnknownInputFieldTemplate;
export const isInputFieldValue = ( export const isInputFieldValue = (
field?: InputFieldValue | OutputFieldValue field?: InputFieldValue | OutputFieldValue

View File

@ -81,6 +81,7 @@ import {
T2IAdapterModelInputFieldTemplate, T2IAdapterModelInputFieldTemplate,
T2IAdapterPolymorphicInputFieldTemplate, T2IAdapterPolymorphicInputFieldTemplate,
UNetInputFieldTemplate, UNetInputFieldTemplate,
UnknownInputFieldTemplate,
VaeInputFieldTemplate, VaeInputFieldTemplate,
VaeModelInputFieldTemplate, VaeModelInputFieldTemplate,
isArraySchemaObject, isArraySchemaObject,
@ -981,6 +982,18 @@ const buildSchedulerInputFieldTemplate = ({
return template; return template;
}; };
const buildUnknownInputFieldTemplate = ({
baseField,
}: BuildInputFieldArg): UnknownInputFieldTemplate => {
const template: UnknownInputFieldTemplate = {
...baseField,
type: 'Unknown',
default: undefined,
};
return template;
};
export const getFieldType = ( export const getFieldType = (
schemaObject: OpenAPIV3_1SchemaOrRef schemaObject: OpenAPIV3_1SchemaOrRef
): string | undefined => { ): string | undefined => {
@ -1145,13 +1158,9 @@ const TEMPLATE_BUILDER_MAP: {
UNetField: buildUNetInputFieldTemplate, UNetField: buildUNetInputFieldTemplate,
VaeField: buildVaeInputFieldTemplate, VaeField: buildVaeInputFieldTemplate,
VaeModelField: buildVaeModelInputFieldTemplate, VaeModelField: buildVaeModelInputFieldTemplate,
Unknown: buildUnknownInputFieldTemplate,
}; };
const isTemplatedFieldType = (
fieldType: string | undefined
): fieldType is keyof typeof TEMPLATE_BUILDER_MAP =>
Boolean(fieldType && fieldType in TEMPLATE_BUILDER_MAP);
/** /**
* Builds an input field from an invocation schema property. * Builds an input field from an invocation schema property.
* @param fieldSchema The schema object * @param fieldSchema The schema object
@ -1161,7 +1170,8 @@ export const buildInputFieldTemplate = (
nodeSchema: InvocationSchemaObject, nodeSchema: InvocationSchemaObject,
fieldSchema: InvocationFieldSchema, fieldSchema: InvocationFieldSchema,
name: string, name: string,
fieldType: FieldType fieldType: FieldType,
originalType: string
) => { ) => {
const { const {
input, input,
@ -1183,6 +1193,7 @@ export const buildInputFieldTemplate = (
ui_order, ui_order,
ui_choice_labels, ui_choice_labels,
item_default, item_default,
originalType,
}; };
const baseField = { const baseField = {
@ -1193,10 +1204,6 @@ export const buildInputFieldTemplate = (
...extra, ...extra,
}; };
if (!isTemplatedFieldType(fieldType)) {
return;
}
const builder = TEMPLATE_BUILDER_MAP[fieldType]; const builder = TEMPLATE_BUILDER_MAP[fieldType];
if (!builder) { if (!builder) {

View File

@ -60,6 +60,7 @@ const FIELD_VALUE_FALLBACK_MAP: {
UNetField: undefined, UNetField: undefined,
VaeField: undefined, VaeField: undefined,
VaeModelField: undefined, VaeModelField: undefined,
Unknown: undefined,
}; };
export const buildInputFieldValue = ( export const buildInputFieldValue = (

View File

@ -4,6 +4,7 @@ import { reduce, startCase } from 'lodash-es';
import { OpenAPIV3_1 } from 'openapi-types'; import { OpenAPIV3_1 } from 'openapi-types';
import { AnyInvocationType } from 'services/events/types'; import { AnyInvocationType } from 'services/events/types';
import { import {
FieldType,
InputFieldTemplate, InputFieldTemplate,
InvocationSchemaObject, InvocationSchemaObject,
InvocationTemplate, InvocationTemplate,
@ -103,7 +104,7 @@ export const parseSchema = (
return inputsAccumulator; return inputsAccumulator;
} }
const fieldType = property.ui_type ?? getFieldType(property); let fieldType = property.ui_type ?? getFieldType(property);
if (!fieldType) { if (!fieldType) {
logger('nodes').warn( logger('nodes').warn(
@ -118,6 +119,9 @@ export const parseSchema = (
return inputsAccumulator; return inputsAccumulator;
} }
// stash this for custom types
const originalType = fieldType;
if (fieldType === 'WorkflowField') { if (fieldType === 'WorkflowField') {
withWorkflow = true; withWorkflow = true;
return inputsAccumulator; return inputsAccumulator;
@ -137,23 +141,24 @@ export const parseSchema = (
} }
if (!isFieldType(fieldType)) { if (!isFieldType(fieldType)) {
logger('nodes').warn( logger('nodes').debug(
{ {
node: type, node: type,
fieldName: propertyName, fieldName: propertyName,
fieldType, fieldType,
field: parseify(property), field: parseify(property),
}, },
`Skipping unknown input field type: ${fieldType}` `Fallback handling for unknown input field type: ${fieldType}`
); );
return inputsAccumulator; fieldType = 'Unknown';
} }
const field = buildInputFieldTemplate( const field = buildInputFieldTemplate(
schema, schema,
property, property,
propertyName, propertyName,
fieldType fieldType as FieldType, // we have already checked that fieldType is a valid FieldType, and forced it to be Unknown if not
originalType
); );
if (!field) { if (!field) {
@ -220,26 +225,43 @@ export const parseSchema = (
return outputsAccumulator; return outputsAccumulator;
} }
const fieldType = property.ui_type ?? getFieldType(property); let fieldType = property.ui_type ?? getFieldType(property);
if (!isFieldType(fieldType)) { if (!fieldType) {
logger('nodes').warn( logger('nodes').warn(
{ fieldName: propertyName, fieldType, field: parseify(property) }, {
'Skipping unknown output field type' node: type,
fieldName: propertyName,
fieldType,
field: parseify(property),
},
'Missing output field type'
); );
return outputsAccumulator; return outputsAccumulator;
} }
// stash for custom types
const originalType = fieldType;
if (!isFieldType(fieldType)) {
logger('nodes').debug(
{ fieldName: propertyName, fieldType, field: parseify(property) },
`Fallback handling for unknown input field type: ${fieldType}`
);
fieldType = 'Unknown';
}
outputsAccumulator[propertyName] = { outputsAccumulator[propertyName] = {
fieldKind: 'output', fieldKind: 'output',
name: propertyName, name: propertyName,
title: title:
property.title ?? (propertyName ? startCase(propertyName) : ''), property.title ?? (propertyName ? startCase(propertyName) : ''),
description: property.description ?? '', description: property.description ?? '',
type: fieldType, type: fieldType as FieldType,
ui_hidden: property.ui_hidden ?? false, ui_hidden: property.ui_hidden ?? false,
ui_type: property.ui_type, ui_type: property.ui_type,
ui_order: property.ui_order, ui_order: property.ui_order,
originalType,
}; };
return outputsAccumulator; return outputsAccumulator;