From 7c0de18f29fc5c34428d0f94acc16ea7718ad185 Mon Sep 17 00:00:00 2001 From: Jan Thomas Date: Mon, 4 Nov 2024 08:53:52 +0100 Subject: [PATCH 1/6] zod'ify StudioInitAction --- .../web/src/app/hooks/useStudioInitAction.ts | 59 +++++++++++++------ 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts b/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts index a48311e5f28..2a353162337 100644 --- a/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts +++ b/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts @@ -18,24 +18,49 @@ import { useCallback, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { getImageDTO, getImageMetadata } from 'services/api/endpoints/images'; import { getStylePreset } from 'services/api/endpoints/stylePresets'; +import { z } from 'zod'; -type _StudioInitAction = { type: T; data: U }; - -type LoadWorkflowAction = _StudioInitAction<'loadWorkflow', { workflowId: string }>; -type SelectStylePresetAction = _StudioInitAction<'selectStylePreset', { stylePresetId: string }>; -type SendToCanvasAction = _StudioInitAction<'sendToCanvas', { imageName: string }>; -type UseAllParametersAction = _StudioInitAction<'useAllParameters', { imageName: string }>; -type StudioDestinationAction = _StudioInitAction< - 'goToDestination', - { destination: 'generation' | 'canvas' | 'workflows' | 'upscaling' | 'viewAllWorkflows' | 'viewAllStylePresets' } ->; - -export type StudioInitAction = - | LoadWorkflowAction - | SelectStylePresetAction - | SendToCanvasAction - | UseAllParametersAction - | StudioDestinationAction; +const zLoadWorkflowAction = z.object({ + type: z.literal('loadWorkflow'), + data: z.object({ workflowId: z.string() }), +}); +// type LoadWorkflowAction = z.infer; + +const zSelectStylePresetAction = z.object({ + type: z.literal('selectStylePreset'), + data: z.object({ stylePresetId: z.string() }), +}); +// type SelectStylePresetAction = z.infer; + +const zSendToCanvasAction = z.object({ + type: z.literal('sendToCanvas'), + data: z.object({ imageName: z.string() }), +}); +// type SendToCanvasAction = z.infer; + +const zUseAllParametersAction = z.object({ + type: z.literal('useAllParameters'), + data: z.object({ imageName: z.string() }), +}); +// type UseAllParametersAction = z.infer; + +const zStudioDestinationAction = z.object({ + type: z.literal('goToDestination'), + data: z.object({ + destination: z.enum(['generation', 'canvas', 'workflows', 'upscaling', 'viewAllWorkflows', 'viewAllStylePresets']), + }), +}); +type StudioDestinationAction = z.infer; + +const zStudioInitAction = z.discriminatedUnion('type', [ + zLoadWorkflowAction, + zSelectStylePresetAction, + zSendToCanvasAction, + zUseAllParametersAction, + zStudioDestinationAction, +]); + +export type StudioInitAction = z.infer; /** * A hook that performs an action when the studio is initialized. This is useful for deep linking into the studio. From 774f9d665bfdb74d1670f54bbac7aecfe5aafae2 Mon Sep 17 00:00:00 2001 From: Jan Thomas Date: Mon, 4 Nov 2024 10:06:36 +0100 Subject: [PATCH 2/6] StudioInitAction via HashBang e.g.: http://localhost:9090/#!useAllParameter&imageName={image-hash}.png will result in a startup using all parameters of that Image --- .../web/src/app/components/InvokeAIUI.tsx | 3 + .../web/src/app/hooks/useStudioInitAction.ts | 68 ++++++++++++++++++- invokeai/frontend/web/src/i18n.ts | 3 +- .../frontend/web/src/services/api/index.ts | 3 +- 4 files changed, 73 insertions(+), 4 deletions(-) diff --git a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx index dbcebd00350..0343da0a080 100644 --- a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx +++ b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx @@ -2,6 +2,7 @@ import 'i18n'; import type { Middleware } from '@reduxjs/toolkit'; import type { StudioInitAction } from 'app/hooks/useStudioInitAction'; +import { fillStudioInitAction } from 'app/hooks/useStudioInitAction'; import type { LoggingOverrides } from 'app/logging/logger'; import { $loggingOverrides, configureLogging } from 'app/logging/logger'; import { $authToken } from 'app/store/nanostores/authToken'; @@ -70,6 +71,8 @@ const InvokeAIUI = ({ workflowCategories, loggingOverrides, }: Props) => { + studioInitAction = fillStudioInitAction(); + useLayoutEffect(() => { /* * We need to configure logging before anything else happens - useLayoutEffect ensures we set this at the first diff --git a/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts b/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts index 2a353162337..4c8a1a88d8c 100644 --- a/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts +++ b/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts @@ -14,11 +14,13 @@ import { $isStylePresetsMenuOpen, activeStylePresetIdChanged } from 'features/st import { toast } from 'features/toast/toast'; import { activeTabCanvasRightPanelChanged, setActiveTab } from 'features/ui/store/uiSlice'; import { useGetAndLoadLibraryWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadLibraryWorkflow'; +import { t } from 'i18next'; import { useCallback, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { getImageDTO, getImageMetadata } from 'services/api/endpoints/images'; import { getStylePreset } from 'services/api/endpoints/stylePresets'; import { z } from 'zod'; +import { fromZodError } from 'zod-validation-error'; const zLoadWorkflowAction = z.object({ type: z.literal('loadWorkflow'), @@ -52,7 +54,7 @@ const zStudioDestinationAction = z.object({ }); type StudioDestinationAction = z.infer; -const zStudioInitAction = z.discriminatedUnion('type', [ +export const zStudioInitAction = z.discriminatedUnion('type', [ zLoadWorkflowAction, zSelectStylePresetAction, zSendToCanvasAction, @@ -60,7 +62,69 @@ const zStudioInitAction = z.discriminatedUnion('type', [ zStudioDestinationAction, ]); -export type StudioInitAction = z.infer; +export type StudioInitAction = z.infer; + +/** + * Converts a given hashbang string to a valid StudioInitAction + * @param {string} hashBang + * @returns {StudioInitAction} + * @throws {z.ZodError | Error} If there is a validation error. + */ +export const genHashBangStudioInitAction = (hashBang: string): StudioInitAction => { + if (!hashBang.startsWith('#!')) { + throw new Error("The given string isn't a valid hashbang"); + } + const parts = hashBang.substring(2).split('&'); + return zStudioInitAction.parse({ + type: parts.shift(), + data: Object.fromEntries(new URLSearchParams(parts.join('&'))), + }); +}; + +export const fillStudioInitAction = (studioInitAction?: StudioInitAction): StudioInitAction | undefined => { + if (studioInitAction !== undefined) { + return studioInitAction; + } + if (!location.hash.startsWith('#!')) { + return undefined; + } + + try { + studioInitAction = genHashBangStudioInitAction(location.hash); + location.hash = ''; + } catch (e) { + const err = { + id: 'UNABLE_TO_VALIDATE_HASHBANG_ACTION', + title: t('nodes.unableToValidateHashBangAction'), + }; + + setTimeout(() => { + if (e instanceof z.ZodError) { + const { message } = fromZodError(e, { + prefix: t('nodes.hashbangActionValidation'), + }); + toast({ + ...err, + status: 'error', + description: message, + }); + } else if (e instanceof Error) { + toast({ + ...err, + status: 'error', + description: e.message, + }); + } else { + toast({ + ...err, + status: 'error', + description: t('nodes.unknownErrorValidateHashBangAction'), + }); + } + }, 1500); + } + return studioInitAction; +}; /** * A hook that performs an action when the studio is initialized. This is useful for deep linking into the studio. diff --git a/invokeai/frontend/web/src/i18n.ts b/invokeai/frontend/web/src/i18n.ts index 89c855bcd02..b9f85d11e75 100644 --- a/invokeai/frontend/web/src/i18n.ts +++ b/invokeai/frontend/web/src/i18n.ts @@ -32,7 +32,8 @@ if (import.meta.env.MODE === 'package') { fallbackLng: 'en', debug: false, backend: { - loadPath: `${window.location.href.replace(/\/$/, '')}/locales/{{lng}}.json`, + // loadPath: `${window.location.href.replace(/\/$/, '')}/locales/{{lng}}.json`, + loadPath: '/locales/{{lng}}.json', }, interpolation: { escapeValue: false, diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts index 0b82714d94c..503832e87bb 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -59,7 +59,8 @@ const dynamicBaseQuery: BaseQueryFn Date: Tue, 5 Nov 2024 07:34:46 +0100 Subject: [PATCH 3/6] ignore additions to window location on API client and translations library comments of the original removed. This enables the address to have a hash without trowing the system off. --- invokeai/frontend/web/src/i18n.ts | 1 - invokeai/frontend/web/src/services/api/index.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/invokeai/frontend/web/src/i18n.ts b/invokeai/frontend/web/src/i18n.ts index b9f85d11e75..f0cbe40a75f 100644 --- a/invokeai/frontend/web/src/i18n.ts +++ b/invokeai/frontend/web/src/i18n.ts @@ -32,7 +32,6 @@ if (import.meta.env.MODE === 'package') { fallbackLng: 'en', debug: false, backend: { - // loadPath: `${window.location.href.replace(/\/$/, '')}/locales/{{lng}}.json`, loadPath: '/locales/{{lng}}.json', }, interpolation: { diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts index 503832e87bb..562f184fcae 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -59,7 +59,6 @@ const dynamicBaseQuery: BaseQueryFn Date: Tue, 5 Nov 2024 07:52:52 +0100 Subject: [PATCH 4/6] error handling cleanup & comments --- .../web/src/app/hooks/useStudioInitAction.ts | 50 +++++++------------ 1 file changed, 18 insertions(+), 32 deletions(-) diff --git a/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts b/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts index 4c8a1a88d8c..c1d518c0982 100644 --- a/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts +++ b/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts @@ -1,3 +1,4 @@ +import { logger } from 'app/logging/logger'; import { useAppStore } from 'app/store/storeHooks'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { withResultAsync } from 'common/util/result'; @@ -14,13 +15,12 @@ import { $isStylePresetsMenuOpen, activeStylePresetIdChanged } from 'features/st import { toast } from 'features/toast/toast'; import { activeTabCanvasRightPanelChanged, setActiveTab } from 'features/ui/store/uiSlice'; import { useGetAndLoadLibraryWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadLibraryWorkflow'; -import { t } from 'i18next'; import { useCallback, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import { serializeError } from 'serialize-error'; import { getImageDTO, getImageMetadata } from 'services/api/endpoints/images'; import { getStylePreset } from 'services/api/endpoints/stylePresets'; import { z } from 'zod'; -import { fromZodError } from 'zod-validation-error'; const zLoadWorkflowAction = z.object({ type: z.literal('loadWorkflow'), @@ -81,6 +81,13 @@ export const genHashBangStudioInitAction = (hashBang: string): StudioInitAction }); }; +/** + * Uses the HashBang fragment to populate an unset StudioInitAction + * If any studioInitAction is given, it will early bail with it. + * this will interpret and validate the hashbang as an studioInitAction + * @param {StudioInitAction} studioInitAction + * @returns {StudioInitAction | undefined} + */ export const fillStudioInitAction = (studioInitAction?: StudioInitAction): StudioInitAction | undefined => { if (studioInitAction !== undefined) { return studioInitAction; @@ -92,36 +99,15 @@ export const fillStudioInitAction = (studioInitAction?: StudioInitAction): Studi try { studioInitAction = genHashBangStudioInitAction(location.hash); location.hash = ''; - } catch (e) { - const err = { - id: 'UNABLE_TO_VALIDATE_HASHBANG_ACTION', - title: t('nodes.unableToValidateHashBangAction'), - }; - - setTimeout(() => { - if (e instanceof z.ZodError) { - const { message } = fromZodError(e, { - prefix: t('nodes.hashbangActionValidation'), - }); - toast({ - ...err, - status: 'error', - description: message, - }); - } else if (e instanceof Error) { - toast({ - ...err, - status: 'error', - description: e.message, - }); - } else { - toast({ - ...err, - status: 'error', - description: t('nodes.unknownErrorValidateHashBangAction'), - }); - } - }, 1500); + } catch (err) { + const log = logger('system'); + if (err instanceof z.ZodError) { + log.error({ error: serializeError(err) }, 'Problem persisting the studioInitAction from the given hashbang'); + } else if (err instanceof Error) { + log.error({ error: serializeError(err) }, 'Problem interpreting the hashbang'); + } else { + log.error({ error: serializeError(err) }, 'Problem while filling StudioInitAction'); + } } return studioInitAction; }; From 29f7b862ead214bd9718180b6418ffc181f35946 Mon Sep 17 00:00:00 2001 From: Jan Thomas Date: Tue, 5 Nov 2024 08:09:36 +0100 Subject: [PATCH 5/6] add comments --- invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts b/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts index c1d518c0982..762e94e4a22 100644 --- a/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts +++ b/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts @@ -66,6 +66,7 @@ export type StudioInitAction = z.infer; /** * Converts a given hashbang string to a valid StudioInitAction + * @see fillStudioInitAction * @param {string} hashBang * @returns {StudioInitAction} * @throws {z.ZodError | Error} If there is a validation error. @@ -82,11 +83,11 @@ export const genHashBangStudioInitAction = (hashBang: string): StudioInitAction }; /** - * Uses the HashBang fragment to populate an unset StudioInitAction + * Uses the HashBang fragment to populate an unset StudioInitAction in case the user tries to execute a StudioInitAction on startup via a location.hash fragment * If any studioInitAction is given, it will early bail with it. * this will interpret and validate the hashbang as an studioInitAction * @param {StudioInitAction} studioInitAction - * @returns {StudioInitAction | undefined} + * @returns {StudioInitAction | undefined} undefined if nothing can be resolved */ export const fillStudioInitAction = (studioInitAction?: StudioInitAction): StudioInitAction | undefined => { if (studioInitAction !== undefined) { From f92cc76a3e91a997f16c5c8e1d5736ed33bb035a Mon Sep 17 00:00:00 2001 From: Jan Thomas Date: Wed, 6 Nov 2024 05:36:42 +0100 Subject: [PATCH 6/6] feat(ui): DeepLinks selectBoard selectBoard now a StudioInitAction optional hashbang clear on fill --- .../web/src/app/components/InvokeAIUI.tsx | 2 +- .../web/src/app/hooks/useStudioInitAction.ts | 42 ++++++++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx index 0343da0a080..ff300615e86 100644 --- a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx +++ b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx @@ -71,7 +71,7 @@ const InvokeAIUI = ({ workflowCategories, loggingOverrides, }: Props) => { - studioInitAction = fillStudioInitAction(); + studioInitAction = fillStudioInitAction(studioInitAction); useLayoutEffect(() => { /* diff --git a/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts b/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts index 762e94e4a22..74e274d039a 100644 --- a/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts +++ b/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts @@ -9,6 +9,7 @@ import type { CanvasRasterLayerState } from 'features/controlLayers/store/types' import { imageDTOToImageObject } from 'features/controlLayers/store/util'; import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { sentImageToCanvas } from 'features/gallery/store/actions'; +import { boardIdSelected } from 'features/gallery/store/gallerySlice'; import { parseAndRecallAllMetadata } from 'features/metadata/util/handlers'; import { $isWorkflowListMenuIsOpen } from 'features/nodes/store/workflowListMenu'; import { $isStylePresetsMenuOpen, activeStylePresetIdChanged } from 'features/stylePresets/store/stylePresetSlice'; @@ -28,6 +29,18 @@ const zLoadWorkflowAction = z.object({ }); // type LoadWorkflowAction = z.infer; +const zSelectBoardAction = z.object({ + type: z.literal('selectBoard'), + data: z.object({ boardId: z.string() }), +}); +// type SelectBoardAction = z.infer; + +const zSelectImageAction = z.object({ + type: z.literal('selectImage'), + data: z.object({ imageName: z.string() }), +}); +// type SelectImageAction = z.infer; + const zSelectStylePresetAction = z.object({ type: z.literal('selectStylePreset'), data: z.object({ stylePresetId: z.string() }), @@ -56,6 +69,8 @@ type StudioDestinationAction = z.infer; export const zStudioInitAction = z.discriminatedUnion('type', [ zLoadWorkflowAction, + zSelectBoardAction, + zSelectImageAction, zSelectStylePresetAction, zSendToCanvasAction, zUseAllParametersAction, @@ -73,7 +88,7 @@ export type StudioInitAction = z.infer; */ export const genHashBangStudioInitAction = (hashBang: string): StudioInitAction => { if (!hashBang.startsWith('#!')) { - throw new Error("The given string isn't a valid hashbang"); + throw new Error("The given string isn't a valid hashbang action"); } const parts = hashBang.substring(2).split('&'); return zStudioInitAction.parse({ @@ -86,10 +101,12 @@ export const genHashBangStudioInitAction = (hashBang: string): StudioInitAction * Uses the HashBang fragment to populate an unset StudioInitAction in case the user tries to execute a StudioInitAction on startup via a location.hash fragment * If any studioInitAction is given, it will early bail with it. * this will interpret and validate the hashbang as an studioInitAction - * @param {StudioInitAction} studioInitAction * @returns {StudioInitAction | undefined} undefined if nothing can be resolved */ -export const fillStudioInitAction = (studioInitAction?: StudioInitAction): StudioInitAction | undefined => { +export const fillStudioInitAction = ( + studioInitAction?: StudioInitAction, + clearHashBang: boolean = false +): StudioInitAction | undefined => { if (studioInitAction !== undefined) { return studioInitAction; } @@ -99,7 +116,9 @@ export const fillStudioInitAction = (studioInitAction?: StudioInitAction): Studi try { studioInitAction = genHashBangStudioInitAction(location.hash); - location.hash = ''; + if (clearHashBang) { + location.hash = ''; //reset the hash to "acknowledge" the initAction (and push the history forward) + } } catch (err) { const log = logger('system'); if (err instanceof z.ZodError) { @@ -120,7 +139,7 @@ export const fillStudioInitAction = (studioInitAction?: StudioInitAction): Studi * * In this hook, we prefer to use imperative APIs over hooks to avoid re-rendering the parent component. For example: * - Use `getImageDTO` helper instead of `useGetImageDTO` - * - Usee the `$imageViewer` atom instead of `useImageViewer` + * - Use the `$imageViewer` atom instead of `useImageViewer` */ export const useStudioInitAction = (action?: StudioInitAction) => { useAssertSingleton('useStudioInitAction'); @@ -190,6 +209,15 @@ export const useStudioInitAction = (action?: StudioInitAction) => { [getAndLoadWorkflow, store] ); + const handleSelectBoard = useCallback( + (boardId: string) => { + //TODO: validate given boardID + store.dispatch(boardIdSelected({ boardId: boardId })); + //TODO: scroll into view + }, + [store] + ); + const handleSelectStylePreset = useCallback( async (stylePresetId: string) => { const getStylePresetResult = await withResultAsync(() => getStylePreset(stylePresetId)); @@ -260,6 +288,9 @@ export const useStudioInitAction = (action?: StudioInitAction) => { case 'loadWorkflow': handleLoadWorkflow(action.data.workflowId); break; + case 'selectBoard': + handleSelectBoard(action.data.boardId); + break; case 'selectStylePreset': handleSelectStylePreset(action.data.stylePresetId); break; @@ -278,6 +309,7 @@ export const useStudioInitAction = (action?: StudioInitAction) => { handleUseAllMetadata, action, handleLoadWorkflow, + handleSelectBoard, handleSelectStylePreset, handleGoToDestination, ]);