exposed field loading state (#5704)

* remove thunk for receivedOpenApiSchema and use RTK query instead. add loading state for exposed fields

* clean up

* ignore any

* fix(ui): do not log on canceled openapi.json queries

- Rely on RTK Query for the `loadSchema` query by providing a custom `jsonReplacer` in our `dynamicBaseQuery`, so we don't need to manage error state.
- Detect when the query was canceled and do not log the error message in those situations.

* feat(ui): `utilitiesApi.endpoints.loadSchema` -> `appInfoApi.endpoints.getOpenAPISchema`

- Utilities is for server actions, move this to `appInfo` bc it fits better there.
- Rename to match convention for HTTP GET queries.
- Fix inverted logic in the `matchRejected` listener (typo'd this)

---------

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:
Mary Hipp Rogers
2024-02-12 18:48:32 -05:00
committed by GitHub
parent 1dd07fb1eb
commit 25ce505628
12 changed files with 77 additions and 90 deletions

View File

@ -2,7 +2,7 @@ import type { UnknownAction } from '@reduxjs/toolkit';
import { isAnyGraphBuilt } from 'features/nodes/store/actions'; import { isAnyGraphBuilt } from 'features/nodes/store/actions';
import { nodeTemplatesBuilt } from 'features/nodes/store/nodeTemplatesSlice'; import { nodeTemplatesBuilt } from 'features/nodes/store/nodeTemplatesSlice';
import { cloneDeep } from 'lodash-es'; import { cloneDeep } from 'lodash-es';
import { receivedOpenAPISchema } from 'services/api/thunks/schema'; import { appInfoApi } from 'services/api/endpoints/appInfo';
import type { Graph } from 'services/api/types'; import type { Graph } from 'services/api/types';
import { socketGeneratorProgress } from 'services/events/actions'; import { socketGeneratorProgress } from 'services/events/actions';
@ -18,7 +18,7 @@ export const actionSanitizer = <A extends UnknownAction>(action: A): A => {
} }
} }
if (receivedOpenAPISchema.fulfilled.match(action)) { if (appInfoApi.endpoints.getOpenAPISchema.matchFulfilled(action)) {
return { return {
...action, ...action,
payload: '<OpenAPI schema omitted>', payload: '<OpenAPI schema omitted>',

View File

@ -23,6 +23,7 @@ import { addControlNetImageProcessedListener } from './listeners/controlNetImage
import { addEnqueueRequestedCanvasListener } from './listeners/enqueueRequestedCanvas'; import { addEnqueueRequestedCanvasListener } from './listeners/enqueueRequestedCanvas';
import { addEnqueueRequestedLinear } from './listeners/enqueueRequestedLinear'; import { addEnqueueRequestedLinear } from './listeners/enqueueRequestedLinear';
import { addEnqueueRequestedNodes } from './listeners/enqueueRequestedNodes'; import { addEnqueueRequestedNodes } from './listeners/enqueueRequestedNodes';
import { addGetOpenAPISchemaListener } from './listeners/getOpenAPISchema';
import { import {
addImageAddedToBoardFulfilledListener, addImageAddedToBoardFulfilledListener,
addImageAddedToBoardRejectedListener, addImageAddedToBoardRejectedListener,
@ -47,7 +48,6 @@ import { addInitialImageSelectedListener } from './listeners/initialImageSelecte
import { addModelSelectedListener } from './listeners/modelSelected'; import { addModelSelectedListener } from './listeners/modelSelected';
import { addModelsLoadedListener } from './listeners/modelsLoaded'; import { addModelsLoadedListener } from './listeners/modelsLoaded';
import { addDynamicPromptsListener } from './listeners/promptChanged'; import { addDynamicPromptsListener } from './listeners/promptChanged';
import { addReceivedOpenAPISchemaListener } from './listeners/receivedOpenAPISchema';
import { addSocketConnectedEventListener as addSocketConnectedListener } from './listeners/socketio/socketConnected'; import { addSocketConnectedEventListener as addSocketConnectedListener } from './listeners/socketio/socketConnected';
import { addSocketDisconnectedEventListener as addSocketDisconnectedListener } from './listeners/socketio/socketDisconnected'; import { addSocketDisconnectedEventListener as addSocketDisconnectedListener } from './listeners/socketio/socketDisconnected';
import { addGeneratorProgressEventListener as addGeneratorProgressListener } from './listeners/socketio/socketGeneratorProgress'; import { addGeneratorProgressEventListener as addGeneratorProgressListener } from './listeners/socketio/socketGeneratorProgress';
@ -150,7 +150,7 @@ addImageRemovedFromBoardRejectedListener();
addBoardIdSelectedListener(); addBoardIdSelectedListener();
// Node schemas // Node schemas
addReceivedOpenAPISchemaListener(); addGetOpenAPISchemaListener();
// Workflows // Workflows
addWorkflowLoadRequestedListener(); addWorkflowLoadRequestedListener();

View File

@ -3,18 +3,18 @@ import { parseify } from 'common/util/serialize';
import { nodeTemplatesBuilt } from 'features/nodes/store/nodeTemplatesSlice'; import { nodeTemplatesBuilt } from 'features/nodes/store/nodeTemplatesSlice';
import { parseSchema } from 'features/nodes/util/schema/parseSchema'; import { parseSchema } from 'features/nodes/util/schema/parseSchema';
import { size } from 'lodash-es'; import { size } from 'lodash-es';
import { receivedOpenAPISchema } from 'services/api/thunks/schema'; import { appInfoApi } from 'services/api/endpoints/appInfo';
import { startAppListening } from '..'; import { startAppListening } from '..';
export const addReceivedOpenAPISchemaListener = () => { export const addGetOpenAPISchemaListener = () => {
startAppListening({ startAppListening({
actionCreator: receivedOpenAPISchema.fulfilled, matcher: appInfoApi.endpoints.getOpenAPISchema.matchFulfilled,
effect: (action, { dispatch, getState }) => { effect: (action, { dispatch, getState }) => {
const log = logger('system'); const log = logger('system');
const schemaJSON = action.payload; const schemaJSON = action.payload;
log.debug({ schemaJSON }, 'Received OpenAPI schema'); log.debug({ schemaJSON: parseify(schemaJSON) }, 'Received OpenAPI schema');
const { nodesAllowlist, nodesDenylist } = getState().config; const { nodesAllowlist, nodesDenylist } = getState().config;
const nodeTemplates = parseSchema(schemaJSON, nodesAllowlist, nodesDenylist); const nodeTemplates = parseSchema(schemaJSON, nodesAllowlist, nodesDenylist);
@ -26,10 +26,14 @@ export const addReceivedOpenAPISchemaListener = () => {
}); });
startAppListening({ startAppListening({
actionCreator: receivedOpenAPISchema.rejected, matcher: appInfoApi.endpoints.getOpenAPISchema.matchRejected,
effect: (action) => { effect: (action) => {
// If action.meta.condition === true, the request was canceled/skipped because another request was in flight or
// the value was already in the cache. We don't want to log these errors.
if (!action.meta.condition) {
const log = logger('system'); const log = logger('system');
log.error({ error: parseify(action.error) }, 'Problem retrieving OpenAPI Schema'); log.error({ error: parseify(action.error) }, 'Problem retrieving OpenAPI Schema');
}
}, },
}); });
}; };

View File

@ -1,10 +1,9 @@
import { logger } from 'app/logging/logger'; import { logger } from 'app/logging/logger';
import { $baseUrl } from 'app/store/nanostores/baseUrl'; import { $baseUrl } from 'app/store/nanostores/baseUrl';
import { isEqual, size } from 'lodash-es'; import { isEqual } from 'lodash-es';
import { atom } from 'nanostores'; import { atom } from 'nanostores';
import { api } from 'services/api'; import { api } from 'services/api';
import { queueApi, selectQueueStatus } from 'services/api/endpoints/queue'; import { queueApi, selectQueueStatus } from 'services/api/endpoints/queue';
import { receivedOpenAPISchema } from 'services/api/thunks/schema';
import { socketConnected } from 'services/events/actions'; import { socketConnected } from 'services/events/actions';
import { startAppListening } from '../..'; import { startAppListening } from '../..';
@ -77,17 +76,4 @@ export const addSocketConnectedEventListener = () => {
} }
}, },
}); });
startAppListening({
actionCreator: socketConnected,
effect: async (action, { dispatch, getState }) => {
const { nodeTemplates, config } = getState();
// We only want to re-fetch the schema if we don't have any node templates
if (!size(nodeTemplates.templates) && !config.disabledTabs.includes('nodes')) {
// This request is a createAsyncThunk - resetting API state as in the above listener
// will not trigger this request, so we need to manually do it.
dispatch(receivedOpenAPISchema());
}
},
});
}; };

View File

@ -1,7 +1,6 @@
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 { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import TopPanel from 'features/nodes/components/flow/panels/TopPanel/TopPanel'; import TopPanel from 'features/nodes/components/flow/panels/TopPanel/TopPanel';
import { SaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/SaveWorkflowAsDialog'; import { SaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/SaveWorkflowAsDialog';
@ -11,6 +10,7 @@ import type { CSSProperties } from 'react';
import { memo } from 'react'; import { memo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { MdDeviceHub } from 'react-icons/md'; import { MdDeviceHub } from 'react-icons/md';
import { useGetOpenAPISchemaQuery } from 'services/api/endpoints/appInfo';
import AddNodePopover from './flow/AddNodePopover/AddNodePopover'; import AddNodePopover from './flow/AddNodePopover/AddNodePopover';
import { Flow } from './flow/Flow'; import { Flow } from './flow/Flow';
@ -40,7 +40,7 @@ const exit: AnimationProps['exit'] = {
}; };
const NodeEditor = () => { const NodeEditor = () => {
const isReady = useAppSelector((s) => s.nodes.isReady); const { data, isLoading } = useGetOpenAPISchemaQuery();
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Flex <Flex
@ -53,7 +53,7 @@ const NodeEditor = () => {
justifyContent="center" justifyContent="center"
> >
<AnimatePresence> <AnimatePresence>
{isReady && ( {data && (
<motion.div initial={initial} animate={animate} exit={exit} style={isReadyMotionStyles}> <motion.div initial={initial} animate={animate} exit={exit} style={isReadyMotionStyles}>
<Flow /> <Flow />
<AddNodePopover /> <AddNodePopover />
@ -65,7 +65,7 @@ const NodeEditor = () => {
)} )}
</AnimatePresence> </AnimatePresence>
<AnimatePresence> <AnimatePresence>
{!isReady && ( {isLoading && (
<motion.div initial={initial} animate={animate} exit={exit} style={notIsReadyMotionStyles}> <motion.div initial={initial} animate={animate} exit={exit} style={notIsReadyMotionStyles}>
<Flex <Flex
layerStyle="first" layerStyle="first"

View File

@ -1,17 +1,16 @@
import { Button } from '@invoke-ai/ui-library'; import { Button } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PiArrowsClockwiseBold } from 'react-icons/pi'; import { PiArrowsClockwiseBold } from 'react-icons/pi';
import { receivedOpenAPISchema } from 'services/api/thunks/schema'; import { useLazyGetOpenAPISchemaQuery } from 'services/api/endpoints/appInfo';
const ReloadNodeTemplatesButton = () => { const ReloadNodeTemplatesButton = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useAppDispatch(); const [_getOpenAPISchema] = useLazyGetOpenAPISchemaQuery();
const handleReloadSchema = useCallback(() => { const handleReloadSchema = useCallback(() => {
dispatch(receivedOpenAPISchema()); _getOpenAPISchema();
}, [dispatch]); }, [_getOpenAPISchema]);
return ( return (
<Button <Button

View File

@ -7,18 +7,22 @@ import LinearViewField from 'features/nodes/components/flow/nodes/Invocation/fie
import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice'; import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice';
import { memo } from 'react'; import { memo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useGetOpenAPISchemaQuery } from 'services/api/endpoints/appInfo';
const selector = createMemoizedSelector(selectWorkflowSlice, (workflow) => workflow.exposedFields); const selector = createMemoizedSelector(selectWorkflowSlice, (workflow) => workflow.exposedFields);
const WorkflowLinearTab = () => { const WorkflowLinearTab = () => {
const fields = useAppSelector(selector); const fields = useAppSelector(selector);
const { isLoading } = useGetOpenAPISchemaQuery();
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Box position="relative" w="full" h="full"> <Box position="relative" w="full" h="full">
<ScrollableContent> <ScrollableContent>
<Flex position="relative" flexDir="column" alignItems="flex-start" p={1} gap={2} h="full" w="full"> <Flex position="relative" flexDir="column" alignItems="flex-start" p={1} gap={2} h="full" w="full">
{fields.length ? ( {isLoading ? (
<IAINoContentFallback label={t('nodes.loadingNodes')} icon={null} />
) : fields.length ? (
fields.map(({ nodeId, fieldName }) => ( fields.map(({ nodeId, fieldName }) => (
<LinearViewField key={`${nodeId}.${fieldName}`} nodeId={nodeId} fieldName={fieldName} /> <LinearViewField key={`${nodeId}.${fieldName}`} nodeId={nodeId} fieldName={fieldName} />
)) ))

View File

@ -2,7 +2,6 @@ import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice, isAnyOf } from '@reduxjs/toolkit'; import { createSlice, isAnyOf } 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 { nodeTemplatesBuilt } from 'features/nodes/store/nodeTemplatesSlice';
import { SHARED_NODE_PROPERTIES } from 'features/nodes/types/constants'; import { SHARED_NODE_PROPERTIES } from 'features/nodes/types/constants';
import type { import type {
BoardFieldValue, BoardFieldValue,
@ -65,7 +64,6 @@ import {
SelectionMode, SelectionMode,
updateEdge, updateEdge,
} from 'reactflow'; } from 'reactflow';
import { receivedOpenAPISchema } from 'services/api/thunks/schema';
import { import {
socketGeneratorProgress, socketGeneratorProgress,
socketInvocationComplete, socketInvocationComplete,
@ -92,7 +90,6 @@ export const initialNodesState: NodesState = {
_version: 1, _version: 1,
nodes: [], nodes: [],
edges: [], edges: [],
isReady: false,
connectionStartParams: null, connectionStartParams: null,
connectionStartFieldType: null, connectionStartFieldType: null,
connectionMade: false, connectionMade: false,
@ -677,10 +674,6 @@ export const nodesSlice = createSlice({
}, },
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
builder.addCase(receivedOpenAPISchema.pending, (state) => {
state.isReady = false;
});
builder.addCase(workflowLoaded, (state, action) => { builder.addCase(workflowLoaded, (state, action) => {
const { nodes, edges } = action.payload; const { nodes, edges } = action.payload;
state.nodes = applyNodeChanges( state.nodes = applyNodeChanges(
@ -752,9 +745,6 @@ export const nodesSlice = createSlice({
}); });
} }
}); });
builder.addCase(nodeTemplatesBuilt, (state) => {
state.isReady = true;
});
}, },
}); });
@ -871,7 +861,6 @@ export const nodesPersistConfig: PersistConfig<NodesState> = {
'connectionStartFieldType', 'connectionStartFieldType',
'selectedNodes', 'selectedNodes',
'selectedEdges', 'selectedEdges',
'isReady',
'nodesToCopy', 'nodesToCopy',
'edgesToCopy', 'edgesToCopy',
'connectionMade', 'connectionMade',

View File

@ -26,7 +26,6 @@ export type NodesState = {
selectedEdges: string[]; selectedEdges: string[];
nodeExecutionStates: Record<string, NodeExecutionState>; nodeExecutionStates: Record<string, NodeExecutionState>;
viewport: Viewport; viewport: Viewport;
isReady: boolean;
nodesToCopy: AnyNode[]; nodesToCopy: AnyNode[];
edgesToCopy: InvocationNodeEdge[]; edgesToCopy: InvocationNodeEdge[];
isAddNodePopoverOpen: boolean; isAddNodePopoverOpen: boolean;

View File

@ -1,3 +1,5 @@
import { $openAPISchemaUrl } from 'app/store/nanostores/openAPISchemaUrl';
import type { OpenAPIV3_1 } from 'openapi-types';
import type { paths } from 'services/api/schema'; import type { paths } from 'services/api/schema';
import type { AppConfig, AppDependencyVersions, AppVersion } from 'services/api/types'; import type { AppConfig, AppDependencyVersions, AppVersion } from 'services/api/types';
@ -57,6 +59,14 @@ export const appInfoApi = api.injectEndpoints({
}), }),
invalidatesTags: ['InvocationCacheStatus'], invalidatesTags: ['InvocationCacheStatus'],
}), }),
getOpenAPISchema: build.query<OpenAPIV3_1.Document, void>({
query: () => {
const openAPISchemaUrl = $openAPISchemaUrl.get();
const url = openAPISchemaUrl ? openAPISchemaUrl : `${window.location.href.replace(/\/$/, '')}/openapi.json`;
return url;
},
providesTags: ['Schema'],
}),
}), }),
}); });
@ -68,4 +78,6 @@ export const {
useDisableInvocationCacheMutation, useDisableInvocationCacheMutation,
useEnableInvocationCacheMutation, useEnableInvocationCacheMutation,
useGetInvocationCacheStatusQuery, useGetInvocationCacheStatusQuery,
useGetOpenAPISchemaQuery,
useLazyGetOpenAPISchemaQuery,
} = appInfoApi; } = appInfoApi;

View File

@ -1,3 +1,4 @@
import type { FetchBaseQueryArgs } from '@reduxjs/toolkit/dist/query/fetchBaseQuery';
import type { BaseQueryFn, FetchArgs, FetchBaseQueryError, TagDescription } from '@reduxjs/toolkit/query/react'; import type { BaseQueryFn, FetchArgs, FetchBaseQueryError, TagDescription } from '@reduxjs/toolkit/query/react';
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { $authToken } from 'app/store/nanostores/authToken'; import { $authToken } from 'app/store/nanostores/authToken';
@ -35,6 +36,7 @@ export const tagTypes = [
'SDXLRefinerModel', 'SDXLRefinerModel',
'Workflow', 'Workflow',
'WorkflowsRecent', 'WorkflowsRecent',
'Schema',
// This is invalidated on reconnect. It should be used for queries that have changing data, // This is invalidated on reconnect. It should be used for queries that have changing data,
// especially related to the queue and generation. // especially related to the queue and generation.
'FetchOnReconnect', 'FetchOnReconnect',
@ -51,7 +53,7 @@ const dynamicBaseQuery: BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryE
const authToken = $authToken.get(); const authToken = $authToken.get();
const projectId = $projectId.get(); const projectId = $projectId.get();
const rawBaseQuery = fetchBaseQuery({ const fetchBaseQueryArgs: FetchBaseQueryArgs = {
baseUrl: baseUrl ? `${baseUrl}/api/v1` : `${window.location.href.replace(/\/$/, '')}/api/v1`, baseUrl: baseUrl ? `${baseUrl}/api/v1` : `${window.location.href.replace(/\/$/, '')}/api/v1`,
prepareHeaders: (headers) => { prepareHeaders: (headers) => {
if (authToken) { if (authToken) {
@ -63,7 +65,17 @@ const dynamicBaseQuery: BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryE
return headers; return headers;
}, },
}); };
// When fetching the openapi.json, we need to remove circular references from the JSON.
if (
(args instanceof Object && args.url.includes('openapi.json')) ||
(typeof args === 'string' && args.includes('openapi.json'))
) {
fetchBaseQueryArgs.jsonReplacer = getCircularReplacer();
}
const rawBaseQuery = fetchBaseQuery(fetchBaseQueryArgs);
return rawBaseQuery(args, api, extraOptions); return rawBaseQuery(args, api, extraOptions);
}; };
@ -74,3 +86,25 @@ export const api = createApi({
tagTypes, tagTypes,
endpoints: () => ({}), endpoints: () => ({}),
}); });
function getCircularReplacer() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ancestors: Record<string, any>[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return function (key: string, value: any) {
if (typeof value !== 'object' || value === null) {
return value;
}
// `this` is the object that value is contained in, i.e., its direct parent.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore don't think it's possible to not have TS complain about this...
while (ancestors.length > 0 && ancestors.at(-1) !== this) {
ancestors.pop();
}
if (ancestors.includes(value)) {
return '[Circular]';
}
ancestors.push(value);
return value;
};
}

View File

@ -1,40 +0,0 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { $openAPISchemaUrl } from 'app/store/nanostores/openAPISchemaUrl';
function getCircularReplacer() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ancestors: Record<string, any>[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return function (key: string, value: any) {
if (typeof value !== 'object' || value === null) {
return value;
}
// `this` is the object that value is contained in, i.e., its direct parent.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore don't think it's possible to not have TS complain about this...
while (ancestors.length > 0 && ancestors.at(-1) !== this) {
ancestors.pop();
}
if (ancestors.includes(value)) {
return '[Circular]';
}
ancestors.push(value);
return value;
};
}
export const receivedOpenAPISchema = createAsyncThunk('nodes/receivedOpenAPISchema', async (_, { rejectWithValue }) => {
try {
const openAPISchemaUrl = $openAPISchemaUrl.get();
const url = openAPISchemaUrl ? openAPISchemaUrl : `${window.location.href.replace(/\/$/, '')}/openapi.json`;
const response = await fetch(url);
const openAPISchema = await response.json();
const schemaJSON = JSON.parse(JSON.stringify(openAPISchema, getCircularReplacer()));
return schemaJSON;
} catch (error) {
return rejectWithValue({ error });
}
});