From d7ec24abc321ab508d4a4392a2e0aa6da26a446c Mon Sep 17 00:00:00 2001 From: sno Date: Fri, 30 Jan 2026 17:09:28 +0900 Subject: [PATCH 01/61] fix: use PR_GH_TOKEN in lint/i18n workflows to trigger e2e tests (#8484) ## Summary Add `PR_GH_TOKEN` to ci-lint-format and i18n-update-core workflows to ensure commits trigger e2e test runs. ## Changes - Add `token: ${{ secrets.PR_GH_TOKEN }}` to checkout step in `.github/workflows/ci-lint-format.yaml` - Add `token: ${{ secrets.PR_GH_TOKEN }}` to checkout step in `.github/workflows/i18n-update-core.yaml` ## Context This matches the pattern used consistently across all other workflows in the repository (release-version-bump, version-bump-desktop-ui, api-update-registry-api-types, i18n-update-nodes, etc.). Without this token, commits made by these workflows don't trigger downstream e2e tests, which can lead to issues being missed. ## Original Work Original idea & implementation by @drjkl in commits: - be3a8d61c - fix: use GitHub App token for lint/i18n workflows to trigger e2e tests - 50ccb254b - Add github app token action to pinact This PR simplifies the approach to use the existing `PR_GH_TOKEN` secret instead of GitHub App tokens, for consistency with the rest of the repository. ## Test Plan - [x] Verify workflows can checkout code successfully - [x] Confirm commits from these workflows trigger e2e test runs ## It works! image --------- Co-authored-by: Claude Sonnet 4.5 Co-authored-by: GitHub Action --- .github/workflows/ci-lint-format.yaml | 1 + .github/workflows/i18n-update-core.yaml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.github/workflows/ci-lint-format.yaml b/.github/workflows/ci-lint-format.yaml index f463af16c..df3f30c38 100644 --- a/.github/workflows/ci-lint-format.yaml +++ b/.github/workflows/ci-lint-format.yaml @@ -21,6 +21,7 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ !github.event.pull_request.head.repo.fork && github.head_ref || github.ref }} + token: ${{ secrets.PR_GH_TOKEN }} - name: Setup frontend uses: ./.github/actions/setup-frontend diff --git a/.github/workflows/i18n-update-core.yaml b/.github/workflows/i18n-update-core.yaml index 7b0299ab1..5f0985b93 100644 --- a/.github/workflows/i18n-update-core.yaml +++ b/.github/workflows/i18n-update-core.yaml @@ -17,6 +17,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v6 + with: + token: ${{ secrets.PR_GH_TOKEN }} # Setup playwright environment - name: Setup ComfyUI Frontend From b4649bc96de169b772e0b12df14c1b11da71aef0 Mon Sep 17 00:00:00 2001 From: Alexander Brown Date: Fri, 30 Jan 2026 01:11:41 -0800 Subject: [PATCH 02/61] Set IS_NIGHTLY right when we build (#8482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8482-Set-IS_NIGHTLY-right-when-we-build-2f86d73d365081009fdbc2aee8f7545d) by [Unito](https://www.unito.io) --- .github/workflows/release-draft-create.yaml | 1 + vite.config.mts | 6 +----- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-draft-create.yaml b/.github/workflows/release-draft-create.yaml index 1e32e450b..74d6c9898 100644 --- a/.github/workflows/release-draft-create.yaml +++ b/.github/workflows/release-draft-create.yaml @@ -50,6 +50,7 @@ jobs: ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }} ENABLE_MINIFY: 'true' USE_PROD_CONFIG: 'true' + IS_NIGHTLY: ${{ case(github.ref == 'refs/heads/main', 'true', 'false') }} run: | pnpm install --frozen-lockfile pnpm build diff --git a/vite.config.mts b/vite.config.mts index 9769ec300..0732698cb 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -53,11 +53,7 @@ const DISTRIBUTION: 'desktop' | 'localhost' | 'cloud' = // Nightly builds are from main branch; RC/stable builds are from core/* branches // Can be overridden via IS_NIGHTLY env var for testing -const IS_NIGHTLY = - process.env.IS_NIGHTLY === 'true' || - (process.env.IS_NIGHTLY !== 'false' && - process.env.CI === 'true' && - process.env.GITHUB_REF_NAME === 'main') +const IS_NIGHTLY = process.env.IS_NIGHTLY === 'true' // Disable Vue DevTools for production cloud distribution const DISABLE_VUE_PLUGINS = From e7d3bc7285429987efd69aba0a87b4dcbf70c5b7 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Fri, 30 Jan 2026 08:16:11 -0800 Subject: [PATCH 03/61] fix: properties panel obscures menus in legacy layout (#8474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes overlap issue where the new menu's node properties panel could cover the old floating menu. ## Changes Hide the properties panel when using the legacy menu, so it doesn't obscure the old menu. The properties panel did not work anyway (empy content). ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8474-fix-increase-z-index-of-legacy-floating-menu-2f86d73d365081a0ab44db24c2ea6357) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp --- src/stores/workspace/rightSidePanelStore.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/stores/workspace/rightSidePanelStore.ts b/src/stores/workspace/rightSidePanelStore.ts index 79a511ab3..ad4a26ae8 100644 --- a/src/stores/workspace/rightSidePanelStore.ts +++ b/src/stores/workspace/rightSidePanelStore.ts @@ -1,5 +1,5 @@ import { defineStore } from 'pinia' -import { computed, ref } from 'vue' +import { computed, ref, watch } from 'vue' import { useSettingStore } from '@/platform/settings/settingStore' @@ -19,8 +19,13 @@ type RightSidePanelSection = 'advanced-inputs' | string export const useRightSidePanelStore = defineStore('rightSidePanel', () => { const settingStore = useSettingStore() + const isLegacyMenu = computed( + () => settingStore.get('Comfy.UseNewMenu') === 'Disabled' + ) + const isOpen = computed({ - get: () => settingStore.get('Comfy.RightSidePanel.IsOpen'), + get: () => + !isLegacyMenu.value && settingStore.get('Comfy.RightSidePanel.IsOpen'), set: (value: boolean) => settingStore.set('Comfy.RightSidePanel.IsOpen', value) }) @@ -29,7 +34,15 @@ export const useRightSidePanelStore = defineStore('rightSidePanel', () => { const focusedSection = ref(null) const searchQuery = ref('') + // Auto-close panel when switching to legacy menu mode + watch(isLegacyMenu, (legacy) => { + if (legacy) { + void settingStore.set('Comfy.RightSidePanel.IsOpen', false) + } + }) + function openPanel(tab?: RightSidePanelTab) { + if (isLegacyMenu.value) return isOpen.value = true if (tab) { activeTab.value = tab From ee600a8951318e32a13c374dd6dcccbae4796c93 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Fri, 30 Jan 2026 08:27:39 -0800 Subject: [PATCH 04/61] fix: prevent image/video preview reset on dynamic widget addition (#8366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes image/video previews getting stuck in loading state when widgets are added dynamically to a node. ## Problem When dynamic widgets are added to a node (e.g., by extensions), Vue reactivity triggers the watch on `imageUrls` prop even when the URL content is identical—the array has a new reference but the same values. This caused: 1. `startDelayedLoader()` to reset loading state to pending 2. If the cached image doesn't trigger `@load` before the 250ms timeout, the loader shows and stays stuck ## Solution Compare URL arrays by content, not reference. Only reset loading state when URLs actually change: - Check array length and element-by-element equality - Return early if URLs are identical (just a new array reference) - Remove `deep: true` since we compare manually ## Screenshots image Screenshot from 2026-01-28 15-24-18 Screenshot from 2026-01-28 15-24-05 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8366-fix-prevent-image-video-preview-reset-on-dynamic-widget-addition-2f66d73d3650819483b2d5cbfb78187f) by [Unito](https://www.unito.io) --------- Co-authored-by: Subagent 5 Co-authored-by: Amp --- .../extensions/vueNodes/VideoPreview.vue | 12 ++- .../vueNodes/components/ImagePreview.test.ts | 76 +++++++++++++++++++ .../vueNodes/components/ImagePreview.vue | 12 ++- .../vueNodes/components/LGraphNode.vue | 12 +-- 4 files changed, 103 insertions(+), 9 deletions(-) diff --git a/src/renderer/extensions/vueNodes/VideoPreview.vue b/src/renderer/extensions/vueNodes/VideoPreview.vue index cd34ee308..6b308a1b3 100644 --- a/src/renderer/extensions/vueNodes/VideoPreview.vue +++ b/src/renderer/extensions/vueNodes/VideoPreview.vue @@ -158,7 +158,15 @@ const hasMultipleVideos = computed(() => props.imageUrls.length > 1) // Watch for URL changes and reset state watch( () => props.imageUrls, - (newUrls) => { + (newUrls, oldUrls) => { + // Only reset state if URLs actually changed (not just array reference) + const urlsChanged = + !oldUrls || + newUrls.length !== oldUrls.length || + newUrls.some((url, i) => url !== oldUrls[i]) + + if (!urlsChanged) return + // Reset current index if it's out of bounds if (currentIndex.value >= newUrls.length) { currentIndex.value = 0 @@ -169,7 +177,7 @@ watch( videoError.value = false showLoader.value = newUrls.length > 0 }, - { deep: true, immediate: true } + { immediate: true } ) // Event handlers diff --git a/src/renderer/extensions/vueNodes/components/ImagePreview.test.ts b/src/renderer/extensions/vueNodes/components/ImagePreview.test.ts index 583bc1856..d6be080ba 100644 --- a/src/renderer/extensions/vueNodes/components/ImagePreview.test.ts +++ b/src/renderer/extensions/vueNodes/components/ImagePreview.test.ts @@ -308,4 +308,80 @@ describe('ImagePreview', () => { expect(imgElement.exists()).toBe(true) expect(imgElement.attributes('alt')).toBe('Node output 2') }) + + describe('URL change detection', () => { + it('should NOT reset loading state when imageUrls prop is reassigned with identical URLs', async () => { + vi.useFakeTimers() + try { + const urls = ['/api/view?filename=test.png&type=output'] + const wrapper = mountImagePreview({ imageUrls: urls }) + + // Simulate image load completing + const img = wrapper.find('img') + await img.trigger('load') + await nextTick() + + // Verify loader is hidden after load + expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false) + + // Reassign with new array reference but same content + await wrapper.setProps({ imageUrls: [...urls] }) + await nextTick() + + // Advance past the 250ms delayed loader timeout + await vi.advanceTimersByTimeAsync(300) + await nextTick() + + // Loading state should NOT have been reset - aria-busy should still be false + // because the URLs are identical (just a new array reference) + expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it('should reset loading state when imageUrls prop changes to different URLs', async () => { + const urls = ['/api/view?filename=test.png&type=output'] + const wrapper = mountImagePreview({ imageUrls: urls }) + + // Simulate image load completing + const img = wrapper.find('img') + await img.trigger('load') + await nextTick() + + // Verify loader is hidden + expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false) + + // Change to different URL + await wrapper.setProps({ + imageUrls: ['/api/view?filename=different.png&type=output'] + }) + await nextTick() + + // After 250ms timeout, loading state should be reset (aria-busy="true") + // We can check the internal state via the Skeleton appearing + // or wait for the timeout + await new Promise((resolve) => setTimeout(resolve, 300)) + await nextTick() + + expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true) + }) + + it('should handle empty to non-empty URL transitions correctly', async () => { + const wrapper = mountImagePreview({ imageUrls: [] }) + + // No preview initially + expect(wrapper.find('.image-preview').exists()).toBe(false) + + // Add URLs + await wrapper.setProps({ + imageUrls: ['/api/view?filename=test.png&type=output'] + }) + await nextTick() + + // Preview should appear + expect(wrapper.find('.image-preview').exists()).toBe(true) + expect(wrapper.find('img').exists()).toBe(true) + }) + }) }) diff --git a/src/renderer/extensions/vueNodes/components/ImagePreview.vue b/src/renderer/extensions/vueNodes/components/ImagePreview.vue index a8b0908c1..553aeeeba 100644 --- a/src/renderer/extensions/vueNodes/components/ImagePreview.vue +++ b/src/renderer/extensions/vueNodes/components/ImagePreview.vue @@ -176,7 +176,15 @@ const imageAltText = computed(() => `Node output ${currentIndex.value + 1}`) // Watch for URL changes and reset state watch( () => props.imageUrls, - (newUrls) => { + (newUrls, oldUrls) => { + // Only reset state if URLs actually changed (not just array reference) + const urlsChanged = + !oldUrls || + newUrls.length !== oldUrls.length || + newUrls.some((url, i) => url !== oldUrls[i]) + + if (!urlsChanged) return + // Reset current index if it's out of bounds if (currentIndex.value >= newUrls.length) { currentIndex.value = 0 @@ -188,7 +196,7 @@ watch( imageError.value = false if (newUrls.length > 0) startDelayedLoader() }, - { deep: true, immediate: true } + { immediate: true } ) // Event handlers diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue index 838e20dd0..3c6b800fc 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNode.vue +++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue @@ -551,6 +551,12 @@ const showAdvancedState = customRef((track, trigger) => { } }) +const hasVideoInput = computed(() => { + return ( + lgraphNode.value?.inputs?.some((input) => input.type === 'VIDEO') ?? false + ) +}) + const nodeMedia = computed(() => { const newOutputs = nodeOutputs.nodeOutputs[nodeOutputLocatorId.value] const node = lgraphNode.value @@ -560,13 +566,9 @@ const nodeMedia = computed(() => { const urls = nodeOutputs.getNodeImageUrls(node) if (!urls?.length) return undefined - // Determine media type from previewMediaType or fallback to input slot types - // Note: Despite the field name "images", videos are also included in outputs - // TODO: fix the backend to return videos using the videos key instead of the images key - const hasVideoInput = node.inputs?.some((input) => input.type === 'VIDEO') const type = node.previewMediaType === 'video' || - (!node.previewMediaType && hasVideoInput) + (!node.previewMediaType && hasVideoInput.value) ? 'video' : 'image' From 6c14ae6f9021ea9f58d0732cebd3584a28092b79 Mon Sep 17 00:00:00 2001 From: AustinMroz Date: Fri, 30 Jan 2026 10:56:44 -0800 Subject: [PATCH 05/61] Don't bypass subgraph contents with subgraph (#8494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When bypassing or muting a subgraph the contents are no longer bypassed along with it. This behaviour was less than ideal because it meant that toggling the bypass of a single subgraph node twice could change the behaviour of the node. It is entirely intended that a subgraph node which is bypassed does not have it's children execute. As part of testing this behaviour, it was found that nodes inside of a bypassed subgraph are still considered for execution even if boundry links are treated as disconnected. The following example would execute even if the subgraph is muted. image To resolve this, the PR does not add the contents of a subgraphNode which is muted or bypassed to the execution map. Resolves #8489 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8494-Don-t-bypass-subgraph-contents-with-subgraph-2f86d73d365081aeba8dd2990b6ba0ad) by [Unito](https://www.unito.io) --- .../canvas/useSelectedLiteGraphItems.test.ts | 11 +++--- .../canvas/useSelectedLiteGraphItems.ts | 38 +++---------------- src/utils/executionUtil.ts | 11 +++++- 3 files changed, 19 insertions(+), 41 deletions(-) diff --git a/src/composables/canvas/useSelectedLiteGraphItems.test.ts b/src/composables/canvas/useSelectedLiteGraphItems.test.ts index 8e16448f4..a8d0acdd3 100644 --- a/src/composables/canvas/useSelectedLiteGraphItems.test.ts +++ b/src/composables/canvas/useSelectedLiteGraphItems.test.ts @@ -276,7 +276,7 @@ describe('useSelectedLiteGraphItems', () => { expect(selectedNodes).toContainEqual(subNode2) }) - it('toggleSelectedNodesMode should apply unified state to subgraph children', () => { + it('toggleSelectedNodesMode should not apply state to subgraph children', () => { const { toggleSelectedNodesMode } = useSelectedLiteGraphItems() const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode const subNode2 = { id: 12, mode: LGraphEventMode.NEVER } as LGraphNode @@ -294,9 +294,8 @@ describe('useSelectedLiteGraphItems', () => { // regularNode: BYPASS -> NEVER (since BYPASS != NEVER) expect(regularNode.mode).toBe(LGraphEventMode.NEVER) - // Subgraph children get unified state (same as their parent): - // Both children should now be NEVER, regardless of their previous states - expect(subNode1.mode).toBe(LGraphEventMode.NEVER) // was ALWAYS, now NEVER + // Subgraph children do not change state + expect(subNode1.mode).toBe(LGraphEventMode.ALWAYS) // was ALWAYS, stays ALWAYS expect(subNode2.mode).toBe(LGraphEventMode.NEVER) // was NEVER, stays NEVER }) @@ -317,9 +316,9 @@ describe('useSelectedLiteGraphItems', () => { // Selected subgraph should toggle to ALWAYS (since it was already NEVER) expect(subgraphNode.mode).toBe(LGraphEventMode.ALWAYS) - // All children should also get ALWAYS (unified with parent's new state) + // All children should be unchanged expect(subNode1.mode).toBe(LGraphEventMode.ALWAYS) - expect(subNode2.mode).toBe(LGraphEventMode.ALWAYS) + expect(subNode2.mode).toBe(LGraphEventMode.BYPASS) }) }) diff --git a/src/composables/canvas/useSelectedLiteGraphItems.ts b/src/composables/canvas/useSelectedLiteGraphItems.ts index a4a93b9fb..c7777d7af 100644 --- a/src/composables/canvas/useSelectedLiteGraphItems.ts +++ b/src/composables/canvas/useSelectedLiteGraphItems.ts @@ -2,10 +2,7 @@ import type { LGraphNode, Positionable } from '@/lib/litegraph/src/litegraph' import { LGraphEventMode, Reroute } from '@/lib/litegraph/src/litegraph' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { app } from '@/scripts/app' -import { - collectFromNodes, - traverseNodesDepthFirst -} from '@/utils/graphTraversalUtil' +import { collectFromNodes } from '@/utils/graphTraversalUtil' /** * Composable for handling selected LiteGraph items filtering and operations. @@ -97,16 +94,10 @@ export function useSelectedLiteGraphItems() { } /** - * Toggle the execution mode of all selected nodes with unified subgraph behavior. + * Toggle the execution mode of all selected nodes * - * Top-level behavior (selected nodes): Standard toggle logic - * - If the selected node is already in the specified mode → set to ALWAYS - * - Otherwise → set to the specified mode - * - * Subgraph behavior (children of selected subgraph nodes): Unified state application - * - All children inherit the same mode that their parent subgraph node was set to - * - This creates predictable behavior: if you toggle a subgraph to "mute", - * ALL nodes inside become muted, regardless of their previous individual states + * - If any nodes are not already the specified node mode → all are set to specified mode + * - Otherwise → set all nodes to ALWAYS * * @param mode - The LGraphEventMode to toggle to (e.g., NEVER for mute, BYPASS for bypass) */ @@ -124,27 +115,8 @@ export function useSelectedLiteGraphItems() { ) const newModeForSelectedNode = allNodesMatch ? LGraphEventMode.ALWAYS : mode - // Process each selected node independently to determine its target state and apply to children - selectedNodeArray.forEach((selectedNode) => { - // Apply standard toggle logic to the selected node itself - + for (const selectedNode of selectedNodeArray) selectedNode.mode = newModeForSelectedNode - - // If this selected node is a subgraph, apply the same mode uniformly to all its children - // This ensures predictable behavior: all children get the same state as their parent - if (selectedNode.isSubgraphNode?.() && selectedNode.subgraph) { - traverseNodesDepthFirst([selectedNode], { - visitor: (node) => { - // Skip the parent node since we already handled it above - if (node === selectedNode) return undefined - - // Apply the parent's new mode to all children uniformly - node.mode = newModeForSelectedNode - return undefined - } - }) - } - }) } return { diff --git a/src/utils/executionUtil.ts b/src/utils/executionUtil.ts index 3f7f982c4..ea3e4e539 100644 --- a/src/utils/executionUtil.ts +++ b/src/utils/executionUtil.ts @@ -63,11 +63,18 @@ export const graphToPrompt = async ( ? new ExecutableGroupNodeDTO(node, [], nodeDtoMap) : new ExecutableNodeDTO(node, [], nodeDtoMap) + nodeDtoMap.set(dto.id, dto) + + if ( + node.mode === LGraphEventMode.NEVER || + node.mode === LGraphEventMode.BYPASS + ) { + continue + } + for (const innerNode of dto.getInnerNodes()) { nodeDtoMap.set(innerNode.id, innerNode) } - - nodeDtoMap.set(dto.id, dto) } const output: ComfyApiWorkflow = {} From 59c58379fe9d8ea16cd402215fc8059abd1e5d7f Mon Sep 17 00:00:00 2001 From: Alexander Brown Date: Fri, 30 Jan 2026 13:24:16 -0800 Subject: [PATCH 06/61] fix: migrate remaining ECMAScript private fields to TypeScript private (#8495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates remaining `#field` syntax to `private _field` for Vue reactivity compatibility. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8495-fix-migrate-remaining-ECMAScript-private-fields-to-TypeScript-private-2f86d73d365081ec87afe2273c0ff6eb) by [Unito](https://www.unito.io) Co-authored-by: Amp --- src/lib/litegraph/src/LGraph.ts | 18 +++++++++--------- src/lib/litegraph/src/LLink.ts | 6 +++--- .../litegraph/src/subgraph/SubgraphInput.ts | 8 ++++---- .../litegraph/src/subgraph/SubgraphSlotBase.ts | 8 ++++---- src/stores/nodeDefStore.ts | 4 ++-- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/lib/litegraph/src/LGraph.ts b/src/lib/litegraph/src/LGraph.ts index fd7b7c000..c567335f8 100644 --- a/src/lib/litegraph/src/LGraph.ts +++ b/src/lib/litegraph/src/LGraph.ts @@ -2466,13 +2466,13 @@ export class LGraph } } - #canvas?: LGraphCanvas + private _canvas?: LGraphCanvas get primaryCanvas(): LGraphCanvas | undefined { - return this.rootGraph.#canvas + return this.rootGraph._canvas } set primaryCanvas(canvas: LGraphCanvas) { - this.rootGraph.#canvas = canvas + this.rootGraph._canvas = canvas } load(url: string | Blob | URL | File, callback: () => void) { @@ -2541,9 +2541,9 @@ export class Subgraph /** A list of node widgets displayed in the parent graph, on the subgraph object. */ readonly widgets: ExposedWidget[] = [] - #rootGraph: LGraph + private _rootGraph: LGraph override get rootGraph(): LGraph { - return this.#rootGraph + return this._rootGraph } constructor(rootGraph: LGraph, data: ExportedSubgraph) { @@ -2551,11 +2551,11 @@ export class Subgraph super() - this.#rootGraph = rootGraph + this._rootGraph = rootGraph const cloned = structuredClone(data) this._configureBase(cloned) - this.#configureSubgraph(cloned) + this._configureSubgraph(cloned) } getIoNodeOnPos( @@ -2567,7 +2567,7 @@ export class Subgraph if (outputNode.containsPoint([x, y])) return outputNode } - #configureSubgraph( + private _configureSubgraph( data: | (ISerialisedGraph & ExportedSubgraph) | (SerialisableGraph & ExportedSubgraph) @@ -2611,7 +2611,7 @@ export class Subgraph ): boolean | undefined { const r = super.configure(data, keep_old) - this.#configureSubgraph(data) + this._configureSubgraph(data) return r } diff --git a/src/lib/litegraph/src/LLink.ts b/src/lib/litegraph/src/LLink.ts index 6ca8832a1..0e5dd98b3 100644 --- a/src/lib/litegraph/src/LLink.ts +++ b/src/lib/litegraph/src/LLink.ts @@ -119,14 +119,14 @@ export class LLink implements LinkSegment, Serialisable { /** @inheritdoc */ _dragging?: boolean - #color?: CanvasColour | null + private _color?: CanvasColour | null /** Custom colour for this link only */ public get color(): CanvasColour | null | undefined { - return this.#color + return this._color } public set color(value: CanvasColour) { - this.#color = value === '' ? null : value + this._color = value === '' ? null : value } public get isFloatingOutput(): boolean { diff --git a/src/lib/litegraph/src/subgraph/SubgraphInput.ts b/src/lib/litegraph/src/subgraph/SubgraphInput.ts index 939115513..0f143b7f6 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphInput.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphInput.ts @@ -35,14 +35,14 @@ export class SubgraphInput extends SubgraphSlot { events = new CustomEventTarget() /** The linked widget that this slot is connected to. */ - #widgetRef?: WeakRef + private _widgetRef?: WeakRef get _widget() { - return this.#widgetRef?.deref() + return this._widgetRef?.deref() } set _widget(widget) { - this.#widgetRef = widget ? new WeakRef(widget) : undefined + this._widgetRef = widget ? new WeakRef(widget) : undefined } override connect( @@ -187,7 +187,7 @@ export class SubgraphInput extends SubgraphSlot { * @returns `true` if the connection is valid, otherwise `false`. */ matchesWidget(otherWidget: IBaseWidget): boolean { - const widget = this.#widgetRef?.deref() + const widget = this._widgetRef?.deref() if (!widget) return true if ( diff --git a/src/lib/litegraph/src/subgraph/SubgraphSlotBase.ts b/src/lib/litegraph/src/subgraph/SubgraphSlotBase.ts index 7c717eaa4..4f66d1e14 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphSlotBase.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphSlotBase.ts @@ -46,7 +46,7 @@ export abstract class SubgraphSlot return LiteGraph.NODE_SLOT_HEIGHT } - readonly #pos: Point = [0, 0] + private readonly _pos: Point = [0, 0] readonly measurement: ConstrainedSize = new ConstrainedSize( SubgraphSlot.defaultHeight, @@ -67,14 +67,14 @@ export abstract class SubgraphSlot ) override get pos() { - return this.#pos + return this._pos } override set pos(value) { if (!value || value.length < 2) return - this.#pos[0] = value[0] - this.#pos[1] = value[1] + this._pos[0] = value[0] + this._pos[1] = value[1] } /** Whether this slot is connected to another slot. */ diff --git a/src/stores/nodeDefStore.ts b/src/stores/nodeDefStore.ts index 7bfa17c2c..d8cd96473 100644 --- a/src/stores/nodeDefStore.ts +++ b/src/stores/nodeDefStore.ts @@ -89,7 +89,7 @@ export class ComfyNodeDefImpl * @internal * Migrate default input options to forceInput. */ - static #migrateDefaultInput(nodeDef: ComfyNodeDefV1): ComfyNodeDefV1 { + private static _migrateDefaultInput(nodeDef: ComfyNodeDefV1): ComfyNodeDefV1 { const def = _.cloneDeep(nodeDef) def.input ??= {} // For required inputs, now we have the input socket always present. Specifying @@ -118,7 +118,7 @@ export class ComfyNodeDefImpl } constructor(def: ComfyNodeDefV1) { - const obj = ComfyNodeDefImpl.#migrateDefaultInput(def) + const obj = ComfyNodeDefImpl._migrateDefaultInput(def) /** * Assign extra fields to `this` for compatibility with group node feature. From a64c561a5fd57f705a4fda3533dc260e9d94b923 Mon Sep 17 00:00:00 2001 From: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com> Date: Fri, 30 Jan 2026 22:25:10 +0100 Subject: [PATCH 07/61] Road to No explicit any: Group 8 (part 8) test files (#8496) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR removes unsafe type assertions ("as unknown as Type") from test files and improves type safety across the codebase. ### Key Changes #### Type Safety Improvements - Removed improper `as unknown as Type` patterns from test files in Group 8 part 8 - Replaced with proper TypeScript patterns using Pinia store testing patterns - Fixed parameter shadowing issue in typeGuardUtil.test.ts (constructor → nodeConstructor) - Fixed stale mock values in useConflictDetection.test.ts using getter functions - Refactored useManagerState tests to follow proper Pinia store testing patterns with createTestingPinia ### Files Changed Test files (Group 8 part 8 - utils and manager composables): - src/utils/typeGuardUtil.test.ts - Fixed parameter shadowing - src/utils/graphTraversalUtil.test.ts - Removed unsafe type assertions - src/utils/litegraphUtil.test.ts - Improved type handling - src/workbench/extensions/manager/composables/useManagerState.test.ts - Complete rewrite using Pinia testing patterns - src/workbench/extensions/manager/composables/useConflictDetection.test.ts - Fixed stale mock values with getters - src/workbench/extensions/manager/composables/useManagerQueue.test.ts - Type safety improvements - src/workbench/extensions/manager/composables/nodePack/useMissingNodes.test.ts - Removed unsafe casts - src/workbench/extensions/manager/composables/nodePack/usePacksSelection.test.ts - Type improvements - src/workbench/extensions/manager/composables/nodePack/usePacksStatus.test.ts - Type improvements - src/workbench/extensions/manager/utils/versionUtil.test.ts - Type safety fixes Source files (minor type fixes): - src/utils/fuseUtil.ts - Type improvements - src/utils/linkFixer.ts - Type safety fixes - src/utils/syncUtil.ts - Type improvements - src/workbench/extensions/manager/composables/nodePack/useWorkflowPacks.ts - Type fix - src/workbench/extensions/manager/composables/useConflictAcknowledgment.ts - Type fix ### Testing - All TypeScript type checking passes (`pnpm typecheck`) - All affected test files pass (`pnpm test:unit`) - Linting passes without errors (`pnpm lint`) - Code formatting applied (`pnpm format`) Part of the "Road to No Explicit Any" initiative, cleaning up type casting issues from branch `fix/remove-any-types-part8`. ### Previous PRs in this series: - Part 2: #7401 - Part 3: #7935 - Part 4: #7970 - Part 5: #8064 - Part 6: #8083 - Part 7: #8092 - Part 8 Group 1: #8253 - Part 8 Group 2: #8258 - Part 8 Group 3: #8304 - Part 8 Group 4: #8314 - Part 8 Group 5: #8329 - Part 8 Group 6: #8344 - Part 8 Group 7: #8459 - Part 8 Group 8: #8496 (this PR) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8496-Road-to-No-explicit-any-Group-8-part-8-test-files-2f86d73d365081f3afdcf8d01fba81e1) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action --- src/utils/fuseUtil.ts | 8 +- src/utils/graphTraversalUtil.test.ts | 26 +- src/utils/linkFixer.ts | 11 +- src/utils/litegraphUtil.test.ts | 24 +- src/utils/syncUtil.ts | 5 +- src/utils/typeGuardUtil.test.ts | 25 +- .../nodePack/useMissingNodes.test.ts | 96 ++-- .../nodePack/usePacksSelection.test.ts | 2 +- .../nodePack/usePacksStatus.test.ts | 2 +- .../composables/nodePack/useWorkflowPacks.ts | 2 +- .../composables/useConflictAcknowledgment.ts | 2 +- .../composables/useConflictDetection.test.ts | 77 +-- .../composables/useManagerQueue.test.ts | 43 +- .../composables/useManagerState.test.ts | 491 ++++++++++-------- .../manager/utils/versionUtil.test.ts | 4 +- 15 files changed, 478 insertions(+), 340 deletions(-) diff --git a/src/utils/fuseUtil.ts b/src/utils/fuseUtil.ts index 6969bbe06..ddfbaa9a4 100644 --- a/src/utils/fuseUtil.ts +++ b/src/utils/fuseUtil.ts @@ -75,8 +75,12 @@ export interface FuseSearchable { postProcessSearchScores: (scores: SearchAuxScore) => SearchAuxScore } -function isFuseSearchable(item: any): item is FuseSearchable { - return 'postProcessSearchScores' in item +function isFuseSearchable(item: unknown): item is FuseSearchable { + return ( + typeof item === 'object' && + item !== null && + 'postProcessSearchScores' in item + ) } /** diff --git a/src/utils/graphTraversalUtil.test.ts b/src/utils/graphTraversalUtil.test.ts index 2b3f1bfbc..604ba8138 100644 --- a/src/utils/graphTraversalUtil.test.ts +++ b/src/utils/graphTraversalUtil.test.ts @@ -28,6 +28,7 @@ import { triggerCallbackOnAllNodes, visitGraphNodes } from '@/utils/graphTraversalUtil' +import { createMockLGraphNode } from './__tests__/litegraphTestUtils' // Mock node factory function createMockNode( @@ -39,13 +40,13 @@ function createMockNode( graph?: LGraph } = {} ): LGraphNode { - const node = { + const node = createMockLGraphNode({ id, isSubgraphNode: options.isSubgraph ? () => true : undefined, subgraph: options.subgraph, onExecutionStart: options.callback, graph: options.graph - } as unknown as LGraphNode + }) satisfies Partial as LGraphNode options.graph?.nodes?.push(node) return node } @@ -58,7 +59,7 @@ function createMockGraph(nodes: LGraphNode[]): LGraph { isRootGraph: true, getNodeById: (id: string | number) => nodes.find((n) => String(n.id) === String(id)) || null - } as unknown as LGraph + } satisfies Partial as LGraph } // Mock subgraph factory @@ -75,7 +76,7 @@ function createMockSubgraph( rootGraph, getNodeById: (nodeId: string | number) => nodes.find((n) => String(n.id) === String(nodeId)) || null - } as unknown as Subgraph + } satisfies Partial as Subgraph return graph } @@ -96,8 +97,8 @@ describe('graphTraversalUtil', () => { it('should return null for invalid input', () => { expect(parseExecutionId('')).toBeNull() - expect(parseExecutionId(null as any)).toBeNull() - expect(parseExecutionId(undefined as any)).toBeNull() + expect(parseExecutionId(null!)).toBeNull() + expect(parseExecutionId(undefined!)).toBeNull() }) }) @@ -415,7 +416,7 @@ describe('graphTraversalUtil', () => { // Add a title property to each node forEachNode(graph, (node) => { - ;(node as any).title = `Node ${node.id}` + node.title = `Node ${node.id}` }) expect(nodes[0]).toHaveProperty('title', 'Node 1') @@ -653,7 +654,7 @@ describe('graphTraversalUtil', () => { it('should return root graph from subgraph', () => { const rootGraph = createMockGraph([]) const subgraph = createMockSubgraph('sub-uuid', []) - ;(subgraph as any).rootGraph = rootGraph + ;(subgraph as Subgraph & { rootGraph: LGraph }).rootGraph = rootGraph expect(getRootGraph(subgraph)).toBe(rootGraph) }) @@ -662,9 +663,10 @@ describe('graphTraversalUtil', () => { const rootGraph = createMockGraph([]) const midSubgraph = createMockSubgraph('mid-uuid', []) const deepSubgraph = createMockSubgraph('deep-uuid', []) - - ;(midSubgraph as any).rootGraph = rootGraph - ;(deepSubgraph as any).rootGraph = midSubgraph + ;(midSubgraph as Subgraph & { rootGraph: LGraph }).rootGraph = rootGraph + ;( + deepSubgraph as Subgraph & { rootGraph: LGraph | Subgraph } + ).rootGraph = midSubgraph expect(getRootGraph(deepSubgraph)).toBe(rootGraph) }) @@ -726,7 +728,7 @@ describe('graphTraversalUtil', () => { const graph = createMockGraph(nodes) forEachSubgraphNode(graph, subgraphId, (node) => { - ;(node as any).title = 'Updated Title' + node.title = 'Updated Title' }) expect(nodes[0]).toHaveProperty('title', 'Updated Title') diff --git a/src/utils/linkFixer.ts b/src/utils/linkFixer.ts index 64fdd598f..065934224 100644 --- a/src/utils/linkFixer.ts +++ b/src/utils/linkFixer.ts @@ -24,6 +24,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ +import type { INodeOutputSlot } from '@/lib/litegraph/src/interfaces' import type { NodeId } from '@/lib/litegraph/src/LGraphNode' import type { SerialisedLLinkArray } from '@/lib/litegraph/src/LLink' import type { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph' @@ -79,12 +80,12 @@ export function fixBadLinks( options: { fix?: boolean silent?: boolean - logger?: { log: (...args: any[]) => void } + logger?: { log: (...args: unknown[]) => void } } = {} ): BadLinksData { const { fix = false, silent = false, logger: _logger = console } = options const logger = { - log: (...args: any[]) => { + log: (...args: unknown[]) => { if (!silent) { _logger.log(...args) } @@ -166,7 +167,9 @@ export function fixBadLinks( patchedNode['outputs']![slot]!['links'].push(linkId) if (fix) { node.outputs = node.outputs || [] - node.outputs[slot] = node.outputs[slot] || ({} as any) + node.outputs[slot] = + node.outputs[slot] || + ({} satisfies Partial as INodeOutputSlot) node.outputs[slot]!.links = node.outputs[slot]!.links || [] node.outputs[slot]!.links!.push(linkId) } @@ -428,7 +431,7 @@ export function fixBadLinks( (l) => l && (l[0] === data.deletedLinks[i] || - (l as any).id === data.deletedLinks[i]) + ('id' in l && l.id === data.deletedLinks[i])) ) if (idx === -1) { logger.log(`INDEX NOT FOUND for #${data.deletedLinks[i]}`) diff --git a/src/utils/litegraphUtil.test.ts b/src/utils/litegraphUtil.test.ts index 36a6692ee..50a6ef821 100644 --- a/src/utils/litegraphUtil.test.ts +++ b/src/utils/litegraphUtil.test.ts @@ -26,10 +26,10 @@ describe('migrateWidgetsValues', () => { } } - const widgets: IWidget[] = [ + const widgets = [ { name: 'normalInput', type: 'number' }, { name: 'anotherNormal', type: 'number' } - ] as unknown as IWidget[] + ] as Partial[] as IWidget[] const widgetValues = [42, 'dummy value', 3.14] @@ -56,7 +56,7 @@ describe('migrateWidgetsValues', () => { it('should handle empty widgets and values', () => { const inputDefs: Record = {} const widgets: IWidget[] = [] - const widgetValues: any[] = [] + const widgetValues: unknown[] = [] const result = migrateWidgetsValues(inputDefs, widgets, widgetValues) expect(result).toEqual([]) @@ -79,10 +79,10 @@ describe('migrateWidgetsValues', () => { } } - const widgets: IWidget[] = [ + const widgets = [ { name: 'first', type: 'number' }, { name: 'last', type: 'number' } - ] as unknown as IWidget[] + ] as Partial[] as IWidget[] const widgetValues = ['first value', 'dummy', 'last value'] @@ -93,7 +93,8 @@ describe('migrateWidgetsValues', () => { describe('compressWidgetInputSlots', () => { it('should remove unconnected widget input slots', () => { - const graph: ISerialisedGraph = { + // Using partial mock - only including properties needed for test + const graph = { nodes: [ { id: 1, @@ -112,7 +113,7 @@ describe('compressWidgetInputSlots', () => { } ], links: [[2, 1, 0, 1, 0, 'INT']] - } as unknown as ISerialisedGraph + } as Partial as ISerialisedGraph compressWidgetInputSlots(graph) @@ -122,7 +123,7 @@ describe('compressWidgetInputSlots', () => { }) it('should update link target slots correctly', () => { - const graph: ISerialisedGraph = { + const graph = { nodes: [ { id: 1, @@ -144,7 +145,7 @@ describe('compressWidgetInputSlots', () => { [2, 1, 0, 1, 1, 'INT'], [3, 1, 0, 1, 2, 'INT'] ] - } as unknown as ISerialisedGraph + } as Partial as ISerialisedGraph compressWidgetInputSlots(graph) @@ -160,10 +161,11 @@ describe('compressWidgetInputSlots', () => { }) it('should handle graphs with no nodes gracefully', () => { - const graph: ISerialisedGraph = { + // Using partial mock - only including properties needed for test + const graph = { nodes: [], links: [] - } as unknown as ISerialisedGraph + } as Partial as ISerialisedGraph compressWidgetInputSlots(graph) diff --git a/src/utils/syncUtil.ts b/src/utils/syncUtil.ts index c60583b9b..f69474603 100644 --- a/src/utils/syncUtil.ts +++ b/src/utils/syncUtil.ts @@ -1,4 +1,5 @@ import { api } from '@/scripts/api' +import type { UserDataFullInfo } from '@/schemas/apiSchema' /** * Sync entities from the API to the entityByPath map. @@ -11,8 +12,8 @@ import { api } from '@/scripts/api' export async function syncEntities( dir: string, entityByPath: Record, - createEntity: (file: any) => T, - updateEntity: (entity: T, file: any) => void, + createEntity: (file: UserDataFullInfo & { path: string }) => T, + updateEntity: (entity: T, file: UserDataFullInfo & { path: string }) => void, exclude: (file: T) => boolean = () => false ) { const files = (await api.listUserDataFullInfo(dir)).map((file) => ({ diff --git a/src/utils/typeGuardUtil.test.ts b/src/utils/typeGuardUtil.test.ts index 9c0689271..9a58655b2 100644 --- a/src/utils/typeGuardUtil.test.ts +++ b/src/utils/typeGuardUtil.test.ts @@ -1,43 +1,42 @@ import { describe, expect, it } from 'vitest' +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import { isSubgraphIoNode } from '@/utils/typeGuardUtil' +type NodeConstructor = { comfyClass?: string } + +function createMockNode(nodeConstructor?: NodeConstructor): LGraphNode { + return { constructor: nodeConstructor } as Partial as LGraphNode +} + describe('typeGuardUtil', () => { describe('isSubgraphIoNode', () => { it('should identify SubgraphInputNode as IO node', () => { - const node = { - constructor: { comfyClass: 'SubgraphInputNode' } - } as any + const node = createMockNode({ comfyClass: 'SubgraphInputNode' }) expect(isSubgraphIoNode(node)).toBe(true) }) it('should identify SubgraphOutputNode as IO node', () => { - const node = { - constructor: { comfyClass: 'SubgraphOutputNode' } - } as any + const node = createMockNode({ comfyClass: 'SubgraphOutputNode' }) expect(isSubgraphIoNode(node)).toBe(true) }) it('should not identify regular nodes as IO nodes', () => { - const node = { - constructor: { comfyClass: 'CLIPTextEncode' } - } as any + const node = createMockNode({ comfyClass: 'CLIPTextEncode' }) expect(isSubgraphIoNode(node)).toBe(false) }) it('should handle nodes without constructor', () => { - const node = {} as any + const node = createMockNode(undefined) expect(isSubgraphIoNode(node)).toBe(false) }) it('should handle nodes without comfyClass', () => { - const node = { - constructor: {} - } as any + const node = createMockNode({}) expect(isSubgraphIoNode(node)).toBe(false) }) diff --git a/src/workbench/extensions/manager/composables/nodePack/useMissingNodes.test.ts b/src/workbench/extensions/manager/composables/nodePack/useMissingNodes.test.ts index a467bf098..371d36b0b 100644 --- a/src/workbench/extensions/manager/composables/nodePack/useMissingNodes.test.ts +++ b/src/workbench/extensions/manager/composables/nodePack/useMissingNodes.test.ts @@ -2,17 +2,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick, ref } from 'vue' import type { LGraphNode, LGraph } from '@/lib/litegraph/src/litegraph' +import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' import { useNodeDefStore } from '@/stores/nodeDefStore' import { collectAllNodes } from '@/utils/graphTraversalUtil' import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes' import { useWorkflowPacks } from '@/workbench/extensions/manager/composables/nodePack/useWorkflowPacks' +import type { WorkflowPack } from '@/workbench/extensions/manager/composables/nodePack/useWorkflowPacks' import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore' +import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils' vi.mock('@vueuse/core', async () => { const actual = await vi.importActual('@vueuse/core') return { ...actual, - createSharedComposable: any>(fn: Fn) => fn + createSharedComposable: unknown>( + fn: Fn + ) => fn } }) @@ -81,11 +86,11 @@ describe('useMissingNodes', () => { // Default setup: pack-3 is installed, others are not mockIsPackInstalled.mockImplementation((id: string) => id === 'pack-3') - // @ts-expect-error - Mocking partial ComfyManagerStore for testing. - // We only need isPackInstalled method for these tests. mockUseComfyManagerStore.mockReturnValue({ isPackInstalled: mockIsPackInstalled - }) + } as Partial> as ReturnType< + typeof useComfyManagerStore + >) mockUseWorkflowPacks.mockReturnValue({ workflowPacks: ref([]), @@ -97,11 +102,11 @@ describe('useMissingNodes', () => { }) // Reset node def store mock - // @ts-expect-error - Mocking partial NodeDefStore for testing. - // We only need nodeDefsByName for these tests. mockUseNodeDefStore.mockReturnValue({ nodeDefsByName: {} - }) + } as Partial> as ReturnType< + typeof useNodeDefStore + >) // Reset app.rootGraph.nodes mockApp.rootGraph = { nodes: [] } @@ -249,7 +254,7 @@ describe('useMissingNodes', () => { describe('reactivity', () => { it('updates when workflow packs change', async () => { - const workflowPacksRef = ref([]) + const workflowPacksRef = ref([]) mockUseWorkflowPacks.mockReturnValue({ workflowPacks: workflowPacksRef, isLoading: ref(false), @@ -265,8 +270,8 @@ describe('useMissingNodes', () => { expect(missingNodePacks.value).toEqual([]) // Update workflow packs - // @ts-expect-error - mockWorkflowPacks is a simplified version without full WorkflowPack interface. - workflowPacksRef.value = mockWorkflowPacks + + workflowPacksRef.value = mockWorkflowPacks as unknown as WorkflowPack[] await nextTick() // Should update missing packs (2 missing since pack-3 is installed) @@ -302,7 +307,7 @@ describe('useMissingNodes', () => { describe('missing core nodes detection', () => { const createMockNode = (type: string, packId?: string, version?: string) => - ({ + createMockLGraphNode({ type, properties: { cnr_id: packId, ver: version }, id: 1, @@ -314,7 +319,7 @@ describe('useMissingNodes', () => { mode: 0, inputs: [], outputs: [] - }) as unknown as LGraphNode + }) it('identifies missing core nodes not in nodeDefStore', () => { const coreNode1 = createMockNode('CoreNode1', 'comfy-core', '1.2.0') @@ -323,13 +328,16 @@ describe('useMissingNodes', () => { // Mock collectAllNodes to return only the filtered nodes (missing core nodes) mockCollectAllNodes.mockReturnValue([coreNode1, coreNode2]) + const namedNode = { + name: 'RegisteredNode' + } as Partial as ComfyNodeDefImpl mockUseNodeDefStore.mockReturnValue({ nodeDefsByName: { - // @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing. - // Only including required properties for our test assertions. - RegisteredNode: { name: 'RegisteredNode' } + RegisteredNode: namedNode } - }) + } as Partial> as ReturnType< + typeof useNodeDefStore + >) const { missingCoreNodes } = useMissingNodes() @@ -347,10 +355,11 @@ describe('useMissingNodes', () => { // Mock collectAllNodes to return these nodes mockCollectAllNodes.mockReturnValue([node120, node130, nodeNoVer]) - // @ts-expect-error - Mocking partial NodeDefStore for testing. mockUseNodeDefStore.mockReturnValue({ nodeDefsByName: {} - }) + } as Partial> as ReturnType< + typeof useNodeDefStore + >) const { missingCoreNodes } = useMissingNodes() @@ -366,10 +375,11 @@ describe('useMissingNodes', () => { // Mock collectAllNodes to return only the filtered nodes (core nodes only) mockCollectAllNodes.mockReturnValue([coreNode]) - // @ts-expect-error - Mocking partial NodeDefStore for testing. mockUseNodeDefStore.mockReturnValue({ nodeDefsByName: {} - }) + } as Partial> as ReturnType< + typeof useNodeDefStore + >) const { missingCoreNodes } = useMissingNodes() @@ -384,13 +394,16 @@ describe('useMissingNodes', () => { mockUseNodeDefStore.mockReturnValue({ nodeDefsByName: { - // @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing. - // Only including required properties for our test assertions. - RegisteredNode1: { name: 'RegisteredNode1' }, - // @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing. - RegisteredNode2: { name: 'RegisteredNode2' } + RegisteredNode1: { + name: 'RegisteredNode1' + } as Partial as ComfyNodeDefImpl, + RegisteredNode2: { + name: 'RegisteredNode2' + } as Partial as ComfyNodeDefImpl } - }) + } as Partial> as ReturnType< + typeof useNodeDefStore + >) const { missingCoreNodes } = useMissingNodes() @@ -404,9 +417,7 @@ describe('useMissingNodes', () => { packId?: string, version?: string ): LGraphNode => - // @ts-expect-error - Creating a partial mock of LGraphNode for testing. - // We only need specific properties for our tests, not the full LGraphNode interface. - ({ + createMockLGraphNode({ type, properties: { cnr_id: packId, ver: version }, id: 1, @@ -441,10 +452,11 @@ describe('useMissingNodes', () => { ]) // Mock none of the nodes as registered - // @ts-expect-error - Mocking partial NodeDefStore for testing. mockUseNodeDefStore.mockReturnValue({ nodeDefsByName: {} - }) + } as Partial> as ReturnType< + typeof useNodeDefStore + >) const { missingCoreNodes } = useMissingNodes() @@ -482,10 +494,13 @@ describe('useMissingNodes', () => { mockUseNodeDefStore.mockReturnValue({ nodeDefsByName: { - // @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing. - RegisteredCore: { name: 'RegisteredCore' } + RegisteredCore: { + name: 'RegisteredCore' + } as Partial as ComfyNodeDefImpl } - }) + } as Partial> as ReturnType< + typeof useNodeDefStore + >) let capturedFilterFunction: ((node: LGraphNode) => boolean) | undefined @@ -561,12 +576,12 @@ describe('useMissingNodes', () => { nodes: [subgraphMissingNode, subgraphRegisteredNode] } - const mockSubgraphNode = { + const mockSubgraphNode = createMockLGraphNode({ isSubgraphNode: () => true, subgraph: mockSubgraph, type: 'SubgraphContainer', properties: { cnr_id: 'custom-pack' } - } as unknown as LGraphNode + }) const mockMainGraph = { nodes: [mainMissingNode, mockSubgraphNode] @@ -576,10 +591,13 @@ describe('useMissingNodes', () => { mockUseNodeDefStore.mockReturnValue({ nodeDefsByName: { - // @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing. - SubgraphRegistered: { name: 'SubgraphRegistered' } + SubgraphRegistered: { + name: 'SubgraphRegistered' + } as Partial as ComfyNodeDefImpl } - }) + } as Partial> as ReturnType< + typeof useNodeDefStore + >) const { missingCoreNodes } = useMissingNodes() diff --git a/src/workbench/extensions/manager/composables/nodePack/usePacksSelection.test.ts b/src/workbench/extensions/manager/composables/nodePack/usePacksSelection.test.ts index 3a17a1ba1..807cbeb38 100644 --- a/src/workbench/extensions/manager/composables/nodePack/usePacksSelection.test.ts +++ b/src/workbench/extensions/manager/composables/nodePack/usePacksSelection.test.ts @@ -324,7 +324,7 @@ describe('usePacksSelection', () => { describe('edge cases', () => { it('should handle packs with undefined ids', () => { const nodePacks = ref([ - { ...createMockPack('pack1'), id: undefined as any }, + { ...createMockPack('pack1'), id: undefined }, createMockPack('pack2') ]) diff --git a/src/workbench/extensions/manager/composables/nodePack/usePacksStatus.test.ts b/src/workbench/extensions/manager/composables/nodePack/usePacksStatus.test.ts index 5977196ce..d630e5eb8 100644 --- a/src/workbench/extensions/manager/composables/nodePack/usePacksStatus.test.ts +++ b/src/workbench/extensions/manager/composables/nodePack/usePacksStatus.test.ts @@ -108,7 +108,7 @@ describe('usePacksStatus', () => { it('should handle packs without ids', () => { const nodePacks = ref([ - { ...createMockPack('pack1'), id: undefined as any }, + { ...createMockPack('pack1'), id: undefined }, createMockPack('pack2') ]) diff --git a/src/workbench/extensions/manager/composables/nodePack/useWorkflowPacks.ts b/src/workbench/extensions/manager/composables/nodePack/useWorkflowPacks.ts index a56c908ef..5fa3fcf38 100644 --- a/src/workbench/extensions/manager/composables/nodePack/useWorkflowPacks.ts +++ b/src/workbench/extensions/manager/composables/nodePack/useWorkflowPacks.ts @@ -11,7 +11,7 @@ import type { components } from '@/types/comfyRegistryTypes' import { mapAllNodes } from '@/utils/graphTraversalUtil' import { useNodePacks } from '@/workbench/extensions/manager/composables/nodePack/useNodePacks' -type WorkflowPack = { +export type WorkflowPack = { id: | ComfyWorkflowJSON['nodes'][number]['properties']['cnr_id'] | ComfyWorkflowJSON['nodes'][number]['properties']['aux_id'] diff --git a/src/workbench/extensions/manager/composables/useConflictAcknowledgment.ts b/src/workbench/extensions/manager/composables/useConflictAcknowledgment.ts index 0abec73e4..d79b5086e 100644 --- a/src/workbench/extensions/manager/composables/useConflictAcknowledgment.ts +++ b/src/workbench/extensions/manager/composables/useConflictAcknowledgment.ts @@ -15,7 +15,7 @@ const STORAGE_KEYS = { /** * Interface for conflict acknowledgment state */ -interface ConflictAcknowledgmentState { +export interface ConflictAcknowledgmentState { modal_dismissed: boolean red_dot_dismissed: boolean warning_banner_dismissed: boolean diff --git a/src/workbench/extensions/manager/composables/useConflictDetection.test.ts b/src/workbench/extensions/manager/composables/useConflictDetection.test.ts index 46aec9d2c..fca5e7de6 100644 --- a/src/workbench/extensions/manager/composables/useConflictDetection.test.ts +++ b/src/workbench/extensions/manager/composables/useConflictDetection.test.ts @@ -8,6 +8,7 @@ import { useSystemStatsStore } from '@/stores/systemStatsStore' import type { components } from '@/types/comfyRegistryTypes' import { useInstalledPacks } from '@/workbench/extensions/manager/composables/nodePack/useInstalledPacks' import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment' +import type { ConflictAcknowledgmentState } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment' import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection' import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService' import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore' @@ -121,13 +122,17 @@ describe('useConflictDetection', () => { getImportFailInfoBulk: vi.fn(), isLoading: ref(false), error: ref(null) - } as unknown as ReturnType + } as Partial> as ReturnType< + typeof useComfyManagerService + > const mockRegistryService = { getBulkNodeVersions: vi.fn(), isLoading: ref(false), error: ref(null) - } as unknown as ReturnType + } as Partial> as ReturnType< + typeof useComfyRegistryService + > // Create a ref that can be modified in tests const mockInstalledPacksWithVersions = ref<{ id: string; version: string }[]>( @@ -143,35 +148,43 @@ describe('useConflictDetection', () => { isReady: ref(false), isLoading: ref(false), error: ref(null) - } as unknown as ReturnType + } as Partial> as ReturnType< + typeof useInstalledPacks + > const mockManagerStore = { isPackEnabled: vi.fn() - } as unknown as ReturnType + } as Partial> as ReturnType< + typeof useComfyManagerStore + > // Create refs that can be used to control computed properties - const mockConflictedPackages = ref([]) + let mockConflictedPackages: ConflictDetectionResult[] = [] const mockConflictStore = { - hasConflicts: computed(() => - mockConflictedPackages.value.some((p) => p.has_conflict) - ), - conflictedPackages: mockConflictedPackages, - bannedPackages: computed(() => - mockConflictedPackages.value.filter((p) => + get hasConflicts() { + return mockConflictedPackages.some((p) => p.has_conflict) + }, + get conflictedPackages() { + return mockConflictedPackages + }, + get bannedPackages() { + return mockConflictedPackages.filter((p) => p.conflicts?.some((c) => c.type === 'banned') ) - ), - securityPendingPackages: computed(() => - mockConflictedPackages.value.filter((p) => + }, + get securityPendingPackages() { + return mockConflictedPackages.filter((p) => p.conflicts?.some((c) => c.type === 'pending') ) - ), + }, setConflictedPackages: vi.fn(), clearConflicts: vi.fn() - } as unknown as ReturnType + } as Partial> as ReturnType< + typeof useConflictDetectionStore + > - const mockIsInitialized = ref(true) + const mockIsInitialized = true const mockSystemStatsStore = { systemStats: { system: { @@ -199,26 +212,26 @@ describe('useConflictDetection', () => { ] }, isInitialized: mockIsInitialized, - $state: {} as never, - $patch: vi.fn(), - $reset: vi.fn(), - $subscribe: vi.fn(), - $onAction: vi.fn(), - $dispose: vi.fn(), - $id: 'systemStats', + _customProperties: new Set() - } as unknown as ReturnType + } as Partial> as ReturnType< + typeof useSystemStatsStore + > const mockAcknowledgment = { checkComfyUIVersionChange: vi.fn(), - acknowledgmentState: computed(() => ({})), + acknowledgmentState: computed( + () => ({}) as Partial + ), shouldShowConflictModal: computed(() => false), shouldShowRedDot: computed(() => false), shouldShowManagerBanner: computed(() => false), dismissRedDotNotification: vi.fn(), dismissWarningBanner: vi.fn(), markConflictsAsSeen: vi.fn() - } as unknown as ReturnType + } as Partial> as ReturnType< + typeof useConflictAcknowledgment + > beforeEach(() => { vi.clearAllMocks() @@ -249,7 +262,7 @@ describe('useConflictDetection', () => { // Reset the installedPacksWithVersions data mockInstalledPacksWithVersions.value = [] // Reset conflicted packages - mockConflictedPackages.value = [] + mockConflictedPackages = [] }) afterEach(() => { @@ -414,7 +427,7 @@ describe('useConflictDetection', () => { error: 'Import error', name: 'fail-pack', path: '/path/to/pack' - } as any // The actual API returns different structure than types + } as { error?: string; traceback?: string } | null // The actual API returns different structure than types }) // Mock registry response for the package @@ -437,7 +450,7 @@ describe('useConflictDetection', () => { describe('computed properties', () => { it('should expose conflict status from store', () => { - mockConflictedPackages.value = [ + mockConflictedPackages = [ { package_id: 'test', package_name: 'Test', @@ -450,8 +463,8 @@ describe('useConflictDetection', () => { useConflictDetection() // The hasConflicts computed should be true since we have a conflict - expect(mockConflictedPackages.value).toHaveLength(1) - expect(mockConflictedPackages.value[0].has_conflict).toBe(true) + expect(mockConflictedPackages).toHaveLength(1) + expect(mockConflictedPackages[0].has_conflict).toBe(true) }) }) diff --git a/src/workbench/extensions/manager/composables/useManagerQueue.test.ts b/src/workbench/extensions/manager/composables/useManagerQueue.test.ts index 740a1e5cf..67a2f19e4 100644 --- a/src/workbench/extensions/manager/composables/useManagerQueue.test.ts +++ b/src/workbench/extensions/manager/composables/useManagerQueue.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Ref } from 'vue' import { ref } from 'vue' import { useManagerQueue } from '@/workbench/extensions/manager/composables/useManagerQueue' @@ -22,9 +23,11 @@ type ManagerTaskHistory = Record< type ManagerTaskQueue = components['schemas']['TaskStateMessage'] describe('useManagerQueue', () => { - let taskHistory: any - let taskQueue: any - let installedPacks: any + let taskHistory: Ref + let taskQueue: Ref + let installedPacks: Ref< + Record + > const createManagerQueue = () => { taskHistory = ref({}) @@ -67,14 +70,28 @@ describe('useManagerQueue', () => { { ui_id: 'task1', client_id: 'test-client-id', - task_name: 'Installing pack1' + kind: 'install', + params: { + id: 'pack1', + version: '1.0.0', + selected_version: '1.0.0', + mode: 'remote' as const, + channel: 'default' as const + } } ] taskQueue.value.pending_queue = [ { ui_id: 'task2', client_id: 'test-client-id', - task_name: 'Installing pack2' + kind: 'install', + params: { + id: 'pack2', + version: '1.0.0', + selected_version: '1.0.0', + mode: 'remote' as const, + channel: 'default' as const + } } ] @@ -101,12 +118,18 @@ describe('useManagerQueue', () => { task1: { ui_id: 'task1', client_id: 'test-client-id', - status: { status_str: 'success', completed: true } + kind: 'install', + timestamp: '2024-01-01T00:00:00Z', + result: 'success', + status: { status_str: 'success', completed: true, messages: [] } }, task2: { ui_id: 'task2', client_id: 'test-client-id', - status: { status_str: 'success', completed: true } + kind: 'install', + timestamp: '2024-01-01T00:00:00Z', + result: 'success', + status: { status_str: 'success', completed: true, messages: [] } } } @@ -198,12 +221,12 @@ describe('useManagerQueue', () => { it('handles empty installed_packs gracefully', () => { const queue = createManagerQueue() - const mockState: any = { + const mockState = { history: {}, running_queue: [], pending_queue: [], - installed_packs: undefined - } + installed_packs: undefined! + } satisfies Partial as ManagerTaskQueue // Just call the function - if it throws, the test will fail automatically queue.updateTaskState(mockState) diff --git a/src/workbench/extensions/manager/composables/useManagerState.test.ts b/src/workbench/extensions/manager/composables/useManagerState.test.ts index 1ba41d107..710ea6d32 100644 --- a/src/workbench/extensions/manager/composables/useManagerState.test.ts +++ b/src/workbench/extensions/manager/composables/useManagerState.test.ts @@ -1,45 +1,45 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ref } from 'vue' -import { useFeatureFlags } from '@/composables/useFeatureFlags' import { api } from '@/scripts/api' -import { useExtensionStore } from '@/stores/extensionStore' import { useSystemStatsStore } from '@/stores/systemStatsStore' import { ManagerUIState, useManagerState } from '@/workbench/extensions/manager/composables/useManagerState' -// Mock dependencies +// Mock dependencies that are not stores vi.mock('@/scripts/api', () => ({ api: { getClientFeatureFlags: vi.fn(), - getServerFeature: vi.fn() + getServerFeature: vi.fn(), + getSystemStats: vi.fn() } })) -vi.mock('@/composables/useFeatureFlags', () => ({ - useFeatureFlags: vi.fn(() => ({ - flags: { supportsManagerV4: false }, - featureFlag: vi.fn() - })) -})) +vi.mock('@/composables/useFeatureFlags', () => { + const featureFlag = vi.fn() + return { + useFeatureFlags: vi.fn(() => ({ + flags: { supportsManagerV4: false }, + featureFlag + })) + } +}) -vi.mock('@/stores/extensionStore', () => ({ - useExtensionStore: vi.fn() -})) - -vi.mock('@/stores/systemStatsStore', () => ({ - useSystemStatsStore: vi.fn() -})) - -vi.mock('@/services/dialogService', () => ({ - useDialogService: vi.fn(() => ({ - showManagerPopup: vi.fn(), - showLegacyManagerPopup: vi.fn(), - showSettingsDialog: vi.fn() - })) -})) +vi.mock('@/services/dialogService', () => { + const showManagerPopup = vi.fn() + const showLegacyManagerPopup = vi.fn() + const showSettingsDialog = vi.fn() + return { + useDialogService: vi.fn(() => ({ + showManagerPopup, + showLegacyManagerPopup, + showSettingsDialog + })) + } +}) vi.mock('@/stores/commandStore', () => ({ useCommandStore: vi.fn(() => ({ @@ -47,198 +47,223 @@ vi.mock('@/stores/commandStore', () => ({ })) })) -vi.mock('@/stores/toastStore', () => ({ - useToastStore: vi.fn(() => ({ - add: vi.fn() - })) -})) +vi.mock('@/platform/updates/common/toastStore', () => { + const add = vi.fn() + return { + useToastStore: vi.fn(() => ({ + add + })) + } +}) -vi.mock('@/workbench/extensions/manager/composables/useManagerDialog', () => ({ - useManagerDialog: vi.fn(() => ({ - show: vi.fn(), - hide: vi.fn() - })) -})) +vi.mock('@/workbench/extensions/manager/composables/useManagerDialog', () => { + const show = vi.fn() + const hide = vi.fn() + return { + useManagerDialog: vi.fn(() => ({ + show, + hide + })) + } +}) describe('useManagerState', () => { + let systemStatsStore: ReturnType + beforeEach(() => { - vi.clearAllMocks() + // Create a fresh testing pinia and activate it for each test + setActivePinia( + createTestingPinia({ + stubActions: false, + createSpy: vi.fn + }) + ) + + // Initialize stores + systemStatsStore = useSystemStatsStore() + + // Reset all mocks + vi.resetAllMocks() + + // Set default mock returns + vi.mocked(api.getClientFeatureFlags).mockReturnValue({}) + vi.mocked(api.getServerFeature).mockReturnValue(undefined) }) describe('managerUIState property', () => { it('should return DISABLED state when --enable-manager is NOT present', () => { - vi.mocked(useSystemStatsStore).mockReturnValue({ - systemStats: ref({ - system: { argv: ['python', 'main.py'] } // No --enable-manager flag - }), - isInitialized: ref(true) - } as any) - vi.mocked(api.getClientFeatureFlags).mockReturnValue({}) - vi.mocked(useExtensionStore).mockReturnValue({ - extensions: [] - } as any) + // Set up store state + systemStatsStore.$patch({ + systemStats: { + system: { + os: 'Test OS', + python_version: '3.10', + embedded_python: false, + comfyui_version: '1.0.0', + pytorch_version: '2.0.0', + argv: ['python', 'main.py'], // No --enable-manager flag + ram_total: 16000000000, + ram_free: 8000000000 + }, + devices: [] + }, + isInitialized: true + }) const managerState = useManagerState() - expect(managerState.managerUIState.value).toBe(ManagerUIState.DISABLED) }) it('should return LEGACY_UI state when --enable-manager-legacy-ui is present', () => { - vi.mocked(useSystemStatsStore).mockReturnValue({ - systemStats: ref({ + // Set up store state + systemStatsStore.$patch({ + systemStats: { system: { + os: 'Test OS', + python_version: '3.10', + embedded_python: false, + comfyui_version: '1.0.0', + pytorch_version: '2.0.0', argv: [ 'python', 'main.py', '--enable-manager', '--enable-manager-legacy-ui' - ] - } // Both flags needed - }), - isInitialized: ref(true) - } as any) - vi.mocked(api.getClientFeatureFlags).mockReturnValue({}) - vi.mocked(useExtensionStore).mockReturnValue({ - extensions: [] - } as any) + ], + ram_total: 16000000000, + ram_free: 8000000000 + }, + devices: [] + }, + isInitialized: true + }) const managerState = useManagerState() - expect(managerState.managerUIState.value).toBe(ManagerUIState.LEGACY_UI) }) it('should return NEW_UI state when client and server both support v4', () => { - vi.mocked(useSystemStatsStore).mockReturnValue({ - systemStats: ref({ - system: { argv: ['python', 'main.py', '--enable-manager'] } - }), // Need --enable-manager - isInitialized: ref(true) - } as any) + // Set up store state + systemStatsStore.$patch({ + systemStats: { + system: { + os: 'Test OS', + python_version: '3.10', + embedded_python: false, + comfyui_version: '1.0.0', + pytorch_version: '2.0.0', + argv: ['python', 'main.py', '--enable-manager'], + ram_total: 16000000000, + ram_free: 8000000000 + }, + devices: [] + }, + isInitialized: true + }) + vi.mocked(api.getClientFeatureFlags).mockReturnValue({ supports_manager_v4_ui: true }) vi.mocked(api.getServerFeature).mockReturnValue(true) - vi.mocked(useFeatureFlags).mockReturnValue({ - flags: { supportsManagerV4: true }, - featureFlag: vi.fn() - } as any) - vi.mocked(useExtensionStore).mockReturnValue({ - extensions: [] - } as any) const managerState = useManagerState() - expect(managerState.managerUIState.value).toBe(ManagerUIState.NEW_UI) }) it('should return LEGACY_UI state when server supports v4 but client does not', () => { - vi.mocked(useSystemStatsStore).mockReturnValue({ - systemStats: ref({ - system: { argv: ['python', 'main.py', '--enable-manager'] } - }), // Need --enable-manager - isInitialized: ref(true) - } as any) + // Set up store state + systemStatsStore.$patch({ + systemStats: { + system: { + os: 'Test OS', + python_version: '3.10', + embedded_python: false, + comfyui_version: '1.0.0', + pytorch_version: '2.0.0', + argv: ['python', 'main.py', '--enable-manager'], + ram_total: 16000000000, + ram_free: 8000000000 + }, + devices: [] + }, + isInitialized: true + }) + vi.mocked(api.getClientFeatureFlags).mockReturnValue({ supports_manager_v4_ui: false }) vi.mocked(api.getServerFeature).mockReturnValue(true) - vi.mocked(useFeatureFlags).mockReturnValue({ - flags: { supportsManagerV4: true }, - featureFlag: vi.fn() - } as any) - vi.mocked(useExtensionStore).mockReturnValue({ - extensions: [] - } as any) const managerState = useManagerState() - expect(managerState.managerUIState.value).toBe(ManagerUIState.LEGACY_UI) }) - it('should return LEGACY_UI state when legacy manager extension exists', () => { - vi.mocked(useSystemStatsStore).mockReturnValue({ - systemStats: ref({ - system: { argv: ['python', 'main.py', '--enable-manager'] } - }), // Need --enable-manager - isInitialized: ref(true) - } as any) + it('should return LEGACY_UI state when server does not support v4', () => { + // Set up store state + systemStatsStore.$patch({ + systemStats: { + system: { + os: 'Test OS', + python_version: '3.10', + embedded_python: false, + comfyui_version: '1.0.0', + pytorch_version: '2.0.0', + argv: ['python', 'main.py', '--enable-manager'], + ram_total: 16000000000, + ram_free: 8000000000 + }, + devices: [] + }, + isInitialized: true + }) + vi.mocked(api.getClientFeatureFlags).mockReturnValue({}) - vi.mocked(useFeatureFlags).mockReturnValue({ - flags: { supportsManagerV4: false }, - featureFlag: vi.fn() - } as any) - vi.mocked(useExtensionStore).mockReturnValue({ - extensions: [{ name: 'Comfy.CustomNodesManager' }] - } as any) + vi.mocked(api.getServerFeature).mockReturnValue(false) const managerState = useManagerState() - expect(managerState.managerUIState.value).toBe(ManagerUIState.LEGACY_UI) }) it('should return NEW_UI state when server feature flags are undefined', () => { - vi.mocked(useSystemStatsStore).mockReturnValue({ - systemStats: ref({ - system: { argv: ['python', 'main.py', '--enable-manager'] } - }), // Need --enable-manager - isInitialized: ref(true) - } as any) + // Set up store state + systemStatsStore.$patch({ + systemStats: { + system: { + os: 'Test OS', + python_version: '3.10', + embedded_python: false, + comfyui_version: '1.0.0', + pytorch_version: '2.0.0', + argv: ['python', 'main.py', '--enable-manager'], + ram_total: 16000000000, + ram_free: 8000000000 + }, + devices: [] + }, + isInitialized: true + }) + vi.mocked(api.getClientFeatureFlags).mockReturnValue({}) vi.mocked(api.getServerFeature).mockReturnValue(undefined) - vi.mocked(useFeatureFlags).mockReturnValue({ - flags: { supportsManagerV4: undefined }, - featureFlag: vi.fn() - } as any) - vi.mocked(useExtensionStore).mockReturnValue({ - extensions: [] - } as any) const managerState = useManagerState() - + // When server feature flags haven't loaded yet, default to NEW_UI expect(managerState.managerUIState.value).toBe(ManagerUIState.NEW_UI) }) - it('should return LEGACY_UI state when server does not support v4', () => { - vi.mocked(useSystemStatsStore).mockReturnValue({ - systemStats: ref({ - system: { argv: ['python', 'main.py', '--enable-manager'] } - }), // Need --enable-manager - isInitialized: ref(true) - } as any) - vi.mocked(api.getClientFeatureFlags).mockReturnValue({}) - vi.mocked(api.getServerFeature).mockReturnValue(false) - vi.mocked(useFeatureFlags).mockReturnValue({ - flags: { supportsManagerV4: false }, - featureFlag: vi.fn() - } as any) - vi.mocked(useExtensionStore).mockReturnValue({ - extensions: [] - } as any) - - const managerState = useManagerState() - - expect(managerState.managerUIState.value).toBe(ManagerUIState.LEGACY_UI) - }) - it('should handle null systemStats gracefully', () => { - vi.mocked(useSystemStatsStore).mockReturnValue({ - systemStats: ref(null), - isInitialized: ref(true) - } as any) + // Set up store state + systemStatsStore.$patch({ + systemStats: null, + isInitialized: true + }) + vi.mocked(api.getClientFeatureFlags).mockReturnValue({ supports_manager_v4_ui: true }) vi.mocked(api.getServerFeature).mockReturnValue(true) - vi.mocked(useFeatureFlags).mockReturnValue({ - flags: { supportsManagerV4: true }, - featureFlag: vi.fn() - } as any) - vi.mocked(useExtensionStore).mockReturnValue({ - extensions: [] - } as any) const managerState = useManagerState() - // When systemStats is null, we can't check for --enable-manager flag, so manager is disabled expect(managerState.managerUIState.value).toBe(ManagerUIState.DISABLED) }) @@ -246,115 +271,163 @@ describe('useManagerState', () => { describe('helper properties', () => { it('isManagerEnabled should return true when state is not DISABLED', () => { - vi.mocked(useSystemStatsStore).mockReturnValue({ - systemStats: ref({ - system: { argv: ['python', 'main.py', '--enable-manager'] } - }), // Need --enable-manager - isInitialized: ref(true) - } as any) + // Set up store state + systemStatsStore.$patch({ + systemStats: { + system: { + os: 'Test OS', + python_version: '3.10', + embedded_python: false, + comfyui_version: '1.0.0', + pytorch_version: '2.0.0', + argv: ['python', 'main.py', '--enable-manager'], + ram_total: 16000000000, + ram_free: 8000000000 + }, + devices: [] + }, + isInitialized: true + }) + vi.mocked(api.getClientFeatureFlags).mockReturnValue({ supports_manager_v4_ui: true }) vi.mocked(api.getServerFeature).mockReturnValue(true) - vi.mocked(useExtensionStore).mockReturnValue({ - extensions: [] - } as any) const managerState = useManagerState() expect(managerState.isManagerEnabled.value).toBe(true) }) it('isManagerEnabled should return false when state is DISABLED', () => { - vi.mocked(useSystemStatsStore).mockReturnValue({ - systemStats: ref({ - system: { argv: ['python', 'main.py'] } // No --enable-manager flag means disabled - }), - isInitialized: ref(true) - } as any) - vi.mocked(api.getClientFeatureFlags).mockReturnValue({}) - vi.mocked(useExtensionStore).mockReturnValue({ - extensions: [] - } as any) + // Set up store state + systemStatsStore.$patch({ + systemStats: { + system: { + os: 'Test OS', + python_version: '3.10', + embedded_python: false, + comfyui_version: '1.0.0', + pytorch_version: '2.0.0', + argv: ['python', 'main.py'], // No --enable-manager flag + ram_total: 16000000000, + ram_free: 8000000000 + }, + devices: [] + }, + isInitialized: true + }) const managerState = useManagerState() expect(managerState.isManagerEnabled.value).toBe(false) }) it('isNewManagerUI should return true when state is NEW_UI', () => { - vi.mocked(useSystemStatsStore).mockReturnValue({ - systemStats: ref({ - system: { argv: ['python', 'main.py', '--enable-manager'] } - }), // Need --enable-manager - isInitialized: ref(true) - } as any) + // Set up store state + systemStatsStore.$patch({ + systemStats: { + system: { + os: 'Test OS', + python_version: '3.10', + embedded_python: false, + comfyui_version: '1.0.0', + pytorch_version: '2.0.0', + argv: ['python', 'main.py', '--enable-manager'], + ram_total: 16000000000, + ram_free: 8000000000 + }, + devices: [] + }, + isInitialized: true + }) + vi.mocked(api.getClientFeatureFlags).mockReturnValue({ supports_manager_v4_ui: true }) vi.mocked(api.getServerFeature).mockReturnValue(true) - vi.mocked(useExtensionStore).mockReturnValue({ - extensions: [] - } as any) const managerState = useManagerState() expect(managerState.isNewManagerUI.value).toBe(true) }) it('isLegacyManagerUI should return true when state is LEGACY_UI', () => { - vi.mocked(useSystemStatsStore).mockReturnValue({ - systemStats: ref({ + // Set up store state + systemStatsStore.$patch({ + systemStats: { system: { + os: 'Test OS', + python_version: '3.10', + embedded_python: false, + comfyui_version: '1.0.0', + pytorch_version: '2.0.0', argv: [ 'python', 'main.py', '--enable-manager', '--enable-manager-legacy-ui' - ] - } // Both flags needed - }), - isInitialized: ref(true) - } as any) - vi.mocked(api.getClientFeatureFlags).mockReturnValue({}) - vi.mocked(useExtensionStore).mockReturnValue({ - extensions: [] - } as any) + ], + ram_total: 16000000000, + ram_free: 8000000000 + }, + devices: [] + }, + isInitialized: true + }) const managerState = useManagerState() expect(managerState.isLegacyManagerUI.value).toBe(true) }) it('shouldShowInstallButton should return true only for NEW_UI', () => { - vi.mocked(useSystemStatsStore).mockReturnValue({ - systemStats: ref({ - system: { argv: ['python', 'main.py', '--enable-manager'] } - }), // Need --enable-manager - isInitialized: ref(true) - } as any) + // Set up store state + systemStatsStore.$patch({ + systemStats: { + system: { + os: 'Test OS', + python_version: '3.10', + embedded_python: false, + comfyui_version: '1.0.0', + pytorch_version: '2.0.0', + argv: ['python', 'main.py', '--enable-manager'], + ram_total: 16000000000, + ram_free: 8000000000 + }, + devices: [] + }, + isInitialized: true + }) + vi.mocked(api.getClientFeatureFlags).mockReturnValue({ supports_manager_v4_ui: true }) vi.mocked(api.getServerFeature).mockReturnValue(true) - vi.mocked(useExtensionStore).mockReturnValue({ - extensions: [] - } as any) const managerState = useManagerState() expect(managerState.shouldShowInstallButton.value).toBe(true) }) it('shouldShowManagerButtons should return true when not DISABLED', () => { - vi.mocked(useSystemStatsStore).mockReturnValue({ - systemStats: ref({ - system: { argv: ['python', 'main.py', '--enable-manager'] } - }), // Need --enable-manager - isInitialized: ref(true) - } as any) + // Set up store state + systemStatsStore.$patch({ + systemStats: { + system: { + os: 'Test OS', + python_version: '3.10', + embedded_python: false, + comfyui_version: '1.0.0', + pytorch_version: '2.0.0', + argv: ['python', 'main.py', '--enable-manager'], + ram_total: 16000000000, + ram_free: 8000000000 + }, + devices: [] + }, + isInitialized: true + }) + vi.mocked(api.getClientFeatureFlags).mockReturnValue({ supports_manager_v4_ui: true }) vi.mocked(api.getServerFeature).mockReturnValue(true) - vi.mocked(useExtensionStore).mockReturnValue({ - extensions: [] - } as any) const managerState = useManagerState() expect(managerState.shouldShowManagerButtons.value).toBe(true) diff --git a/src/workbench/extensions/manager/utils/versionUtil.test.ts b/src/workbench/extensions/manager/utils/versionUtil.test.ts index a9eacfc9d..c0d6270d9 100644 --- a/src/workbench/extensions/manager/utils/versionUtil.test.ts +++ b/src/workbench/extensions/manager/utils/versionUtil.test.ts @@ -27,7 +27,7 @@ describe('versionUtil', () => { it('should return null when current version is null', () => { const result = checkVersionCompatibility( 'comfyui_version', - null as any, + null!, '>=1.0.0' ) expect(result).toBeNull() @@ -51,7 +51,7 @@ describe('versionUtil', () => { const result = checkVersionCompatibility( 'comfyui_version', '1.0.0', - null as any + null! ) expect(result).toBeNull() }) From c4e5fc8dbfcba331c7440967e11900025fa8c638 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Fri, 30 Jan 2026 16:49:57 -0800 Subject: [PATCH 08/61] fix: update reactive ref after merge in imagePreviewStore (#8479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fix for COM-14110: Preview image does not display new outputs in vue-nodes. ## Problem The merge logic in `setOutputsByLocatorId` updated `app.nodeOutputs` but returned early without updating the reactive `nodeOutputs.value` ref. This caused Vue components to never receive merged output updates because only the non-reactive `app.nodeOutputs` was being updated. ## Solution Added `nodeOutputs.value[nodeLocatorId] = existingOutput` after the merge loop, before the return statement. ## Testing - Added 2 unit tests covering the merge behavior - All 4076 existing unit tests pass - Typechecks pass - Lint passes ## Notes - Related open PRs touching same files: #8143, #8366 - potential minor conflicts possible Fixes COM-14110 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8479-fix-update-reactive-ref-after-merge-in-imagePreviewStore-2f86d73d365081f1a145fa5a9782515f) by [Unito](https://www.unito.io) Co-authored-by: Amp --- src/stores/imagePreviewStore.test.ts | 60 +++++++++++++++++++++++++++- src/stores/imagePreviewStore.ts | 1 + 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/stores/imagePreviewStore.test.ts b/src/stores/imagePreviewStore.test.ts index 4b796e479..b9a4ce240 100644 --- a/src/stores/imagePreviewStore.test.ts +++ b/src/stores/imagePreviewStore.test.ts @@ -14,7 +14,9 @@ vi.mock('@/utils/litegraphUtil', () => ({ vi.mock('@/scripts/app', () => ({ app: { - getPreviewFormatParam: vi.fn(() => '&format=test_webp') + getPreviewFormatParam: vi.fn(() => '&format=test_webp'), + nodeOutputs: {} as Record, + nodePreviewImages: {} as Record } })) @@ -29,6 +31,62 @@ const createMockOutputs = ( images?: ExecutedWsMessage['output']['images'] ): ExecutedWsMessage['output'] => ({ images }) +vi.mock('@/stores/executionStore', () => ({ + useExecutionStore: vi.fn(() => ({ + executionIdToNodeLocatorId: vi.fn((id: string) => id) + })) +})) + +vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ + useWorkflowStore: vi.fn(() => ({ + nodeIdToNodeLocatorId: vi.fn((id: string | number) => String(id)), + nodeToNodeLocatorId: vi.fn((node: { id: number }) => String(node.id)) + })) +})) + +describe('imagePreviewStore setNodeOutputsByExecutionId with merge', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + vi.clearAllMocks() + app.nodeOutputs = {} + app.nodePreviewImages = {} + }) + + it('should update reactive nodeOutputs.value when merging outputs', () => { + const store = useNodeOutputStore() + const executionId = '1' + + const initialOutput = createMockOutputs([{ filename: 'a.png' }]) + store.setNodeOutputsByExecutionId(executionId, initialOutput) + + expect(app.nodeOutputs[executionId]?.images).toHaveLength(1) + expect(store.nodeOutputs[executionId]?.images).toHaveLength(1) + + const newOutput = createMockOutputs([{ filename: 'b.png' }]) + store.setNodeOutputsByExecutionId(executionId, newOutput, { merge: true }) + + expect(app.nodeOutputs[executionId]?.images).toHaveLength(2) + expect(store.nodeOutputs[executionId]?.images).toHaveLength(2) + }) + + it('should assign to reactive ref after merge for Vue reactivity', () => { + const store = useNodeOutputStore() + const executionId = '1' + + const initialOutput = createMockOutputs([{ filename: 'a.png' }]) + store.setNodeOutputsByExecutionId(executionId, initialOutput) + + const newOutput = createMockOutputs([{ filename: 'b.png' }]) + + store.setNodeOutputsByExecutionId(executionId, newOutput, { merge: true }) + + expect(store.nodeOutputs[executionId]).toStrictEqual( + app.nodeOutputs[executionId] + ) + expect(store.nodeOutputs[executionId]?.images).toHaveLength(2) + }) +}) + describe('imagePreviewStore getPreviewParam', () => { beforeEach(() => { setActivePinia(createTestingPinia({ stubActions: false })) diff --git a/src/stores/imagePreviewStore.ts b/src/stores/imagePreviewStore.ts index db2c9abaa..d2aa2d4e0 100644 --- a/src/stores/imagePreviewStore.ts +++ b/src/stores/imagePreviewStore.ts @@ -148,6 +148,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { existingOutput[k] = newValue } } + nodeOutputs.value[nodeLocatorId] = existingOutput return } } From 34d42311ea7949721068138c1a33730f1e80fece Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Fri, 30 Jan 2026 16:51:04 -0800 Subject: [PATCH 09/61] fix: add security params to Contact Support window.open call (#8470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds `'noopener,noreferrer'` to the `window.open()` call in `Comfy.ContactSupport` command, aligning with the established codebase pattern for external links. ## Changes - Updated `src/composables/useCoreCommands.ts` line 865 to include security parameters ## Testing - [x] Typecheck passes - [x] Lint passes ## Related - Fixes COM-13631 - Notion: https://www.notion.so/2ee6d73d365081328ae5cc7bce64b310 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8470-fix-add-security-params-to-Contact-Support-window-open-call-2f86d73d365081e38ea1c1ea9c9883f5) by [Unito](https://www.unito.io) Co-authored-by: Amp --- src/composables/useCoreCommands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/composables/useCoreCommands.ts b/src/composables/useCoreCommands.ts index 319970071..19d36f52b 100644 --- a/src/composables/useCoreCommands.ts +++ b/src/composables/useCoreCommands.ts @@ -862,7 +862,7 @@ export function useCoreCommands(): ComfyCommand[] { userEmail: userEmail.value, userId: resolvedUserInfo.value?.id }) - window.open(supportUrl, '_blank') + window.open(supportUrl, '_blank', 'noopener,noreferrer') } }, { From 679288500a9cf1e48a9eaa00d72438641b9fd50a Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Fri, 30 Jan 2026 16:51:31 -0800 Subject: [PATCH 10/61] fix(cloud): disable legacy node templates feature on cloud (#8462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Disables the legacy "node templates" feature on ComfyUI Cloud. This feature is accessed via right-clicking on the canvas and is distinct from both subgraphs and workflow templates. ## Problem The legacy node templates feature presents false-positive errors on cloud: - Errors appear when no existing templates exist initially - After refresh, workflow progress is lost from workflow tabs - Node templates fail to delete (except the first one) ## Solution Conditionally load the `nodeTemplates` extension only for non-cloud builds by wrapping the import in `if (!isCloud)`. ## Testing - [ ] Verify right-click canvas menu no longer shows "Node Templates" submenu on cloud - [ ] Verify right-click canvas menu no longer shows "Save Selected as Template" on cloud - [ ] Verify the feature still works on desktop/OSS builds ## Related - Refs: COM-14105 - Related issue: #4056 (Migrate Node Templates to Workflows) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8462-fix-cloud-disable-legacy-node-templates-feature-on-cloud-2f86d73d36508162948cc897d4c7ef5e) by [Unito](https://www.unito.io) Co-authored-by: Amp --- src/extensions/core/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/extensions/core/index.ts b/src/extensions/core/index.ts index fe8cbf2c7..457ef244f 100644 --- a/src/extensions/core/index.ts +++ b/src/extensions/core/index.ts @@ -13,7 +13,9 @@ import './imageCompare' import './imageCrop' import './load3d' import './maskeditor' -import './nodeTemplates' +if (!isCloud) { + await import('./nodeTemplates') +} import './noteNode' import './previewAny' import './rerouteNode' From a8079868355d71d98a87104eaebdf73f1bc3f668 Mon Sep 17 00:00:00 2001 From: Comfy Org PR Bot Date: Sat, 31 Jan 2026 13:30:35 +0900 Subject: [PATCH 11/61] 1.39.3 (#8500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch version increment to 1.39.3 **Base branch:** `main` ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8500-1-39-3-2f96d73d365081a9a74fd1fcb7e33a41) by [Unito](https://www.unito.io) --------- Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com> Co-authored-by: github-actions --- package.json | 2 +- src/locales/ar/main.json | 23 ++++++++++++++++++++++ src/locales/ar/nodeDefs.json | 34 +++++++++++++++++++++++++++++++++ src/locales/en/main.json | 1 + src/locales/en/nodeDefs.json | 34 +++++++++++++++++++++++++++++++++ src/locales/es/main.json | 23 ++++++++++++++++++++++ src/locales/es/nodeDefs.json | 34 +++++++++++++++++++++++++++++++++ src/locales/fa/main.json | 23 ++++++++++++++++++++++ src/locales/fa/nodeDefs.json | 34 +++++++++++++++++++++++++++++++++ src/locales/fr/main.json | 23 ++++++++++++++++++++++ src/locales/fr/nodeDefs.json | 34 +++++++++++++++++++++++++++++++++ src/locales/ja/main.json | 23 ++++++++++++++++++++++ src/locales/ja/nodeDefs.json | 34 +++++++++++++++++++++++++++++++++ src/locales/ko/main.json | 23 +++++++++++----------- src/locales/ko/nodeDefs.json | 34 +++++++++++++++++++++++++++++++++ src/locales/pt-BR/main.json | 23 ++++++++++++++++++++++ src/locales/pt-BR/nodeDefs.json | 34 +++++++++++++++++++++++++++++++++ src/locales/ru/main.json | 23 ++++++++++++++++++++++ src/locales/ru/nodeDefs.json | 34 +++++++++++++++++++++++++++++++++ src/locales/tr/main.json | 23 ++++++++++++++++++++++ src/locales/tr/nodeDefs.json | 34 +++++++++++++++++++++++++++++++++ src/locales/zh-TW/main.json | 23 ++++++++++++++++++++++ src/locales/zh-TW/nodeDefs.json | 34 +++++++++++++++++++++++++++++++++ src/locales/zh/main.json | 23 ++++++++++++++++++++++ src/locales/zh/nodeDefs.json | 34 +++++++++++++++++++++++++++++++++ 25 files changed, 652 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 86db51eb8..395e964e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@comfyorg/comfyui-frontend", - "version": "1.39.2", + "version": "1.39.3", "private": true, "description": "Official front-end implementation of ComfyUI", "homepage": "https://comfy.org", diff --git a/src/locales/ar/main.json b/src/locales/ar/main.json index ecb3649de..73284bf9a 100644 --- a/src/locales/ar/main.json +++ b/src/locales/ar/main.json @@ -578,6 +578,7 @@ "CLIP": "CLIP", "CLIP_VISION": "رؤية CLIP", "CLIP_VISION_OUTPUT": "خرج رؤية CLIP", + "COLOR": "لون", "COMBO": "تركيب", "COMFY_AUTOGROW_V3": "COMFY_AUTOGROW_V3", "COMFY_DYNAMICCOMBO_V3": "COMFY_DYNAMICCOMBO_V3", @@ -1301,6 +1302,28 @@ "disabledNodesWontUpdate": "العقد المعطلة لن يتم تحديثها", "discoverCommunityContent": "استكشف حزم العقد والامتدادات والمزيد من إبداعات المجتمع...", "downloads": "التنزيلات", + "emptyState": { + "allInstalled": { + "message": "لم تقم بتثبيت أي إضافات بعد.", + "title": "لا توجد إضافات مثبتة" + }, + "conflicting": { + "message": "جميع إضافاتك متوافقة.", + "title": "لا توجد تعارضات" + }, + "missing": { + "message": "جميع العقد المطلوبة مثبتة.", + "title": "لا توجد عقد مفقودة" + }, + "updateAvailable": { + "message": "جميع إضافاتك محدّثة.", + "title": "كل شيء محدّث" + }, + "workflow": { + "message": "هذا سير العمل لا يستخدم أي عقدة إضافة.", + "title": "لا توجد إضافات في سير العمل" + } + }, "enablePackToChangeVersion": "فعّل هذه الحزمة لتغيير الإصدارات", "errorConnecting": "خطأ في الاتصال بسجل عقد Comfy.", "extensionsSuccessfullyInstalled": "تم تثبيت الإضافات بنجاح وهي جاهزة للاستخدام!", diff --git a/src/locales/ar/nodeDefs.json b/src/locales/ar/nodeDefs.json index 135b7f544..2750c25ae 100644 --- a/src/locales/ar/nodeDefs.json +++ b/src/locales/ar/nodeDefs.json @@ -1266,6 +1266,21 @@ } } }, + "ColorToRGBInt": { + "description": "تحويل لون إلى قيمة عددية لنظام RGB.", + "display_name": "تحويل اللون إلى قيمة RGB عددية", + "inputs": { + "color": { + "name": "اللون" + } + }, + "outputs": { + "0": { + "name": "قيمة RGB عددية", + "tooltip": null + } + } + }, "CombineHooks2": { "display_name": "دمج الخطافات [2]", "inputs": { @@ -10982,6 +10997,25 @@ } } }, + "RecraftCreateStyleNode": { + "description": "أنشئ نمطًا مخصصًا من صور مرجعية. قم برفع ١ إلى ٥ صور لاستخدامها كمرجع للنمط. الحجم الإجمالي لجميع الصور محدود بـ ٥ ميجابايت.", + "display_name": "إنشاء نمط مخصص Recraft", + "inputs": { + "images": { + "name": "الصور" + }, + "style": { + "name": "النمط", + "tooltip": "النمط الأساسي للصور المُولدة." + } + }, + "outputs": { + "0": { + "name": "معرّف النمط", + "tooltip": null + } + } + }, "RecraftCreativeUpscaleNode": { "description": "تكبير الصورة بشكل متزامن.\nيعزز صورة نقطية معينة باستخدام أداة 'التكبير الإبداعي'، مع التركيز على تحسين التفاصيل الصغيرة والوجوه.", "display_name": "تكبير إبداعي لإعادة الصياغة", diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 17f4b8268..c00f2a456 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1577,6 +1577,7 @@ "CLIP": "CLIP", "CLIP_VISION": "CLIP_VISION", "CLIP_VISION_OUTPUT": "CLIP_VISION_OUTPUT", + "COLOR": "COLOR", "COMBO": "COMBO", "COMFY_AUTOGROW_V3": "COMFY_AUTOGROW_V3", "COMFY_DYNAMICCOMBO_V3": "COMFY_DYNAMICCOMBO_V3", diff --git a/src/locales/en/nodeDefs.json b/src/locales/en/nodeDefs.json index 6aceadac5..007087c9b 100644 --- a/src/locales/en/nodeDefs.json +++ b/src/locales/en/nodeDefs.json @@ -1266,6 +1266,21 @@ } } }, + "ColorToRGBInt": { + "display_name": "Color to RGB Int", + "description": "Convert a color to a RGB integer value.", + "inputs": { + "color": { + "name": "color" + } + }, + "outputs": { + "0": { + "name": "rgb_int", + "tooltip": null + } + } + }, "CombineHooks2": { "display_name": "Combine Hooks [2]", "inputs": { @@ -11000,6 +11015,25 @@ } } }, + "RecraftCreateStyleNode": { + "display_name": "Recraft Create Style", + "description": "Create a custom style from reference images. Upload 1-5 images to use as style references. Total size of all images is limited to 5 MB.", + "inputs": { + "style": { + "name": "style", + "tooltip": "The base style of the generated images." + }, + "images": { + "name": "images" + } + }, + "outputs": { + "0": { + "name": "style_id", + "tooltip": null + } + } + }, "RecraftCreativeUpscaleNode": { "display_name": "Recraft Creative Upscale Image", "description": "Upscale image synchronously.\nEnhances a given raster image using ‘creative upscale’ tool, boosting resolution with a focus on refining small details and faces.", diff --git a/src/locales/es/main.json b/src/locales/es/main.json index b32cda213..e14b857bf 100644 --- a/src/locales/es/main.json +++ b/src/locales/es/main.json @@ -578,6 +578,7 @@ "CLIP": "CLIP", "CLIP_VISION": "CLIP_VISION", "CLIP_VISION_OUTPUT": "SALIDA_CLIP_VISION", + "COLOR": "COLOR", "COMBO": "COMBO", "COMFY_AUTOGROW_V3": "COMFY_AUTOGROW_V3", "COMFY_DYNAMICCOMBO_V3": "COMFY_DYNAMICCOMBO_V3", @@ -1301,6 +1302,28 @@ "disabledNodesWontUpdate": "Los nodos deshabilitados no se actualizarán", "discoverCommunityContent": "Descubre paquetes de nodos, extensiones y más creados por la comunidad...", "downloads": "Descargas", + "emptyState": { + "allInstalled": { + "message": "Aún no has instalado ninguna extensión.", + "title": "No hay extensiones instaladas" + }, + "conflicting": { + "message": "Todas tus extensiones son compatibles.", + "title": "No se detectaron conflictos" + }, + "missing": { + "message": "Todos los nodos requeridos están instalados.", + "title": "No hay nodos faltantes" + }, + "updateAvailable": { + "message": "Todas tus extensiones están actualizadas.", + "title": "Todo actualizado" + }, + "workflow": { + "message": "Este flujo de trabajo no utiliza ningún nodo de extensión.", + "title": "Sin extensiones en el flujo de trabajo" + } + }, "enablePackToChangeVersion": "Habilita este paquete para cambiar versiones", "errorConnecting": "Error al conectar con el Registro de Nodos Comfy.", "extensionsSuccessfullyInstalled": "¡Extensión(es) instalada(s) exitosamente y lista(s) para usar!", diff --git a/src/locales/es/nodeDefs.json b/src/locales/es/nodeDefs.json index 69a9588cc..5c9c0c030 100644 --- a/src/locales/es/nodeDefs.json +++ b/src/locales/es/nodeDefs.json @@ -1266,6 +1266,21 @@ } } }, + "ColorToRGBInt": { + "description": "Convierte un color a un valor entero RGB.", + "display_name": "Color a RGB Int", + "inputs": { + "color": { + "name": "color" + } + }, + "outputs": { + "0": { + "name": "rgb_int", + "tooltip": null + } + } + }, "CombineHooks2": { "display_name": "Combinar Hooks [2]", "inputs": { @@ -10982,6 +10997,25 @@ } } }, + "RecraftCreateStyleNode": { + "description": "Crea un estilo personalizado a partir de imágenes de referencia. Sube de 1 a 5 imágenes para usar como referencias de estilo. El tamaño total de todas las imágenes está limitado a 5 MB.", + "display_name": "Recraft Crear Estilo", + "inputs": { + "images": { + "name": "imágenes" + }, + "style": { + "name": "estilo", + "tooltip": "El estilo base de las imágenes generadas." + } + }, + "outputs": { + "0": { + "name": "id_estilo", + "tooltip": null + } + } + }, "RecraftCreativeUpscaleNode": { "description": "Aumenta la imagen de forma sincrónica.\nMejora una imagen ráster dada utilizando la herramienta de ‘creative upscale’, aumentando la resolución con un enfoque en refinar pequeños detalles y rostros.", "display_name": "Recraft Creative Upscale Image", diff --git a/src/locales/fa/main.json b/src/locales/fa/main.json index 4fbf68e97..6a483452e 100644 --- a/src/locales/fa/main.json +++ b/src/locales/fa/main.json @@ -578,6 +578,7 @@ "CLIP": "clip", "CLIP_VISION": "بینایی clip", "CLIP_VISION_OUTPUT": "خروجی بینایی clip", + "COLOR": "رنگ", "COMBO": "ترکیبی", "COMFY_AUTOGROW_V3": "Comfy AutoGrow V3", "COMFY_DYNAMICCOMBO_V3": "Comfy DynamicCombo V3", @@ -1301,6 +1302,28 @@ "disabledNodesWontUpdate": "نودهای غیرفعال به‌روزرسانی نخواهند شد", "discoverCommunityContent": "بسته‌های نود، افزونه‌ها و محتوای ساخته‌شده توسط جامعه را کشف کنید...", "downloads": "دانلودها", + "emptyState": { + "allInstalled": { + "message": "شما هنوز هیچ افزونه‌ای نصب نکرده‌اید.", + "title": "افزونه‌ای نصب نشده است" + }, + "conflicting": { + "message": "تمام افزونه‌های شما سازگار هستند.", + "title": "تعارضی شناسایی نشد" + }, + "missing": { + "message": "تمام nodeهای مورد نیاز نصب شده‌اند.", + "title": "node گمشده‌ای وجود ندارد" + }, + "updateAvailable": { + "message": "تمام افزونه‌های شما به‌روز هستند.", + "title": "همه به‌روز هستند" + }, + "workflow": { + "message": "این گردش‌کار از هیچ node افزونه‌ای استفاده نمی‌کند.", + "title": "افزونه‌ای در گردش‌کار وجود ندارد" + } + }, "enablePackToChangeVersion": "برای تغییر نسخه، این بسته را فعال کنید", "errorConnecting": "خطا در اتصال به رجیستری نود Comfy.", "extensionsSuccessfullyInstalled": "افزونه(ها) با موفقیت نصب شدند و آماده استفاده هستند!", diff --git a/src/locales/fa/nodeDefs.json b/src/locales/fa/nodeDefs.json index c8607b6c1..f6c28f4b7 100644 --- a/src/locales/fa/nodeDefs.json +++ b/src/locales/fa/nodeDefs.json @@ -1266,6 +1266,21 @@ } } }, + "ColorToRGBInt": { + "description": "تبدیل یک رنگ به مقدار صحیح RGB.", + "display_name": "تبدیل رنگ به مقدار صحیح RGB", + "inputs": { + "color": { + "name": "رنگ" + } + }, + "outputs": { + "0": { + "name": "مقدار صحیح RGB", + "tooltip": null + } + } + }, "CombineHooks2": { "display_name": "ترکیب هوک‌ها [۲]", "inputs": { @@ -10998,6 +11013,25 @@ } } }, + "RecraftCreateStyleNode": { + "description": "ایجاد یک استایل سفارشی از تصاویر مرجع. ۱ تا ۵ تصویر را برای استفاده به عنوان مرجع استایل بارگذاری کنید. مجموع حجم تمام تصاویر حداکثر ۵ مگابایت است.", + "display_name": "ایجاد استایل سفارشی Recraft", + "inputs": { + "images": { + "name": "تصاویر" + }, + "style": { + "name": "استایل", + "tooltip": "استایل پایه تصاویر تولید شده." + } + }, + "outputs": { + "0": { + "name": "شناسه استایل", + "tooltip": null + } + } + }, "RecraftCreativeUpscaleNode": { "description": "بزرگ‌نمایی تصویر به صورت همزمان.\nیک تصویر شطرنجی را با ابزار «بزرگ‌نمایی خلاقانه» بهبود می‌بخشد و وضوح را با تمرکز بر جزئیات کوچک و چهره‌ها افزایش می‌دهد.", "display_name": "بزرگ‌نمایی خلاقانه تصویر Recraft", diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json index 7f726c62f..e4c2e6671 100644 --- a/src/locales/fr/main.json +++ b/src/locales/fr/main.json @@ -578,6 +578,7 @@ "CLIP": "CLIP", "CLIP_VISION": "CLIP_VISION", "CLIP_VISION_OUTPUT": "SORTIE_CLIP_VISION", + "COLOR": "COULEUR", "COMBO": "COMBO", "COMFY_AUTOGROW_V3": "COMFY_AUTOGROW_V3", "COMFY_DYNAMICCOMBO_V3": "COMFY_DYNAMICCOMBO_V3", @@ -1301,6 +1302,28 @@ "disabledNodesWontUpdate": "Les nœuds désactivés ne seront pas mis à jour", "discoverCommunityContent": "Découvrez les packs de nœuds, extensions et plus encore créés par la communauté...", "downloads": "Téléchargements", + "emptyState": { + "allInstalled": { + "message": "Vous n'avez pas encore installé d'extensions.", + "title": "Aucune extension installée" + }, + "conflicting": { + "message": "Toutes vos extensions sont compatibles.", + "title": "Aucun conflit détecté" + }, + "missing": { + "message": "Tous les nœuds requis sont installés.", + "title": "Aucun nœud manquant" + }, + "updateAvailable": { + "message": "Toutes vos extensions sont à jour.", + "title": "Tout est à jour" + }, + "workflow": { + "message": "Ce workflow n'utilise aucun nœud d'extension.", + "title": "Aucune extension dans le workflow" + } + }, "enablePackToChangeVersion": "Activez ce pack pour changer de version", "errorConnecting": "Erreur de connexion au registre de nœuds Comfy.", "extensionsSuccessfullyInstalled": "Extension(s) installée(s) avec succès et prête(s) à l'emploi !", diff --git a/src/locales/fr/nodeDefs.json b/src/locales/fr/nodeDefs.json index 015e19560..590c14299 100644 --- a/src/locales/fr/nodeDefs.json +++ b/src/locales/fr/nodeDefs.json @@ -1266,6 +1266,21 @@ } } }, + "ColorToRGBInt": { + "description": "Convertir une couleur en une valeur entière RGB.", + "display_name": "Couleur vers RGB Int", + "inputs": { + "color": { + "name": "couleur" + } + }, + "outputs": { + "0": { + "name": "rgb_int", + "tooltip": null + } + } + }, "CombineHooks2": { "display_name": "Combiner Hooks [2]", "inputs": { @@ -10982,6 +10997,25 @@ } } }, + "RecraftCreateStyleNode": { + "description": "Créez un style personnalisé à partir d’images de référence. Téléchargez 1 à 5 images à utiliser comme références de style. La taille totale de toutes les images est limitée à 5 Mo.", + "display_name": "Créer un style Recraft", + "inputs": { + "images": { + "name": "images" + }, + "style": { + "name": "style", + "tooltip": "Le style de base des images générées." + } + }, + "outputs": { + "0": { + "name": "style_id", + "tooltip": null + } + } + }, "RecraftCreativeUpscaleNode": { "description": "Agrandit l'image de manière synchrone.\nAméliore une image matricielle donnée à l'aide de l'outil « creative upscale », augmentant la résolution avec un accent sur l'affinement des petits détails et des visages.", "display_name": "Recraft Creative Upscale Image", diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json index 5a018a7f7..a2728d1a4 100644 --- a/src/locales/ja/main.json +++ b/src/locales/ja/main.json @@ -578,6 +578,7 @@ "CLIP": "CLIP", "CLIP_VISION": "CLIP_VISION", "CLIP_VISION_OUTPUT": "CLIP_VISION_OUTPUT", + "COLOR": "カラー", "COMBO": "コンボ", "COMFY_AUTOGROW_V3": "COMFY_AUTOGROW_V3", "COMFY_DYNAMICCOMBO_V3": "COMFY_DYNAMICCOMBO_V3", @@ -1301,6 +1302,28 @@ "disabledNodesWontUpdate": "無効なノードは更新されません", "discoverCommunityContent": "コミュニティが作成したノードパック、拡張機能などを探す...", "downloads": "ダウンロード", + "emptyState": { + "allInstalled": { + "message": "まだ拡張機能をインストールしていません。", + "title": "拡張機能がインストールされていません" + }, + "conflicting": { + "message": "すべての拡張機能は互換性があります。", + "title": "競合は検出されませんでした" + }, + "missing": { + "message": "必要なノードはすべてインストールされています。", + "title": "不足しているノードはありません" + }, + "updateAvailable": { + "message": "すべての拡張機能は最新の状態です。", + "title": "すべて最新です" + }, + "workflow": { + "message": "このワークフローでは拡張ノードが使用されていません。", + "title": "ワークフローに拡張機能がありません" + } + }, "enablePackToChangeVersion": "このパックを有効にしてバージョンを変更してください", "errorConnecting": "Comfy Node Registryへの接続エラー。", "extensionsSuccessfullyInstalled": "拡張機能のインストールが完了し、使用可能になりました!", diff --git a/src/locales/ja/nodeDefs.json b/src/locales/ja/nodeDefs.json index c0ffaa953..bfcad7e5d 100644 --- a/src/locales/ja/nodeDefs.json +++ b/src/locales/ja/nodeDefs.json @@ -1266,6 +1266,21 @@ } } }, + "ColorToRGBInt": { + "description": "カラーをRGB整数値に変換します。", + "display_name": "カラーをRGB整数に変換", + "inputs": { + "color": { + "name": "カラー" + } + }, + "outputs": { + "0": { + "name": "rgb整数値", + "tooltip": null + } + } + }, "CombineHooks2": { "display_name": "フックを組み合わせる [2]", "inputs": { @@ -10982,6 +10997,25 @@ } } }, + "RecraftCreateStyleNode": { + "description": "参照画像からカスタムスタイルを作成します。スタイル参照として1~5枚の画像をアップロードしてください。すべての画像の合計サイズは5MBまでです。", + "display_name": "Recraft スタイル作成", + "inputs": { + "images": { + "name": "画像" + }, + "style": { + "name": "スタイル", + "tooltip": "生成される画像の基本スタイルです。" + } + }, + "outputs": { + "0": { + "name": "スタイルID", + "tooltip": null + } + } + }, "RecraftCreativeUpscaleNode": { "description": "画像を同期的にアップスケールします。\n「creative upscale」ツールを使用して指定されたラスタ画像を強化し、解像度を高めながら細部や顔の精細さに重点を置いて向上させます。", "display_name": "Recraft Creative アップスケール画像", diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json index b1e83aea8..c209af87d 100644 --- a/src/locales/ko/main.json +++ b/src/locales/ko/main.json @@ -578,6 +578,7 @@ "CLIP": "CLIP", "CLIP_VISION": "CLIP_VISION", "CLIP_VISION_OUTPUT": "CLIP_VISION 출력", + "COLOR": "색상", "COMBO": "콤보", "COMFY_AUTOGROW_V3": "COMFY_AUTOGROW_V3", "COMFY_DYNAMICCOMBO_V3": "COMFY_DYNAMICCOMBO_V3", @@ -1301,29 +1302,29 @@ "disabledNodesWontUpdate": "비활성화된 노드는 업데이트되지 않습니다", "discoverCommunityContent": "커뮤니티에서 만든 노드 팩 및 확장 프로그램을 찾아보세요...", "downloads": "다운로드", - "enablePackToChangeVersion": "버전을 변경하려면 이 팩을 활성화하세요", "emptyState": { "allInstalled": { - "title": "설치된 확장 프로그램 없음", - "message": "아직 설치한 확장 프로그램이 없습니다." + "message": "아직 설치한 확장 프로그램이 없습니다.", + "title": "설치된 확장 프로그램 없음" }, "conflicting": { - "title": "충돌 없음", - "message": "모든 확장 프로그램이 호환됩니다." + "message": "모든 확장 프로그램이 호환됩니다.", + "title": "충돌 없음" }, "missing": { - "title": "누락된 노드 없음", - "message": "필요한 모든 노드가 설치되어 있습니다." + "message": "필요한 모든 노드가 설치되어 있습니다.", + "title": "누락된 노드 없음" }, "updateAvailable": { - "title": "모두 최신 상태", - "message": "모든 확장 프로그램이 최신 버전입니다." + "message": "모든 확장 프로그램이 최신 버전입니다.", + "title": "모두 최신 상태" }, "workflow": { - "title": "워크플로에 확장 프로그램 없음", - "message": "이 워크플로는 확장 노드를 사용하지 않습니다." + "message": "이 워크플로는 확장 노드를 사용하지 않습니다.", + "title": "워크플로에 확장 프로그램 없음" } }, + "enablePackToChangeVersion": "버전을 변경하려면 이 팩을 활성화하세요", "errorConnecting": "Comfy Node Registry에 연결하는 중 오류가 발생했습니다.", "extensionsSuccessfullyInstalled": "확장이 성공적으로 설치되어 사용할 준비가 되었습니다!", "failed": "실패 ({count})", diff --git a/src/locales/ko/nodeDefs.json b/src/locales/ko/nodeDefs.json index 98a99c75e..8020c76fc 100644 --- a/src/locales/ko/nodeDefs.json +++ b/src/locales/ko/nodeDefs.json @@ -1266,6 +1266,21 @@ } } }, + "ColorToRGBInt": { + "description": "색상을 RGB 정수 값으로 변환합니다.", + "display_name": "색상을 RGB 정수로 변환", + "inputs": { + "color": { + "name": "색상" + } + }, + "outputs": { + "0": { + "name": "rgb_정수", + "tooltip": null + } + } + }, "CombineHooks2": { "display_name": "후크 결합 [2]", "inputs": { @@ -10982,6 +10997,25 @@ } } }, + "RecraftCreateStyleNode": { + "description": "참고 이미지를 사용하여 사용자 지정 스타일을 만듭니다. 스타일 참조로 사용할 이미지를 1~5개 업로드하세요. 모든 이미지의 총 용량은 5MB로 제한됩니다.", + "display_name": "Recraft 스타일 생성", + "inputs": { + "images": { + "name": "이미지" + }, + "style": { + "name": "스타일", + "tooltip": "생성된 이미지의 기본 스타일입니다." + } + }, + "outputs": { + "0": { + "name": "style_id", + "tooltip": null + } + } + }, "RecraftCreativeUpscaleNode": { "description": "이미지를 동기적으로 업스케일합니다.\n‘크리에이티브 업스케일’ 도구를 사용하여 주어진 래스터 이미지를 향상시키고, 작은 디테일과 얼굴을 정교하게 개선하면서 해상도를 높입니다.", "display_name": "Recraft 크리에이티브 업스케일 이미지", diff --git a/src/locales/pt-BR/main.json b/src/locales/pt-BR/main.json index 4ee3a127d..f48b26195 100644 --- a/src/locales/pt-BR/main.json +++ b/src/locales/pt-BR/main.json @@ -578,6 +578,7 @@ "CLIP": "clip", "CLIP_VISION": "clip visão", "CLIP_VISION_OUTPUT": "saída de clip visão", + "COLOR": "COR", "COMBO": "COMBO", "COMFY_AUTOGROW_V3": "COMFY_AUTOGROW_V3", "COMFY_DYNAMICCOMBO_V3": "COMFY_DYNAMICCOMBO_V3", @@ -1301,6 +1302,28 @@ "disabledNodesWontUpdate": "Nodes desativados não serão atualizados", "discoverCommunityContent": "Descubra Node Packs, Extensões e mais criados pela comunidade...", "downloads": "Downloads", + "emptyState": { + "allInstalled": { + "message": "Você ainda não instalou nenhuma extensão.", + "title": "Nenhuma extensão instalada" + }, + "conflicting": { + "message": "Todas as suas extensões são compatíveis.", + "title": "Nenhum conflito detectado" + }, + "missing": { + "message": "Todos os nós necessários estão instalados.", + "title": "Nenhum nó ausente" + }, + "updateAvailable": { + "message": "Todas as suas extensões estão atualizadas.", + "title": "Tudo atualizado" + }, + "workflow": { + "message": "Este fluxo de trabalho não utiliza nenhum nó de extensão.", + "title": "Nenhuma extensão no fluxo de trabalho" + } + }, "enablePackToChangeVersion": "Ative este pacote para alterar versões", "errorConnecting": "Erro ao conectar ao Registro de Nodes do Comfy.", "extensionsSuccessfullyInstalled": "Extensão(ões) instalada(s) com sucesso e pronta(s) para uso!", diff --git a/src/locales/pt-BR/nodeDefs.json b/src/locales/pt-BR/nodeDefs.json index 964c0541a..d0908ed12 100644 --- a/src/locales/pt-BR/nodeDefs.json +++ b/src/locales/pt-BR/nodeDefs.json @@ -1266,6 +1266,21 @@ } } }, + "ColorToRGBInt": { + "description": "Converte uma cor em um valor inteiro RGB.", + "display_name": "Cor para RGB Int", + "inputs": { + "color": { + "name": "cor" + } + }, + "outputs": { + "0": { + "name": "rgb_int", + "tooltip": null + } + } + }, "CombineHooks2": { "display_name": "Combinar Hooks [2]", "inputs": { @@ -10998,6 +11013,25 @@ } } }, + "RecraftCreateStyleNode": { + "description": "Crie um estilo personalizado a partir de imagens de referência. Faça upload de 1 a 5 imagens para usar como referência de estilo. O tamanho total de todas as imagens é limitado a 5 MB.", + "display_name": "Recraft Criar Estilo", + "inputs": { + "images": { + "name": "imagens" + }, + "style": { + "name": "estilo", + "tooltip": "O estilo base das imagens geradas." + } + }, + "outputs": { + "0": { + "name": "id_estilo", + "tooltip": null + } + } + }, "RecraftCreativeUpscaleNode": { "description": "Aumente a imagem de forma síncrona.\nAprimora uma imagem rasterizada usando a ferramenta de ‘ampliação criativa’, aumentando a resolução com foco no refinamento de pequenos detalhes e rostos.", "display_name": "Recraft Ampliação Criativa de Imagem", diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json index c33fdc5a9..f08923c2a 100644 --- a/src/locales/ru/main.json +++ b/src/locales/ru/main.json @@ -578,6 +578,7 @@ "CLIP": "CLIP", "CLIP_VISION": "CLIP_VISION", "CLIP_VISION_OUTPUT": "CLIP_VISION_OUTPUT", + "COLOR": "ЦВЕТ", "COMBO": "КОМБО", "COMFY_AUTOGROW_V3": "COMFY_AUTOGROW_V3", "COMFY_DYNAMICCOMBO_V3": "COMFY_DYNAMICCOMBO_V3", @@ -1301,6 +1302,28 @@ "disabledNodesWontUpdate": "Отключенные ноды не будут обновлены", "discoverCommunityContent": "Откройте для себя пакеты узлов, расширения и многое другое, созданные сообществом...", "downloads": "Загрузки", + "emptyState": { + "allInstalled": { + "message": "Вы еще не установили ни одного расширения.", + "title": "Нет установленных расширений" + }, + "conflicting": { + "message": "Все ваши расширения совместимы.", + "title": "Конфликтов не обнаружено" + }, + "missing": { + "message": "Все необходимые узлы установлены.", + "title": "Нет отсутствующих узлов" + }, + "updateAvailable": { + "message": "Все ваши расширения обновлены.", + "title": "Все обновлено" + }, + "workflow": { + "message": "В этом рабочем процессе не используются узлы расширений.", + "title": "Нет расширений в рабочем процессе" + } + }, "enablePackToChangeVersion": "Включите этот пакет для изменения версий", "errorConnecting": "Ошибка подключения к реестру Comfy Node.", "extensionsSuccessfullyInstalled": "Расширение(я) успешно установлено и готово к использованию!", diff --git a/src/locales/ru/nodeDefs.json b/src/locales/ru/nodeDefs.json index 91a6c62a8..3eb90825c 100644 --- a/src/locales/ru/nodeDefs.json +++ b/src/locales/ru/nodeDefs.json @@ -1266,6 +1266,21 @@ } } }, + "ColorToRGBInt": { + "description": "Преобразовать цвет в целочисленное значение RGB.", + "display_name": "Преобразование цвета в RGB Int", + "inputs": { + "color": { + "name": "цвет" + } + }, + "outputs": { + "0": { + "name": "rgb_int", + "tooltip": null + } + } + }, "CombineHooks2": { "display_name": "Объединить хуки [2]", "inputs": { @@ -10982,6 +10997,25 @@ } } }, + "RecraftCreateStyleNode": { + "description": "Создайте пользовательский стиль на основе референсных изображений. Загрузите 1–5 изображений для использования в качестве референсов стиля. Общий размер всех изображений ограничен 5 МБ.", + "display_name": "Recraft Создать стиль", + "inputs": { + "images": { + "name": "изображения" + }, + "style": { + "name": "стиль", + "tooltip": "Базовый стиль сгенерированных изображений." + } + }, + "outputs": { + "0": { + "name": "style_id", + "tooltip": null + } + } + }, "RecraftCreativeUpscaleNode": { "description": "Масштабирует изображение синхронно.\nУлучшает заданное растровое изображение с помощью инструмента «creative upscale», повышая разрешение с акцентом на детализацию мелких элементов и лиц.", "display_name": "Recraft Creative Upscale Image", diff --git a/src/locales/tr/main.json b/src/locales/tr/main.json index 1708bc687..53fa6eb2d 100644 --- a/src/locales/tr/main.json +++ b/src/locales/tr/main.json @@ -578,6 +578,7 @@ "CLIP": "CLIP", "CLIP_VISION": "CLIP_VISION", "CLIP_VISION_OUTPUT": "CLIP_VISION_ÇIKTISI", + "COLOR": "RENK", "COMBO": "COMBO", "COMFY_AUTOGROW_V3": "COMFY_AUTOGROW_V3", "COMFY_DYNAMICCOMBO_V3": "COMFY_DYNAMICCOMBO_V3", @@ -1301,6 +1302,28 @@ "disabledNodesWontUpdate": "Devre dışı düğümler güncellenmeyecek", "discoverCommunityContent": "Topluluk tarafından yapılmış Düğüm Paketlerini, Uzantıları ve daha fazlasını keşfedin...", "downloads": "İndirmeler", + "emptyState": { + "allInstalled": { + "message": "Henüz hiç eklenti yüklemediniz.", + "title": "Yüklü Eklenti Yok" + }, + "conflicting": { + "message": "Tüm eklentileriniz uyumlu.", + "title": "Çakışma Tespit Edilmedi" + }, + "missing": { + "message": "Gerekli tüm düğümler yüklü.", + "title": "Eksik Düğüm Yok" + }, + "updateAvailable": { + "message": "Tüm eklentileriniz güncel.", + "title": "Hepsi Güncel" + }, + "workflow": { + "message": "Bu iş akışı herhangi bir eklenti düğümü kullanmıyor.", + "title": "İş Akışında Eklenti Yok" + } + }, "enablePackToChangeVersion": "Sürümleri değiştirmek için bu paketi etkinleştirin", "errorConnecting": "Comfy Düğüm Kayıt Defteri'ne bağlanırken hata oluştu.", "extensionsSuccessfullyInstalled": "Uzantı(lar) başarıyla yüklendi ve kullanıma hazır!", diff --git a/src/locales/tr/nodeDefs.json b/src/locales/tr/nodeDefs.json index d487158cf..691e0e610 100644 --- a/src/locales/tr/nodeDefs.json +++ b/src/locales/tr/nodeDefs.json @@ -1266,6 +1266,21 @@ } } }, + "ColorToRGBInt": { + "description": "Bir rengi RGB tam sayı değerine dönüştür.", + "display_name": "Renkten RGB Tam Sayıya", + "inputs": { + "color": { + "name": "renk" + } + }, + "outputs": { + "0": { + "name": "rgb_tam_sayı", + "tooltip": null + } + } + }, "CombineHooks2": { "display_name": "Kancaları Birleştir [2]", "inputs": { @@ -10982,6 +10997,25 @@ } } }, + "RecraftCreateStyleNode": { + "description": "Referans görsellerden özel bir stil oluşturun. Stil referansı olarak kullanmak için 1-5 görsel yükleyin. Tüm görsellerin toplam boyutu 5 MB ile sınırlıdır.", + "display_name": "Recraft Stil Oluştur", + "inputs": { + "images": { + "name": "görseller" + }, + "style": { + "name": "stil", + "tooltip": "Oluşturulan görsellerin temel stili." + } + }, + "outputs": { + "0": { + "name": "stil_id", + "tooltip": null + } + } + }, "RecraftCreativeUpscaleNode": { "description": "Görüntüyü eşzamanlı olarak büyütün.\nVerilen bir raster görüntüyü ‘yaratıcı büyütme’ aracıyla geliştirir, küçük ayrıntıları ve yüzleri iyileştirmeye odaklanarak çözünürlüğü artırır.", "display_name": "Recraft Yaratıcı Büyütme Görüntüsü", diff --git a/src/locales/zh-TW/main.json b/src/locales/zh-TW/main.json index c69c67dcf..14446b4ad 100644 --- a/src/locales/zh-TW/main.json +++ b/src/locales/zh-TW/main.json @@ -578,6 +578,7 @@ "CLIP": "CLIP", "CLIP_VISION": "CLIP 視覺", "CLIP_VISION_OUTPUT": "CLIP 視覺輸出", + "COLOR": "顏色", "COMBO": "組合", "COMFY_AUTOGROW_V3": "COMFY_AUTOGROW_V3", "COMFY_DYNAMICCOMBO_V3": "COMFY_DYNAMICCOMBO_V3", @@ -1301,6 +1302,28 @@ "disabledNodesWontUpdate": "已停用的節點將不會更新", "discoverCommunityContent": "探索社群製作的節點包、擴充功能等...", "downloads": "下載次數", + "emptyState": { + "allInstalled": { + "message": "你尚未安裝任何擴充功能。", + "title": "尚未安裝擴充功能" + }, + "conflicting": { + "message": "你的所有擴充功能皆相容。", + "title": "未偵測到衝突" + }, + "missing": { + "message": "所有必要的節點都已安裝。", + "title": "無缺少的節點" + }, + "updateAvailable": { + "message": "你的所有擴充功能都是最新的。", + "title": "全部為最新版本" + }, + "workflow": { + "message": "此工作流程未使用任何擴充節點。", + "title": "工作流程中無擴充功能" + } + }, "enablePackToChangeVersion": "啟用此套件以變更版本", "errorConnecting": "連線至 Comfy Node Registry 時發生錯誤。", "extensionsSuccessfullyInstalled": "擴充功能已成功安裝並可使用!", diff --git a/src/locales/zh-TW/nodeDefs.json b/src/locales/zh-TW/nodeDefs.json index 173cc06c3..3a696149e 100644 --- a/src/locales/zh-TW/nodeDefs.json +++ b/src/locales/zh-TW/nodeDefs.json @@ -1266,6 +1266,21 @@ } } }, + "ColorToRGBInt": { + "description": "將顏色轉換為 RGB 整數值。", + "display_name": "顏色轉為 RGB 整數值", + "inputs": { + "color": { + "name": "顏色" + } + }, + "outputs": { + "0": { + "name": "rgb 整數值", + "tooltip": null + } + } + }, "CombineHooks2": { "display_name": "合併 Hooks [2]", "inputs": { @@ -10982,6 +10997,25 @@ } } }, + "RecraftCreateStyleNode": { + "description": "從參考圖片建立自訂風格。上傳 1-5 張圖片作為風格參考。所有圖片的總大小限制為 5 MB。", + "display_name": "Recraft 建立風格", + "inputs": { + "images": { + "name": "圖片" + }, + "style": { + "name": "風格", + "tooltip": "生成圖片的基礎風格。" + } + }, + "outputs": { + "0": { + "name": "風格 ID", + "tooltip": null + } + } + }, "RecraftCreativeUpscaleNode": { "description": "同步放大影像。\n使用「creative upscale」工具增強所提供的點陣影像,提升解析度,特別強化細節與人臉。", "display_name": "Recraft Creative 影像放大", diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json index 35a6812d3..8cd846ac8 100644 --- a/src/locales/zh/main.json +++ b/src/locales/zh/main.json @@ -578,6 +578,7 @@ "CLIP": "CLIP", "CLIP_VISION": "CLIP视觉", "CLIP_VISION_OUTPUT": "CLIP视觉输出", + "COLOR": "颜色", "COMBO": "组合", "COMFY_AUTOGROW_V3": "COMFY_AUTOGROW_V3", "COMFY_DYNAMICCOMBO_V3": "COMFY_DYNAMICCOMBO_V3", @@ -1301,6 +1302,28 @@ "disabledNodesWontUpdate": "已禁用的节点不会被更新", "discoverCommunityContent": "发现社区制作的节点包,扩展等等...", "downloads": "下载", + "emptyState": { + "allInstalled": { + "message": "你还没有安装任何扩展。", + "title": "未安装扩展" + }, + "conflicting": { + "message": "你的所有扩展均兼容。", + "title": "未检测到冲突" + }, + "missing": { + "message": "所有必需节点均已安装。", + "title": "无缺失节点" + }, + "updateAvailable": { + "message": "你的所有扩展都是最新的。", + "title": "全部已是最新" + }, + "workflow": { + "message": "此工作流未使用任何扩展节点。", + "title": "工作流中无扩展" + } + }, "enablePackToChangeVersion": "启用此包以更改版本", "errorConnecting": "连接到Comfy节点注册表时出错。", "extensionsSuccessfullyInstalled": "扩展已成功安装并可使用!", diff --git a/src/locales/zh/nodeDefs.json b/src/locales/zh/nodeDefs.json index 4a2341b9f..09a9f742d 100644 --- a/src/locales/zh/nodeDefs.json +++ b/src/locales/zh/nodeDefs.json @@ -1266,6 +1266,21 @@ } } }, + "ColorToRGBInt": { + "description": "将颜色转换为RGB整数值。", + "display_name": "颜色转RGB整数", + "inputs": { + "color": { + "name": "颜色" + } + }, + "outputs": { + "0": { + "name": "rgb整数", + "tooltip": null + } + } + }, "CombineHooks2": { "display_name": "组合约束 [2]", "inputs": { @@ -10998,6 +11013,25 @@ } } }, + "RecraftCreateStyleNode": { + "description": "从参考图像创建自定义风格。上传1-5张图片作为风格参考。所有图片的总大小限制为5MB。", + "display_name": "Recraft创建风格", + "inputs": { + "images": { + "name": "图片" + }, + "style": { + "name": "风格", + "tooltip": "生成图像的基础风格。" + } + }, + "outputs": { + "0": { + "name": "风格ID", + "tooltip": null + } + } + }, "RecraftCreativeUpscaleNode": { "description": "同步放大图像。\n使用“创意放大”工具增强给定的光栅图像,提升分辨率,重点优化细节和人脸。", "display_name": "Recraft 创意放大图像", From be7c34e28bb2db2dff32bf7fd8bb386418c4c65a Mon Sep 17 00:00:00 2001 From: AustinMroz Date: Fri, 30 Jan 2026 21:34:50 -0800 Subject: [PATCH 12/61] Update control_after_generate schema (#8505) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates `control_after_generate` in the schema to support specifying the default control value as a string See Comfy-Org/ComfyUI#12187 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8505-Update-control_after_generate-schema-2f96d73d365081f9bf73c804072bb415) by [Unito](https://www.unito.io) --- .../widgets/composables/useComboWidget.ts | 18 +++++++++++++++--- .../widgets/composables/useIntWidget.ts | 6 +++++- src/schemas/nodeDefSchema.ts | 9 +++++++-- src/types/simplifiedWidget.ts | 2 +- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useComboWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useComboWidget.ts index 75cf47fec..f398f9b0b 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useComboWidget.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useComboWidget.ts @@ -74,10 +74,14 @@ const addMultiSelectWidget = ( // TODO: Add remote support to multi-select widget // https://github.com/Comfy-Org/ComfyUI_frontend/issues/3003 if (inputSpec.control_after_generate) { + const defaultType = + typeof inputSpec.control_after_generate === 'string' + ? inputSpec.control_after_generate + : 'fixed' widget.linkedWidgets = addValueControlWidgets( node, widget, - 'fixed', + defaultType, undefined, transformInputSpecV2ToV1(inputSpec) ) @@ -209,10 +213,14 @@ const createInputMappingWidget = ( if (!isComboWidget(widget)) { throw new Error(`Expected combo widget but received ${widget.type}`) } + const defaultType = + typeof inputSpec.control_after_generate === 'string' + ? inputSpec.control_after_generate + : 'randomize' widget.linkedWidgets = addValueControlWidgets( node, widget, - undefined, + defaultType, undefined, transformInputSpecV2ToV1(inputSpec) ) @@ -284,10 +292,14 @@ const addComboWidget = ( throw new Error(`Expected combo widget but received ${widget.type}`) } + const defaultType = + typeof inputSpec.control_after_generate === 'string' + ? inputSpec.control_after_generate + : 'randomize' widget.linkedWidgets = addValueControlWidgets( node, widget, - undefined, + defaultType, undefined, transformInputSpecV2ToV1(inputSpec) ) diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useIntWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useIntWidget.ts index 0c257c15b..f307aa0e1 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useIntWidget.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useIntWidget.ts @@ -72,10 +72,14 @@ export const useIntWidget = () => { ['seed', 'noise_seed'].includes(inputSpec.name) if (controlAfterGenerate) { + const defaultType = + typeof inputSpec.control_after_generate === 'string' + ? inputSpec.control_after_generate + : 'randomize' const controlWidget = addValueControlWidget( node, widget, - 'randomize', + defaultType, undefined, undefined, transformInputSpecV2ToV1(inputSpec) diff --git a/src/schemas/nodeDefSchema.ts b/src/schemas/nodeDefSchema.ts index 47810b51c..f4fe928cc 100644 --- a/src/schemas/nodeDefSchema.ts +++ b/src/schemas/nodeDefSchema.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { fromZodError } from 'zod-validation-error' import { resultItemType } from '@/schemas/apiSchema' +import { CONTROL_OPTIONS } from '@/types/simplifiedWidget' const zComboOption = z.union([z.string(), z.number()]) const zRemoteWidgetConfig = z.object({ @@ -50,7 +51,9 @@ export const zIntInputOptions = zNumericInputOptions.extend({ * If true, a linked widget will be added to the node to select the mode * of `control_after_generate`. */ - control_after_generate: z.boolean().optional() + control_after_generate: z + .union([z.boolean(), z.enum(CONTROL_OPTIONS)]) + .optional() }) export const zFloatInputOptions = zNumericInputOptions.extend({ @@ -74,7 +77,9 @@ export const zStringInputOptions = zBaseInputOptions.extend({ }) export const zComboInputOptions = zBaseInputOptions.extend({ - control_after_generate: z.boolean().optional(), + control_after_generate: z + .union([z.boolean(), z.enum(CONTROL_OPTIONS)]) + .optional(), image_upload: z.boolean().optional(), image_folder: resultItemType.optional(), allow_batch: z.boolean().optional(), diff --git a/src/types/simplifiedWidget.ts b/src/types/simplifiedWidget.ts index 40b74f98b..b4654406b 100644 --- a/src/types/simplifiedWidget.ts +++ b/src/types/simplifiedWidget.ts @@ -15,7 +15,7 @@ export type WidgetValue = | void | File[] -const CONTROL_OPTIONS = [ +export const CONTROL_OPTIONS = [ 'fixed', 'increment', 'decrement', From 4a85bffb1faba656dbdbc20787f91a4efffa55b5 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sat, 31 Jan 2026 15:57:38 -0800 Subject: [PATCH 13/61] fix: node header on preview has a gap on the right (not flush) (#8487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary After changes: [Screencast from 2026-01-30 00-34-59.webm](https://github.com/user-attachments/assets/9111b8ce-936d-4b30-8b56-6f44aabfe009) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8487-fix-node-header-on-preview-has-a-gap-on-the-right-not-flush-2f86d73d365081f38cabfca9ab9e04e4) by [Unito](https://www.unito.io) --- src/components/node/NodePreview.test.ts | 8 -------- src/components/node/NodePreview.vue | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/components/node/NodePreview.test.ts b/src/components/node/NodePreview.test.ts index e52687d42..a5dae5638 100644 --- a/src/components/node/NodePreview.test.ts +++ b/src/components/node/NodePreview.test.ts @@ -76,14 +76,6 @@ describe('NodePreview', () => { expect(wrapper.find('._sb_preview_badge').text()).toBe('Preview') }) - it('applies text-ellipsis class to node header for text truncation', () => { - const wrapper = mountComponent() - const nodeHeader = wrapper.find('.node_header') - - expect(nodeHeader.classes()).toContain('text-ellipsis') - expect(nodeHeader.classes()).toContain('mr-4') - }) - it('sets title attribute on node header with full display name', () => { const wrapper = mountComponent() const nodeHeader = wrapper.find('.node_header') diff --git a/src/components/node/NodePreview.vue b/src/components/node/NodePreview.vue index 107ee3471..9734d5b43 100644 --- a/src/components/node/NodePreview.vue +++ b/src/components/node/NodePreview.vue @@ -10,7 +10,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
Date: Sat, 31 Jan 2026 19:14:44 -0800 Subject: [PATCH 15/61] fix: remove delete account button and direct users to support (#8515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Reverts PR #5216 delete account functionality. The delete button only removed Firebase accounts without canceling Stripe subscriptions, causing orphaned accounts and support issues. ## Changes - Removed delete account button from UserPanel.vue - Added text directing users to contact support@comfy.org for account deletion (clickable mailto: link) - Cleaned up related code: removed `handleDeleteAccount` from useCurrentUser.ts, `deleteAccount` from useFirebaseAuthActions.ts, `_deleteAccount` from firebaseAuthStore.ts - Updated en/main.json locale with `contactSupport` key using {email} placeholder ## Testing - Typecheck and lint pass - Manual verification: user settings panel shows contact support text instead of delete button ## Related Issues Fixes COM-14243 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8515-fix-remove-delete-account-button-and-direct-users-to-support-2fa6d73d3650819dbc83efb41c07a809) by [Unito](https://www.unito.io) Co-authored-by: Amp --- .../dialog/content/setting/UserPanel.vue | 19 +++++++++++-------- src/composables/auth/useCurrentUser.ts | 18 ------------------ .../auth/useFirebaseAuthActions.ts | 16 ---------------- src/locales/en/main.json | 8 +------- src/stores/firebaseAuthStore.ts | 10 ---------- 5 files changed, 12 insertions(+), 59 deletions(-) diff --git a/src/components/dialog/content/setting/UserPanel.vue b/src/components/dialog/content/setting/UserPanel.vue index 60bc6dba3..82985641d 100644 --- a/src/components/dialog/content/setting/UserPanel.vue +++ b/src/components/dialog/content/setting/UserPanel.vue @@ -63,14 +63,18 @@ {{ $t('auth.signOut.signOut') }} - + +
@@ -116,7 +120,6 @@ const { providerName, providerIcon, handleSignOut, - handleSignIn, - handleDeleteAccount + handleSignIn } = useCurrentUser() diff --git a/src/composables/auth/useCurrentUser.ts b/src/composables/auth/useCurrentUser.ts index b43191fbb..28a427749 100644 --- a/src/composables/auth/useCurrentUser.ts +++ b/src/composables/auth/useCurrentUser.ts @@ -1,9 +1,6 @@ import { whenever } from '@vueuse/core' import { computed, watch } from 'vue' -import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions' -import { t } from '@/i18n' -import { useDialogService } from '@/services/dialogService' import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore' import { useCommandStore } from '@/stores/commandStore' import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' @@ -13,8 +10,6 @@ export const useCurrentUser = () => { const authStore = useFirebaseAuthStore() const commandStore = useCommandStore() const apiKeyStore = useApiKeyAuthStore() - const dialogService = useDialogService() - const { deleteAccount } = useFirebaseAuthActions() const firebaseUser = computed(() => authStore.currentUser) const isApiKeyLogin = computed(() => apiKeyStore.isAuthenticated) @@ -116,18 +111,6 @@ export const useCurrentUser = () => { await commandStore.execute('Comfy.User.OpenSignInDialog') } - const handleDeleteAccount = async () => { - const confirmed = await dialogService.confirm({ - title: t('auth.deleteAccount.confirmTitle'), - message: t('auth.deleteAccount.confirmMessage'), - type: 'delete' - }) - - if (confirmed) { - await deleteAccount() - } - } - return { loading: authStore.loading, isLoggedIn, @@ -141,7 +124,6 @@ export const useCurrentUser = () => { resolvedUserInfo, handleSignOut, handleSignIn, - handleDeleteAccount, onUserResolved, onTokenRefreshed, onUserLogout diff --git a/src/composables/auth/useFirebaseAuthActions.ts b/src/composables/auth/useFirebaseAuthActions.ts index 319dfa851..2d021d734 100644 --- a/src/composables/auth/useFirebaseAuthActions.ts +++ b/src/composables/auth/useFirebaseAuthActions.ts @@ -206,21 +206,6 @@ export const useFirebaseAuthActions = () => { [createReauthenticationRecovery<[string], void>()] ) - const deleteAccount = wrapWithErrorHandlingAsync( - async () => { - await authStore.deleteAccount() - toastStore.add({ - severity: 'success', - summary: t('auth.deleteAccount.success'), - detail: t('auth.deleteAccount.successDetail'), - life: 5000 - }) - }, - reportError, - undefined, - [createReauthenticationRecovery<[], void>()] - ) - return { logout, sendPasswordReset, @@ -232,7 +217,6 @@ export const useFirebaseAuthActions = () => { signInWithEmail, signUpWithEmail, updatePassword, - deleteAccount, accessError, reportError } diff --git a/src/locales/en/main.json b/src/locales/en/main.json index c00f2a456..1e1b8d44f 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1949,13 +1949,7 @@ "auth/cancelled-popup-request": "Sign-in was cancelled. Please try again." }, "deleteAccount": { - "deleteAccount": "Delete Account", - "confirmTitle": "Delete Account", - "confirmMessage": "Are you sure you want to delete your account? This action cannot be undone and will permanently remove all your data.", - "confirm": "Delete Account", - "cancel": "Cancel", - "success": "Account Deleted", - "successDetail": "Your account has been successfully deleted." + "contactSupport": "To delete your account, please contact {email}" }, "reauthRequired": { "title": "Re-authentication Required", diff --git a/src/stores/firebaseAuthStore.ts b/src/stores/firebaseAuthStore.ts index 32ae22a9a..6b8d35453 100644 --- a/src/stores/firebaseAuthStore.ts +++ b/src/stores/firebaseAuthStore.ts @@ -5,7 +5,6 @@ import { GoogleAuthProvider, browserLocalPersistence, createUserWithEmailAndPassword, - deleteUser, getAdditionalUserInfo, onAuthStateChanged, onIdTokenChanged, @@ -429,14 +428,6 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { await updatePassword(currentUser.value, newPassword) } - /** Delete the current user account */ - const _deleteAccount = async (): Promise => { - if (!currentUser.value) { - throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated')) - } - await deleteUser(currentUser.value) - } - const addCredits = async ( requestBodyContent: CreditPurchasePayload ): Promise => { @@ -536,7 +527,6 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { accessBillingPortal, sendPasswordReset, updatePassword: _updatePassword, - deleteAccount: _deleteAccount, getAuthHeader, getFirebaseAuthHeader, getAuthToken From 1bbbcfedf021a4dac2fedd62074f12ef71c3f3f9 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sat, 31 Jan 2026 19:18:13 -0800 Subject: [PATCH 16/61] feat: add provider logo overlays to workflow template thumbnails (#8365) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add support for overlaying provider logos on workflow template thumbnails at runtime. ## Changes - **What**: - Add `LogoInfo` interface and `logos` field to `TemplateInfo` type - Create `LogoOverlay.vue` component for rendering positioned logos - Fetch logo index from `templates/index_logo.json` in store - Add `getLogoUrl` helper to `useTemplateWorkflows` composable - Integrate `LogoOverlay` into `WorkflowTemplateSelectorDialog` ## Review Focus - Logo positioning uses Tailwind classes (e.g. `absolute bottom-2 right-2`) - Supports multiple logos per template with configurable size/opacity - Gracefully handles missing logos (returns empty string, renders nothing) - Templates must explicitly declare logos - no magic inference from models ## Dependencies Requires separate PR in workflow_templates repo to: 1. Update `index.schema.json` with logos definition 2. Add `logos` field to templates in `index.json` ## Screenshots (if applicable) image ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8365-feat-add-provider-logo-overlays-to-workflow-template-thumbnails-2f66d73d365081309236c6b991cb6f7b) by [Unito](https://www.unito.io) --------- Co-authored-by: Subagent 5 Co-authored-by: Amp --- .../widget/WorkflowTemplateSelectorDialog.vue | 6 + .../templates/thumbnails/LogoOverlay.test.ts | 157 ++++++++++++++++++ .../templates/thumbnails/LogoOverlay.vue | 117 +++++++++++++ src/locales/en/main.json | 3 + .../repositories/workflowTemplatesStore.ts | 51 +++++- .../templates/schemas/templateSchema.ts | 5 + .../workflow/templates/types/template.ts | 17 ++ 7 files changed, 349 insertions(+), 7 deletions(-) create mode 100644 src/components/templates/thumbnails/LogoOverlay.test.ts create mode 100644 src/components/templates/thumbnails/LogoOverlay.vue create mode 100644 src/platform/workflow/templates/schemas/templateSchema.ts diff --git a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue index 8580e29f9..2ed71befc 100644 --- a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue +++ b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue @@ -256,6 +256,11 @@ " /> + ({ + useI18n: () => ({ + t: (key: string) => + key === 'templates.logoProviderSeparator' ? ' & ' : key, + locale: ref('en') + }) +})) + +type LogoOverlayProps = ComponentProps + +describe('LogoOverlay', () => { + function mockGetLogoUrl(provider: string) { + return `/logos/${provider}.png` + } + + function mountOverlay( + logos: LogoInfo[], + props: Partial = {} + ) { + return mount(LogoOverlay, { + props: { + logos, + getLogoUrl: mockGetLogoUrl, + ...props + } + }) + } + + it('renders nothing when logos array is empty', () => { + const wrapper = mountOverlay([]) + expect(wrapper.findAll('img')).toHaveLength(0) + }) + + it('renders a single logo with correct src and alt', () => { + const wrapper = mountOverlay([{ provider: 'Google' }]) + const img = wrapper.find('img') + expect(img.attributes('src')).toBe('/logos/Google.png') + expect(img.attributes('alt')).toBe('Google') + }) + + it('renders multiple separate logo entries', () => { + const wrapper = mountOverlay([ + { provider: 'Google' }, + { provider: 'OpenAI' }, + { provider: 'Stability' } + ]) + expect(wrapper.findAll('img')).toHaveLength(3) + }) + + it('displays provider name as label for single provider', () => { + const wrapper = mountOverlay([{ provider: 'Google' }]) + const span = wrapper.find('span') + expect(span.text()).toBe('Google') + }) + + it('images are not draggable', () => { + const wrapper = mountOverlay([{ provider: 'Google' }]) + const img = wrapper.find('img') + expect(img.attributes('draggable')).toBe('false') + }) + + it('filters out logos with empty URLs', () => { + function getLogoUrl(provider: string) { + return provider === 'Google' ? '/logos/Google.png' : '' + } + const wrapper = mount(LogoOverlay, { + props: { + logos: [{ provider: 'Google' }, { provider: 'Unknown' }], + getLogoUrl + } + }) + expect(wrapper.findAll('img')).toHaveLength(1) + }) + + it('renders one logo per unique provider', () => { + const wrapper = mountOverlay([ + { provider: 'Google' }, + { provider: 'OpenAI' } + ]) + expect(wrapper.findAll('img')).toHaveLength(2) + }) + + describe('stacked logos', () => { + it('renders multiple providers as stacked overlapping logos', () => { + const wrapper = mountOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }]) + const images = wrapper.findAll('img') + expect(images).toHaveLength(2) + expect(images[0].attributes('alt')).toBe('WaveSpeed') + expect(images[1].attributes('alt')).toBe('Hunyuan') + }) + + it('joins provider names with locale-aware conjunction for default label', () => { + const wrapper = mountOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }]) + const span = wrapper.find('span') + expect(span.text()).toBe('WaveSpeed and Hunyuan') + }) + + it('uses custom label when provided', () => { + const wrapper = mountOverlay([ + { provider: ['WaveSpeed', 'Hunyuan'], label: 'Custom Label' } + ]) + const span = wrapper.find('span') + expect(span.text()).toBe('Custom Label') + }) + + it('applies negative gap for overlap effect', () => { + const wrapper = mountOverlay([ + { provider: ['WaveSpeed', 'Hunyuan'], gap: -8 } + ]) + const images = wrapper.findAll('img') + expect(images[1].attributes('style')).toContain('margin-left: -8px') + }) + + it('applies default gap when not specified', () => { + const wrapper = mountOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }]) + const images = wrapper.findAll('img') + expect(images[1].attributes('style')).toContain('margin-left: -6px') + }) + + it('filters out invalid providers from stacked logos', () => { + function getLogoUrl(provider: string) { + return provider === 'WaveSpeed' ? '/logos/WaveSpeed.png' : '' + } + const wrapper = mount(LogoOverlay, { + props: { + logos: [{ provider: ['WaveSpeed', 'Unknown'] }], + getLogoUrl + } + }) + expect(wrapper.findAll('img')).toHaveLength(1) + expect(wrapper.find('span').text()).toBe('WaveSpeed') + }) + }) + + describe('error handling', () => { + it('keeps showing remaining providers when one image fails in stacked logos', async () => { + const wrapper = mountOverlay([{ provider: ['Google', 'OpenAI'] }]) + const images = wrapper.findAll('[data-testid="logo-img"]') + expect(images).toHaveLength(2) + + await images[0].trigger('error') + await nextTick() + + const remainingImages = wrapper.findAll('[data-testid="logo-img"]') + expect(remainingImages).toHaveLength(2) + expect(remainingImages[1].attributes('alt')).toBe('OpenAI') + }) + }) +}) diff --git a/src/components/templates/thumbnails/LogoOverlay.vue b/src/components/templates/thumbnails/LogoOverlay.vue new file mode 100644 index 000000000..3dc4846b4 --- /dev/null +++ b/src/components/templates/thumbnails/LogoOverlay.vue @@ -0,0 +1,117 @@ + + + diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 1e1b8d44f..71d243b40 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -966,6 +966,9 @@ "searchPlaceholder": "Search..." } }, + "templates": { + "logoProviderSeparator": " & " + }, "graphCanvasMenu": { "zoomIn": "Zoom In", "zoomOut": "Zoom Out", diff --git a/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts b/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts index f96872f79..463d76ec7 100644 --- a/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts +++ b/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts @@ -8,6 +8,8 @@ import type { NavGroupData, NavItemData } from '@/types/navTypes' import { generateCategoryId, getCategoryIcon } from '@/utils/categoryUtil' import { normalizeI18nKey } from '@/utils/formatUtil' +import { zLogoIndex } from '../schemas/templateSchema' +import type { LogoIndex } from '../schemas/templateSchema' import type { TemplateGroup, TemplateInfo, @@ -31,6 +33,7 @@ export const useWorkflowTemplatesStore = defineStore( const customTemplates = shallowRef<{ [moduleName: string]: string[] }>({}) const coreTemplates = shallowRef([]) const englishTemplates = shallowRef([]) + const logoIndex = shallowRef({}) const isLoaded = ref(false) const knownTemplateNames = ref(new Set()) @@ -475,15 +478,18 @@ export const useWorkflowTemplatesStore = defineStore( customTemplates.value = await api.getWorkflowTemplates() const locale = i18n.global.locale.value - const [coreResult, englishResult] = await Promise.all([ - api.getCoreWorkflowTemplates(locale), - isCloud && locale !== 'en' - ? api.getCoreWorkflowTemplates('en') - : Promise.resolve([]) - ]) + const [coreResult, englishResult, logoIndexResult] = + await Promise.all([ + api.getCoreWorkflowTemplates(locale), + isCloud && locale !== 'en' + ? api.getCoreWorkflowTemplates('en') + : Promise.resolve([]), + fetchLogoIndex() + ]) coreTemplates.value = coreResult englishTemplates.value = englishResult + logoIndex.value = logoIndexResult const coreNames = coreTemplates.value.flatMap((category) => category.templates.map((template) => template.name) @@ -498,6 +504,36 @@ export const useWorkflowTemplatesStore = defineStore( } } + async function fetchLogoIndex(): Promise { + try { + const response = await api.fetchApi('/templates/index_logo.json') + const contentType = response.headers.get('content-type') + if (!contentType?.includes('application/json')) return {} + const data = await response.json() + const result = zLogoIndex.safeParse(data) + return result.success ? result.data : {} + } catch { + return {} + } + } + + function getLogoUrl(provider: string): string { + const logoPath = logoIndex.value[provider] + if (!logoPath) return '' + + // Validate path to prevent directory traversal and ensure safe file extensions + const safePathPattern = /^[a-zA-Z0-9_\-./]+\.(png|jpg|jpeg|svg|webp)$/i + if ( + !safePathPattern.test(logoPath) || + logoPath.includes('..') || + logoPath.startsWith('/') + ) { + return '' + } + + return api.fileURL(`/templates/${logoPath}`) + } + function getEnglishMetadata(templateName: string): { tags?: string[] category?: string @@ -534,7 +570,8 @@ export const useWorkflowTemplatesStore = defineStore( loadWorkflowTemplates, knownTemplateNames, getTemplateByName, - getEnglishMetadata + getEnglishMetadata, + getLogoUrl } } ) diff --git a/src/platform/workflow/templates/schemas/templateSchema.ts b/src/platform/workflow/templates/schemas/templateSchema.ts new file mode 100644 index 000000000..148bd6357 --- /dev/null +++ b/src/platform/workflow/templates/schemas/templateSchema.ts @@ -0,0 +1,5 @@ +import { z } from 'zod' + +export const zLogoIndex = z.record(z.string(), z.string()) + +export type LogoIndex = z.infer diff --git a/src/platform/workflow/templates/types/template.ts b/src/platform/workflow/templates/types/template.ts index 1a9d839f8..2d52a5629 100644 --- a/src/platform/workflow/templates/types/template.ts +++ b/src/platform/workflow/templates/types/template.ts @@ -1,3 +1,16 @@ +export interface LogoInfo { + /** Provider name(s) matching index_logo.json. String for single, array for stacked logos. */ + provider: string | string[] + /** Custom label text. If omitted, defaults to provider names joined with " & " */ + label?: string + /** Gap between stacked logos in pixels. Negative for overlap effect. Default: -6 */ + gap?: number + /** Tailwind positioning classes */ + position?: string + /** Opacity 0-1, default 0.85 */ + opacity?: number +} + export interface TemplateInfo { name: string /** @@ -47,6 +60,10 @@ export interface TemplateInfo { * If not specified, the template will be included on all distributions. */ includeOnDistributions?: TemplateIncludeOnDistributionEnum[] + /** + * Logo overlays to display on the template thumbnail. + */ + logos?: LogoInfo[] } export enum TemplateIncludeOnDistributionEnum { From 69129414d0c4c1b8cef48602a061770355abc962 Mon Sep 17 00:00:00 2001 From: Alexander Brown Date: Sat, 31 Jan 2026 21:07:13 -0800 Subject: [PATCH 17/61] fix: use PR_GH_TOKEN to trigger e2e after updating expectations (#8525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary See https://github.com/Comfy-Org/ComfyUI_frontend/pull/8484 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8525-fix-use-PR_GH_TOKEN-to-trigger-e2e-after-updating-expectations-2fa6d73d3650817c81fccd1cbb3ddadb) by [Unito](https://www.unito.io) --- .github/workflows/pr-update-playwright-expectations.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pr-update-playwright-expectations.yaml b/.github/workflows/pr-update-playwright-expectations.yaml index 1372c8126..c0865f85b 100644 --- a/.github/workflows/pr-update-playwright-expectations.yaml +++ b/.github/workflows/pr-update-playwright-expectations.yaml @@ -173,6 +173,7 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.setup.outputs.branch }} + token: ${{ secrets.PR_GH_TOKEN }} # Download all changed snapshot files from shards - name: Download snapshot artifacts From 544ef5bb706316a05d6ab8c4263487ccc7c664c3 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sat, 31 Jan 2026 22:01:14 -0800 Subject: [PATCH 18/61] fix: dedupe queueStore.update() to prevent race conditions (#8523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Prevents stale API responses from overwriting fresh queue state during rapid consecutive `update()` calls. ## Problem When `queueStore.update()` is called multiple times in quick succession (e.g., during websocket reconnection), responses could resolve out-of-order. A stale response resolving after a fresh one would overwrite the current state with outdated data. ## Solution Added request ID tracking that: 1. Increments a counter on each `update()` call 2. Guards all state mutations with a staleness check 3. Only sets `isLoading=false` for the latest request This pattern matches the existing approach in `jobOutputCache.ts`. ## Changes - `src/stores/queueStore.ts`: Added `updateRequestId` counter and staleness guards - `src/stores/queueStore.test.ts`: Added tests for deduplication behavior ## Testing - All 49 existing tests pass - Added 3 new tests for race condition handling: - Stale responses are discarded - isLoading state is correctly managed - Stale request failures don't affect latest state ## Fixes COM-12784 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8523-fix-dedupe-queueStore-update-to-prevent-race-conditions-2fa6d73d3650810daa0dc63af359e1ea) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp --- src/stores/queueStore.test.ts | 105 ++++++++++++++++++++++++++++++++++ src/stores/queueStore.ts | 13 ++++- 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/src/stores/queueStore.test.ts b/src/stores/queueStore.test.ts index a183d3c54..d2f314590 100644 --- a/src/stores/queueStore.test.ts +++ b/src/stores/queueStore.test.ts @@ -49,6 +49,9 @@ const createTaskOutput = ( } }) +type QueueResponse = { Running: JobListItem[]; Pending: JobListItem[] } +type QueueResolver = (value: QueueResponse) => void + // Mock API vi.mock('@/scripts/api', () => ({ api: { @@ -802,4 +805,106 @@ describe('useQueueStore', () => { expect(mockGetHistory).toHaveBeenCalled() }) }) + + describe('update deduplication', () => { + it('should discard stale responses when newer request completes first', async () => { + let resolveFirst: QueueResolver + let resolveSecond: QueueResolver + + const firstQueuePromise = new Promise((resolve) => { + resolveFirst = resolve + }) + const secondQueuePromise = new Promise((resolve) => { + resolveSecond = resolve + }) + + mockGetHistory.mockResolvedValue([]) + + mockGetQueue + .mockReturnValueOnce(firstQueuePromise) + .mockReturnValueOnce(secondQueuePromise) + + const firstUpdate = store.update() + const secondUpdate = store.update() + + resolveSecond!({ Running: [], Pending: [createPendingJob(2, 'new-job')] }) + await secondUpdate + + expect(store.pendingTasks).toHaveLength(1) + expect(store.pendingTasks[0].promptId).toBe('new-job') + + resolveFirst!({ + Running: [], + Pending: [createPendingJob(1, 'stale-job')] + }) + await firstUpdate + + expect(store.pendingTasks).toHaveLength(1) + expect(store.pendingTasks[0].promptId).toBe('new-job') + }) + + it('should set isLoading to false only for the latest request', async () => { + let resolveFirst: QueueResolver + let resolveSecond: QueueResolver + + const firstQueuePromise = new Promise((resolve) => { + resolveFirst = resolve + }) + const secondQueuePromise = new Promise((resolve) => { + resolveSecond = resolve + }) + + mockGetHistory.mockResolvedValue([]) + + mockGetQueue + .mockReturnValueOnce(firstQueuePromise) + .mockReturnValueOnce(secondQueuePromise) + + const firstUpdate = store.update() + expect(store.isLoading).toBe(true) + + const secondUpdate = store.update() + expect(store.isLoading).toBe(true) + + resolveSecond!({ Running: [], Pending: [] }) + await secondUpdate + + expect(store.isLoading).toBe(false) + + resolveFirst!({ Running: [], Pending: [] }) + await firstUpdate + + expect(store.isLoading).toBe(false) + }) + + it('should handle stale request failure without affecting latest state', async () => { + let resolveSecond: QueueResolver + + const secondQueuePromise = new Promise((resolve) => { + resolveSecond = resolve + }) + + mockGetHistory.mockResolvedValue([]) + + mockGetQueue + .mockRejectedValueOnce(new Error('stale network error')) + .mockReturnValueOnce(secondQueuePromise) + + const firstUpdate = store.update() + const secondUpdate = store.update() + + resolveSecond!({ Running: [], Pending: [createPendingJob(2, 'new-job')] }) + await secondUpdate + + expect(store.pendingTasks).toHaveLength(1) + expect(store.pendingTasks[0].promptId).toBe('new-job') + expect(store.isLoading).toBe(false) + + await expect(firstUpdate).rejects.toThrow('stale network error') + + expect(store.pendingTasks).toHaveLength(1) + expect(store.pendingTasks[0].promptId).toBe('new-job') + expect(store.isLoading).toBe(false) + }) + }) }) diff --git a/src/stores/queueStore.ts b/src/stores/queueStore.ts index c302a1076..2b9becf18 100644 --- a/src/stores/queueStore.ts +++ b/src/stores/queueStore.ts @@ -475,6 +475,9 @@ export const useQueueStore = defineStore('queue', () => { const maxHistoryItems = ref(64) const isLoading = ref(false) + // Scoped per-store instance; incremented to dedupe concurrent update() calls + let updateRequestId = 0 + const tasks = computed( () => [ @@ -498,6 +501,7 @@ export const useQueueStore = defineStore('queue', () => { ) const update = async () => { + const requestId = ++updateRequestId isLoading.value = true try { const [queue, history] = await Promise.all([ @@ -505,6 +509,8 @@ export const useQueueStore = defineStore('queue', () => { api.getHistory(maxHistoryItems.value) ]) + if (requestId !== updateRequestId) return + // API returns pre-sorted data (sort_by=create_time&order=desc) runningTasks.value = queue.Running.map((job) => new TaskItemImpl(job)) pendingTasks.value = queue.Pending.map((job) => new TaskItemImpl(job)) @@ -545,7 +551,12 @@ export const useQueueStore = defineStore('queue', () => { return existing }) } finally { - isLoading.value = false + // Only clear loading if this is the latest request. + // A stale request completing (success or error) should not touch loading state + // since a newer request is responsible for it. + if (requestId === updateRequestId) { + isLoading.value = false + } } } From 4e20b7522bdba4852a8be44df4f868e145778f4c Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Sun, 1 Feb 2026 06:32:10 -0800 Subject: [PATCH 19/61] Forward scroll unless focused (#6597) ## Summary Forward wheel events to the canvas unless a wheel-capturing element is focused, and ensure the Load3D scene becomes focusable on pointer interaction so its wheel zoom/pan works after the user clicks into it. ## Changes - **What**: gate wheel forwarding on focused capture elements; focus the Load3D scene container on pointerdown to opt into wheel capture. - **Dependencies**: none ## Review Focus - Validate wheel forwarding behavior across focusable inputs vs. non-focusable capture zones. - Confirm Load3D zoom/pan only captures wheel after a user click (canvas pan should still work when merely hovering). ## Screenshots (if applicable) N/A --------- Co-authored-by: Christian Byrne Co-authored-by: Subagent 5 Co-authored-by: Amp --- src/components/load3d/Load3DScene.vue | 7 +- .../core/canvas/useCanvasInteractions.test.ts | 67 +++++++++++++++++++ .../core/canvas/useCanvasInteractions.ts | 38 ++++++----- 3 files changed, 95 insertions(+), 17 deletions(-) diff --git a/src/components/load3d/Load3DScene.vue b/src/components/load3d/Load3DScene.vue index de2afbdb2..7f9e4a8d4 100644 --- a/src/components/load3d/Load3DScene.vue +++ b/src/components/load3d/Load3DScene.vue @@ -3,7 +3,8 @@ ref="container" class="relative h-full w-full min-h-[200px]" data-capture-wheel="true" - @pointerdown.stop + tabindex="-1" + @pointerdown.stop="focusContainer" @pointermove.stop @pointerup.stop @mousedown.stop @@ -45,6 +46,10 @@ const props = defineProps<{ const container = ref(null) +function focusContainer() { + container.value?.focus() +} + const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } = useLoad3dDrag({ onModelDrop: async (file) => { diff --git a/src/renderer/core/canvas/useCanvasInteractions.test.ts b/src/renderer/core/canvas/useCanvasInteractions.test.ts index b1287a2e7..cce86a037 100644 --- a/src/renderer/core/canvas/useCanvasInteractions.test.ts +++ b/src/renderer/core/canvas/useCanvasInteractions.test.ts @@ -155,5 +155,72 @@ describe('useCanvasInteractions', () => { expect(mockEvent.preventDefault).not.toHaveBeenCalled() expect(mockEvent.stopPropagation).not.toHaveBeenCalled() }) + it('should forward wheel events to canvas when capture element is NOT focused', () => { + const { get } = useSettingStore() + vi.mocked(get).mockReturnValue('legacy') + + const captureElement = document.createElement('div') + captureElement.setAttribute('data-capture-wheel', 'true') + const textarea = document.createElement('textarea') + captureElement.appendChild(textarea) + document.body.appendChild(captureElement) + + const { handleWheel } = useCanvasInteractions() + const mockEvent = createMockWheelEvent() + Object.defineProperty(mockEvent, 'target', { value: textarea }) + + handleWheel(mockEvent) + + expect(mockEvent.preventDefault).toHaveBeenCalled() + expect(mockEvent.stopPropagation).toHaveBeenCalled() + + document.body.removeChild(captureElement) + }) + + it('should NOT forward wheel events when capture element IS focused', () => { + const { get } = useSettingStore() + vi.mocked(get).mockReturnValue('legacy') + + const captureElement = document.createElement('div') + captureElement.setAttribute('data-capture-wheel', 'true') + const textarea = document.createElement('textarea') + captureElement.appendChild(textarea) + document.body.appendChild(captureElement) + textarea.focus() + + const { handleWheel } = useCanvasInteractions() + const mockEvent = createMockWheelEvent() + Object.defineProperty(mockEvent, 'target', { value: textarea }) + + handleWheel(mockEvent) + + expect(mockEvent.preventDefault).not.toHaveBeenCalled() + expect(mockEvent.stopPropagation).not.toHaveBeenCalled() + + document.body.removeChild(captureElement) + }) + + it('should forward ctrl+wheel to canvas when capture element IS focused in standard mode', () => { + const { get } = useSettingStore() + vi.mocked(get).mockReturnValue('standard') + + const captureElement = document.createElement('div') + captureElement.setAttribute('data-capture-wheel', 'true') + const textarea = document.createElement('textarea') + captureElement.appendChild(textarea) + document.body.appendChild(captureElement) + textarea.focus() + + const { handleWheel } = useCanvasInteractions() + const mockEvent = createMockWheelEvent(true) + Object.defineProperty(mockEvent, 'target', { value: textarea }) + + handleWheel(mockEvent) + + expect(mockEvent.preventDefault).toHaveBeenCalled() + expect(mockEvent.stopPropagation).toHaveBeenCalled() + + document.body.removeChild(captureElement) + }) }) }) diff --git a/src/renderer/core/canvas/useCanvasInteractions.ts b/src/renderer/core/canvas/useCanvasInteractions.ts index 5d9804e13..b93d4ff15 100644 --- a/src/renderer/core/canvas/useCanvasInteractions.ts +++ b/src/renderer/core/canvas/useCanvasInteractions.ts @@ -26,19 +26,31 @@ export function useCanvasInteractions() { () => !(canvasStore.canvas?.read_only ?? false) ) + /** + * Returns true if the wheel event target is inside an element that should + * capture wheel events AND that element (or a descendant) currently has focus. + * + * This allows two-finger panning to continue over inputs until the user has + * actively focused the widget, at which point the widget can consume scroll. + */ + const wheelCapturedByFocusedElement = (event: WheelEvent): boolean => { + const target = event.target as HTMLElement | null + const captureElement = target?.closest('[data-capture-wheel="true"]') + const active = document.activeElement as Element | null + + return !!(captureElement && active && captureElement.contains(active)) + } + + const shouldForwardWheelEvent = (event: WheelEvent): boolean => + !wheelCapturedByFocusedElement(event) || + (isStandardNavMode.value && (event.ctrlKey || event.metaKey)) + /** * Handles wheel events from UI components that should be forwarded to canvas * when appropriate (e.g., Ctrl+wheel for zoom in standard mode) */ const handleWheel = (event: WheelEvent) => { - // Check if the wheel event is from an element that wants to capture wheel events - const target = event.target as HTMLElement - const captureElement = target?.closest('[data-capture-wheel="true"]') - - if (captureElement) { - // Element wants to capture wheel events, don't forward to canvas - return - } + if (!shouldForwardWheelEvent(event)) return // In standard mode, Ctrl+wheel should go to canvas for zoom if (isStandardNavMode.value && (event.ctrlKey || event.metaKey)) { @@ -87,14 +99,8 @@ export function useCanvasInteractions() { const forwardEventToCanvas = ( event: WheelEvent | PointerEvent | MouseEvent ) => { - // Check if the wheel event is from an element that wants to capture wheel events - const target = event.target as HTMLElement - const captureElement = target?.closest('[data-capture-wheel="true"]') - - if (captureElement) { - // Element wants to capture wheel events, don't forward to canvas - return - } + // Honor wheel capture only when the element is focused + if (event instanceof WheelEvent && !shouldForwardWheelEvent(event)) return const canvasEl = app.canvas?.canvas if (!canvasEl) return From eaa3ff15794a4813a4c1cd9c881489bff5af1893 Mon Sep 17 00:00:00 2001 From: Alexander Brown Date: Sun, 1 Feb 2026 20:01:18 -0800 Subject: [PATCH 20/61] feat: add ownership and base model filtering, unify asset/dropdown types (#8497) Add ownership and base model filtering to AssetBrowserModal and FormDropdown widgets. ## Changes - **Ownership filter**: Filter by All/My Models/Public Models (uses `is_immutable` field) - **Base model filter**: Multi-select filter with Clear Filters button - **Type unification**: Replace `AssetDropdownItem` with `FormDropdownItem` - **Sorting unification**: Extract shared utilities to `assetSortUtils.ts` - **UI refactor**: Use `Button` component, Vue 3.5 prop shorthand, i18n improvements --------- Co-authored-by: Amp --- src/locales/en/main.json | 2 + .../assets/components/AssetBrowserModal.vue | 12 +- .../assets/components/AssetFilterBar.test.ts | 11 +- .../assets/components/AssetFilterBar.vue | 39 +-- .../composables/useAssetBrowser.test.ts | 143 +++++++++- .../assets/composables/useAssetBrowser.ts | 86 ++---- .../composables/useAssetFilterOptions.test.ts | 9 +- .../composables/useAssetFilterOptions.ts | 14 +- src/platform/assets/types/filterTypes.ts | 48 ++++ .../assets/utils/assetFilterUtils.test.ts | 181 +++++++++++++ src/platform/assets/utils/assetFilterUtils.ts | 66 +++++ .../assets/utils/assetSortUtils.test.ts | 122 +++++++++ src/platform/assets/utils/assetSortUtils.ts | 61 +++++ .../WidgetSelect.asset-mode.test.ts | 9 +- .../widgets/components/WidgetSelect.test.ts | 17 +- .../components/WidgetSelectDropdown.test.ts | 48 ++-- .../components/WidgetSelectDropdown.vue | 133 +++++++--- .../components/form/FormSearchInput.vue | 6 +- .../components/form/dropdown/FormDropdown.vue | 93 ++++--- .../form/dropdown/FormDropdownInput.vue | 8 +- .../form/dropdown/FormDropdownMenu.vue | 52 ++-- .../form/dropdown/FormDropdownMenuActions.vue | 247 +++++++++++++++--- .../form/dropdown/FormDropdownMenuFilter.vue | 12 +- .../form/dropdown/FormDropdownMenuItem.vue | 17 +- .../components/form/dropdown/shared.test.ts | 13 +- .../components/form/dropdown/shared.ts | 47 ++-- .../widgets/components/form/dropdown/types.ts | 31 ++- .../useAssetWidgetData.desktop.test.ts | 4 +- .../composables/useAssetWidgetData.test.ts | 19 +- .../widgets/composables/useAssetWidgetData.ts | 18 +- 30 files changed, 1200 insertions(+), 368 deletions(-) create mode 100644 src/platform/assets/types/filterTypes.ts create mode 100644 src/platform/assets/utils/assetFilterUtils.test.ts create mode 100644 src/platform/assets/utils/assetFilterUtils.ts create mode 100644 src/platform/assets/utils/assetSortUtils.test.ts create mode 100644 src/platform/assets/utils/assetSortUtils.ts diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 71d243b40..cd3671bd5 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2504,6 +2504,7 @@ "imported": "Imported", "assetCollection": "Asset collection", "assets": "Assets", + "baseModel": "Base model", "baseModels": "Base models", "browseAssets": "Browse Assets", "checkpoints": "Checkpoints", @@ -2558,6 +2559,7 @@ "selectModelType": "Select model type", "selectProjects": "Select Projects", "sortAZ": "A-Z", + "sortDefault": "Default", "sortBy": "Sort by", "sortingType": "Sorting Type", "sortPopular": "Popular", diff --git a/src/platform/assets/components/AssetBrowserModal.vue b/src/platform/assets/components/AssetBrowserModal.vue index 05f0c1efc..779be651e 100644 --- a/src/platform/assets/components/AssetBrowserModal.vue +++ b/src/platform/assets/components/AssetBrowserModal.vue @@ -51,6 +51,7 @@