From 38e3dcbaebb97d311aa899aa77eca6d1fa5d8432 Mon Sep 17 00:00:00 2001 From: Chenlei Hu Date: Sun, 6 Oct 2024 22:15:33 -0400 Subject: [PATCH] Add frontend extension management panel (#1141) * Manage register of extension in pinia * Add disabled extensions setting * nit * Disable extension * Add virtual divider * Basic extension panel * Style cell * nit * Fix loading * inactive rules * nit * Calculate changes * nit * Experimental setting guard --- .../dialog/content/SettingDialogContent.vue | 28 ++++++ .../dialog/content/setting/ExtensionPanel.vue | 93 +++++++++++++++++++ src/extensions/core/nodeBadge.ts | 1 - src/i18n.ts | 4 + src/scripts/app.ts | 34 ++++--- src/stores/coreSettings.ts | 15 +++ src/stores/extensionStore.ts | 81 ++++++++++++++++ src/types/apiTypes.ts | 4 +- tests-ui/globalSetup.ts | 9 ++ 9 files changed, 249 insertions(+), 20 deletions(-) create mode 100644 src/components/dialog/content/setting/ExtensionPanel.vue create mode 100644 src/stores/extensionStore.ts diff --git a/src/components/dialog/content/SettingDialogContent.vue b/src/components/dialog/content/SettingDialogContent.vue index 730dd2c8b..750f553f4 100644 --- a/src/components/dialog/content/SettingDialogContent.vue +++ b/src/components/dialog/content/SettingDialogContent.vue @@ -57,6 +57,9 @@ + + + @@ -78,6 +81,7 @@ import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue' import { flattenTree } from '@/utils/treeUtil' import AboutPanel from './setting/AboutPanel.vue' import KeybindingPanel from './setting/KeybindingPanel.vue' +import ExtensionPanel from './setting/ExtensionPanel.vue' interface ISettingGroup { label: string @@ -96,11 +100,24 @@ const keybindingPanelNode: SettingTreeNode = { children: [] } +const extensionPanelNode: SettingTreeNode = { + key: 'extension', + label: 'Extension', + children: [] +} + +const extensionPanelNodeList = computed(() => { + const settingStore = useSettingStore() + const showExtensionPanel = settingStore.get('Comfy.Settings.ExtensionPanel') + return showExtensionPanel ? [extensionPanelNode] : [] +}) + const settingStore = useSettingStore() const settingRoot = computed(() => settingStore.settingTree) const categories = computed(() => [ ...(settingRoot.value.children || []), keybindingPanelNode, + ...extensionPanelNodeList.value, aboutPanelNode ]) const activeCategory = ref(null) @@ -226,4 +243,15 @@ const tabValue = computed(() => width: 100%; } } + +/* Show a separator line above the Keybinding tab */ +/* This indicates the start of custom setting panels */ +.settings-sidebar :deep(.p-listbox-option[aria-label='Keybinding']) { + position: relative; +} + +.settings-sidebar :deep(.p-listbox-option[aria-label='Keybinding'])::before { + @apply content-[''] top-0 left-0 absolute w-full; + border-top: 1px solid var(--p-divider-border-color); +} diff --git a/src/components/dialog/content/setting/ExtensionPanel.vue b/src/components/dialog/content/setting/ExtensionPanel.vue new file mode 100644 index 000000000..fc8be122a --- /dev/null +++ b/src/components/dialog/content/setting/ExtensionPanel.vue @@ -0,0 +1,93 @@ + + + diff --git a/src/extensions/core/nodeBadge.ts b/src/extensions/core/nodeBadge.ts index 109bd75ea..93a95e9a9 100644 --- a/src/extensions/core/nodeBadge.ts +++ b/src/extensions/core/nodeBadge.ts @@ -9,7 +9,6 @@ import _ from 'lodash' import { getColorPalette, defaultColorPalette } from './colorPalette' import { BadgePosition } from '@comfyorg/litegraph' import type { Palette } from '@/types/colorPalette' -import type { ComfyNodeDef } from '@/types/apiTypes' import { useNodeDefStore } from '@/stores/nodeDefStore' function getNodeSource(node: LGraphNode): NodeSource | null { diff --git a/src/i18n.ts b/src/i18n.ts index 26fffee17..ab9b37d54 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -2,6 +2,8 @@ import { createI18n } from 'vue-i18n' const messages = { en: { + extensionName: 'Extension Name', + reloadToApplyChanges: 'Reload to apply changes', insert: 'Insert', systemInfo: 'System Info', devices: 'Devices', @@ -108,6 +110,8 @@ const messages = { } }, zh: { + extensionName: '扩展名称', + reloadToApplyChanges: '重新加载以应用更改', insert: '插入', systemInfo: '系统信息', devices: '设备', diff --git a/src/scripts/app.ts b/src/scripts/app.ts index 56f2d6f4f..24b8623ba 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -52,8 +52,7 @@ import type { ToastMessageOptions } from 'primevue/toast' import { useWorkspaceStore } from '@/stores/workspaceStateStore' import { useExecutionStore } from '@/stores/executionStore' import { IWidget } from '@comfyorg/litegraph' -import { useKeybindingStore } from '@/stores/keybindingStore' -import { useCommandStore } from '@/stores/commandStore' +import { useExtensionStore } from '@/stores/extensionStore' export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview' @@ -357,6 +356,13 @@ export class ComfyApp { } } + get enabledExtensions() { + if (!this.vueAppReady) { + return this.extensions + } + return useExtensionStore().enabledExtensions + } + /** * Invoke an extension callback * @param {keyof ComfyExtension} method The extension callback to execute @@ -365,7 +371,7 @@ export class ComfyApp { */ #invokeExtensions(method, ...args) { let results = [] - for (const ext of this.extensions) { + for (const ext of this.enabledExtensions) { if (method in ext) { try { results.push(ext[method](...args, this)) @@ -391,7 +397,7 @@ export class ComfyApp { */ async #invokeExtensionsAsync(method, ...args) { return await Promise.all( - this.extensions.map(async (ext) => { + this.enabledExtensions.map(async (ext) => { if (method in ext) { try { return await ext[method](...args, this) @@ -1773,6 +1779,8 @@ export class ComfyApp { * Loads all extensions from the API into the window in parallel */ async #loadExtensions() { + useExtensionStore().loadDisabledExtensionNames() + const extensions = await api.getExtensions() this.logging.addEntry('Comfy.App', 'debug', { Extensions: extensions }) @@ -2943,22 +2951,12 @@ export class ComfyApp { * @param {ComfyExtension} extension */ registerExtension(extension: ComfyExtension) { - if (!extension.name) { - throw new Error("Extensions must have a 'name' property.") - } - // https://github.com/Comfy-Org/litegraph.js/pull/117 - if (extension.name === 'pysssss.Locking') { - console.log('pysssss.Locking is replaced by pin/unpin in ComfyUI core.') - return - } - if (this.extensions.find((ext) => ext.name === extension.name)) { - throw new Error(`Extension named '${extension.name}' already registered.`) - } if (this.vueAppReady) { - useKeybindingStore().loadExtensionKeybindings(extension) - useCommandStore().loadExtensionCommands(extension) + useExtensionStore().registerExtension(extension) + } else { + // For jest testing. + this.extensions.push(extension) } - this.extensions.push(extension) } /** diff --git a/src/stores/coreSettings.ts b/src/stores/coreSettings.ts index 84d044f98..72ad7f558 100644 --- a/src/stores/coreSettings.ts +++ b/src/stores/coreSettings.ts @@ -420,5 +420,20 @@ export const CORE_SETTINGS: SettingParams[] = [ type: 'hidden', defaultValue: [] as Keybinding[], versionAdded: '1.3.7' + }, + { + id: 'Comfy.Extension.Disabled', + name: 'Disabled extension names', + type: 'hidden', + defaultValue: [] as string[], + versionAdded: '1.3.11' + }, + { + id: 'Comfy.Settings.ExtensionPanel', + name: 'Show extension panel in settings dialog', + type: 'boolean', + defaultValue: false, + experimental: true, + versionAdded: '1.3.11' } ] diff --git a/src/stores/extensionStore.ts b/src/stores/extensionStore.ts new file mode 100644 index 000000000..675f41cdb --- /dev/null +++ b/src/stores/extensionStore.ts @@ -0,0 +1,81 @@ +import { ref, computed, markRaw } from 'vue' +import { defineStore } from 'pinia' +import type { ComfyExtension } from '@/types/comfy' +import { useKeybindingStore } from './keybindingStore' +import { useCommandStore } from './commandStore' +import { useSettingStore } from './settingStore' +import { app } from '@/scripts/app' + +export const useExtensionStore = defineStore('extension', () => { + // For legacy reasons, the name uniquely identifies an extension + const extensionByName = ref>({}) + const extensions = computed(() => Object.values(extensionByName.value)) + // Not using computed because disable extension requires reloading of the page. + // Dynamically update this list won't affect extensions that are already loaded. + const disabledExtensionNames = ref>(new Set()) + + // Disabled extension names that are currently not in the extension list. + // If a node pack is disabled in the backend, we shouldn't remove the configuration + // of the frontend extension disable list, in case the node pack is re-enabled. + const inactiveDisabledExtensionNames = computed(() => { + return Array.from(disabledExtensionNames.value).filter( + (name) => !(name in extensionByName.value) + ) + }) + + const isExtensionEnabled = (name: string) => + !disabledExtensionNames.value.has(name) + const enabledExtensions = computed(() => { + return extensions.value.filter((ext) => isExtensionEnabled(ext.name)) + }) + + function registerExtension(extension: ComfyExtension) { + if (!extension.name) { + throw new Error("Extensions must have a 'name' property.") + } + + if (extensionByName.value[extension.name]) { + throw new Error(`Extension named '${extension.name}' already registered.`) + } + + if (disabledExtensionNames.value.has(extension.name)) { + console.log(`Extension ${extension.name} is disabled.`) + } + + extensionByName.value[extension.name] = markRaw(extension) + useKeybindingStore().loadExtensionKeybindings(extension) + useCommandStore().loadExtensionCommands(extension) + + /* + * Extensions are currently stored in both extensionStore and app.extensions. + * Legacy jest tests still depend on app.extensions being populated. + */ + app.extensions.push(extension) + } + + function loadDisabledExtensionNames() { + disabledExtensionNames.value = new Set( + useSettingStore().get('Comfy.Extension.Disabled') + ) + // pysssss.Locking is replaced by pin/unpin in ComfyUI core. + // https://github.com/Comfy-Org/litegraph.js/pull/117 + disabledExtensionNames.value.add('pysssss.Locking') + } + + // Some core extensions are registered before the store is initialized, e.g. + // colorPalette. + // Register them manually here so the state of app.extensions and + // extensionByName are in sync. + for (const ext of app.extensions) { + extensionByName.value[ext.name] = markRaw(ext) + } + + return { + extensions, + enabledExtensions, + inactiveDisabledExtensionNames, + isExtensionEnabled, + registerExtension, + loadDisabledExtensionNames + } +}) diff --git a/src/types/apiTypes.ts b/src/types/apiTypes.ts index abe72831a..db8073a84 100644 --- a/src/types/apiTypes.ts +++ b/src/types/apiTypes.ts @@ -507,7 +507,9 @@ const zSettings = z.record(z.any()).and( 'Comfy.NodeBadge.NodeLifeCycleBadgeMode': zNodeBadgeMode, 'Comfy.QueueButton.BatchCountLimit': z.number(), 'Comfy.Keybinding.UnsetBindings': z.array(zKeybinding), - 'Comfy.Keybinding.NewBindings': z.array(zKeybinding) + 'Comfy.Keybinding.NewBindings': z.array(zKeybinding), + 'Comfy.Extension.Disabled': z.array(z.string()), + 'Comfy.Settings.ExtensionPanel': z.boolean() }) .optional() ) diff --git a/tests-ui/globalSetup.ts b/tests-ui/globalSetup.ts index d8ff2d2ea..4217738f7 100644 --- a/tests-ui/globalSetup.ts +++ b/tests-ui/globalSetup.ts @@ -36,6 +36,15 @@ module.exports = async function () { } }) + jest.mock('@/stores/extensionStore', () => { + return { + useExtensionStore: () => ({ + registerExtension: jest.fn(), + loadDisabledExtensionNames: jest.fn() + }) + } + }) + jest.mock('vue-i18n', () => { return { useI18n: jest.fn()