Compare commits

...

53 Commits

Author SHA1 Message Date
Benjamin Lu
f5726e5948 [test] Add test to verify targetSelector property deletion
Add a specific test case to ensure the keybinding migration properly
deletes the deprecated 'targetSelector' property after migrating to
'targetElementId'. This test provides additional coverage to verify
the migration doesn't leave behind obsolete properties.

The test verifies:
- The old 'targetSelector' property is removed
- The new 'targetElementId' property is added with correct value
- Other properties remain unchanged
2025-07-01 19:21:02 -04:00
Benjamin Lu
bd0df83a7c [fix] Improve type safety in keybinding migration
Replace 'any' type with proper 'Keybinding[]' type annotation in the
keybinding migration. Also create new objects during migration to avoid
mutating the original keybinding data, which follows immutability best
practices and prevents potential side effects.

This ensures better type checking and makes the migration code more
robust and maintainable.
2025-07-01 19:20:26 -04:00
Benjamin Lu
c5edbe588c [refactor] Move setting migrations to settingStore
Relocate runSettingMigrations() from GraphCanvas.vue to settingStore's
loadSettingValues() method. This improves separation of concerns by keeping
data migration logic within the store rather than coupling it to UI components.

The migrations now run at the optimal time: after loading setting values from
the server but before any settings are registered, ensuring proper initialization
order and preventing potential race conditions.

This change makes the architecture cleaner and more maintainable by centralizing
all setting-related logic in the appropriate store.
2025-07-01 19:19:51 -04:00
Benjamin Lu
6def711414 Add tests 2025-07-01 18:54:40 -04:00
Benjamin Lu
472f90799d [refactor] Consolidate settings migration logic into dedicated utility
- Extract all migrateDeprecatedValue logic from individual settings into centralized settingsMigration.ts
- Remove migrateDeprecatedValue from SettingParams interface and coreSettings definitions
- Simplify settingStore by removing tryMigrateDeprecatedValue function
- Ensure migrations run after loadSettingValues() for clean initialization flow
- Update tests to reflect new migration approach

This refactor centralizes all setting value migrations in one place, making them easier to maintain and avoiding timing issues with settings that don't exist yet.
2025-07-01 18:47:04 -04:00
Comfy Org PR Bot
4c177121a6 [chore] Update Comfy Registry API types from comfy-api@065aded (#4274)
Co-authored-by: bmcomfy <214909599+bmcomfy@users.noreply.github.com>
2025-06-25 23:57:37 +00:00
Jin Yi
63181a1ddd [Manager] Standardize Card Aspect Ratios & Enhance UI (#4271)
Co-authored-by: Claude <noreply@anthropic.com>
2025-06-25 12:34:19 -07:00
Jin Yi
e17ca7ce71 fix: node migration TypeError (#4260) 2025-06-25 03:01:40 -07:00
Comfy Org PR Bot
77d2cae301 1.23.2 (#4266)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-06-25 00:48:39 +00:00
Comfy Org PR Bot
164a4c4c25 [chore] Update Comfy Registry API types from comfy-api@af72ba5 (#4264)
Co-authored-by: bmcomfy <214909599+bmcomfy@users.noreply.github.com>
2025-06-24 14:57:41 -07:00
Jin Yi
47145ce4b8 [Manager] Modal UI Adjustment (Align with Design) (#4222) 2025-06-23 21:30:56 -07:00
Christian Byrne
6cf77a9814 [Manager] Fix bug: installed packs metadata not re-fetched after installations (#4254) 2025-06-23 04:37:50 -07:00
Christian Byrne
886e4908d4 [Manager] Fix flush timing issue when switching tabs (#4253) 2025-06-23 03:49:47 -07:00
Christian Byrne
24cbc41832 [Manager] Fix bug: opening modal when last focused tab was 'Installed' always shows empty list (#4252) 2025-06-23 02:41:15 -07:00
Christian Byrne
a80a939324 Fix: virtual grid scrolling bug when container is rendered with emtpy items (switching tabs) (#4251) 2025-06-23 00:13:46 -07:00
Christian Byrne
8e2d7cabba Fix bug: drag-and-drop, copy-paste, and upload don't work in nodes that specify upload folder that isn't 'input' (#4186) 2025-06-22 20:18:36 -07:00
Christian Byrne
e8dd26ff59 [Manager] Fix: When using registry search provider, results not properly paginated' (#4249) 2025-06-22 20:05:37 -07:00
Christian Byrne
3a1bd1829a [feat] Add auto-refresh on task completion for RemoteWidget nodes (#4191)
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-06-22 17:30:24 -07:00
ComfyUI Wiki
2f9dcd1669 Fix: fix typo in Lite Graph settings (#4245) 2025-06-22 08:32:09 +00:00
filtered
e23547dd5a [TS] Remove expect-error (type fix) (#4235) 2025-06-21 20:52:35 -07:00
Comfy Org PR Bot
f0f40bc39b 1.23.1 (#4234)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-06-21 18:37:51 +00:00
Christian Byrne
4b32786ef5 [Manager] Update Algolia mappings (#4230)
Co-authored-by: Claude <noreply@anthropic.com>
2025-06-21 11:09:14 -07:00
Comfy Org PR Bot
9942b17388 [chore] Update Comfy Registry API types from comfy-api@4286a10 (#4229)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-06-20 15:47:04 -07:00
Christian Byrne
b99214bf5e [feat] Show version-specific missing core nodes in workflow warnings (#4227)
Co-authored-by: github-actions <github-actions@github.com>
2025-06-20 15:33:47 -07:00
Christian Byrne
2ef760c599 [Manager] Keep progress dialog on top using priority system (#4225) 2025-06-20 15:22:42 -07:00
Christian Byrne
429ab6c365 [Manager] Fix "total nodes" count when selecting multiple packs (#4228) 2025-06-20 15:20:26 -07:00
ComfyUI Wiki
b7693ae9f5 Fix typo in 3D settings (#4224) 2025-06-20 13:26:40 -07:00
filtered
ebedf1074d [CI] Fix intermittent actions/cache errors (#4220) 2025-06-18 03:55:05 -07:00
filtered
0832347f47 [CI] Fix intermittent failure when using actions/cache (#4219) 2025-06-18 01:24:42 -07:00
filtered
c745af0f25 [Test] Fix vitest scope overlaps playwright tests (#4218) 2025-06-18 01:08:30 -07:00
Comfy Org PR Bot
8c05266b83 1.23.0 (#4217)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-06-18 00:32:54 -07:00
Jin Yi
fa14ec52f4 [Manager] Impletent “Install All” button (#4196)
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: comfy-waifu <comfywaifu.ai@gmail.com>
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
2025-06-18 10:52:24 +09:00
Christian Byrne
ec9da0b6c5 [refactor] Add ResultItemType and improve image upload typing (#4200) 2025-06-16 14:31:24 -07:00
Christian Byrne
98bb1df436 [refactor] introduce frontend type augmentation pattern (#4192) 2025-06-16 11:32:07 -07:00
Christian Byrne
75077fe9ed [Manager] Add registry search fallback with gateway pattern (#4187) 2025-06-15 17:22:05 -07:00
filtered
d5ecfb2c99 Revert "[refactor] Refactor and type image upload options" (#4190) 2025-06-15 12:17:54 -07:00
Christian Byrne
3211875084 [refactor] Refactor and type image upload options (#4185) 2025-06-15 12:07:26 -07:00
Christian Byrne
a6bd04f951 [Manager] Make dialog closeable with button and hotkey (#4179)
Co-authored-by: github-actions <github-actions@github.com>
2025-06-14 15:21:43 -07:00
Christian Byrne
5b32d2aad0 [Manager] Persist/Restore Manager UI state (#4180) 2025-06-14 15:19:56 -07:00
Christian Byrne
23ba7e6501 [Manager] Fix version selector popover not closing when selecting different pack (#4176) 2025-06-14 15:06:32 -07:00
Comfy Org PR Bot
1e2b16f14d 1.22.2 (#4170)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-06-14 07:19:53 +00:00
Christian Byrne
ec27d50333 [Manager] Fix selection state race condition during pack data merge (#4165) 2025-06-13 23:46:53 -07:00
Christian Byrne
693e156ab2 [Manager] Update PackCard styling to match Figma design (#4164) 2025-06-13 22:27:34 -07:00
comfy-waifu
8274df5075 Fixed favicon some progress frames not found - by ComfyWaifu 🤍 (#4143) 2025-06-13 21:59:35 -07:00
Christian Byrne
55bf36564d [Manager] Fix card selection highlight z-index and border radius issues (#4160) 2025-06-13 21:19:11 -07:00
Christian Byrne
48ac4a2b36 [Manager] Fix race condition in pack selection (#4158) 2025-06-14 03:53:06 +00:00
Christian Byrne
c9c1275e4c [Manager] Add enable/disable toggle for installed node packs (#4157) 2025-06-13 20:43:38 -07:00
Terry Jia
78ebc54ebe [3d] bugfix for preview manager (#4147) 2025-06-13 17:34:45 -07:00
Christian Byrne
88f2cc7847 [Manager] Refactor search result types (#4154) 2025-06-13 15:08:55 -07:00
Christian Byrne
7907e206da [Types] Remove outdated type intersection (#4146) 2025-06-13 14:08:59 -07:00
Christian Byrne
c4fa3dfe5a [Manager] Fix: fetch repeated infitely if no node packs installed (#4145) 2025-06-13 13:57:03 -07:00
filtered
587d7a19a1 [TS] Improve various types / remove assertions (#4148) 2025-06-13 01:46:50 -07:00
Jin Yi
9ca705381c Update fallback banner layout (#4141)
Co-authored-by: github-actions <github-actions@github.com>
2025-06-12 11:04:55 -07:00
87 changed files with 4849 additions and 743 deletions

View File

@@ -46,8 +46,8 @@ jobs:
id: cache-key
run: echo "key=$(date +%s)" >> $GITHUB_OUTPUT
- name: Cache setup
uses: actions/cache@v3
- name: Save cache
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684
with:
path: |
ComfyUI
@@ -62,9 +62,13 @@ jobs:
matrix:
browser: [chromium, chromium-2x, mobile-chrome]
steps:
- name: Wait for cache propagation
run: sleep 10
- name: Restore cached setup
uses: actions/cache@v3
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684
with:
fail-on-cache-miss: true
path: |
ComfyUI
ComfyUI_frontend

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.22.1",
"version": "1.23.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.22.1",
"version": "1.23.2",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.22.1",
"version": "1.23.2",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -1,15 +1,12 @@
<template>
<hr
<div
:class="{
'm-0': true,
'border-t': orientation === 'horizontal',
'border-l': orientation === 'vertical',
'h-full': orientation === 'vertical',
'w-full': orientation === 'horizontal'
'content-divider': true,
'content-divider--horizontal': orientation === 'horizontal',
'content-divider--vertical': orientation === 'vertical'
}"
:style="{
borderColor: isLightTheme ? '#DCDAE1' : '#2C2C2C',
borderWidth: `${width}px !important`
backgroundColor: isLightTheme ? '#DCDAE1' : '#2C2C2C'
}"
/>
</template>
@@ -29,3 +26,25 @@ const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
)
</script>
<style scoped>
.content-divider {
display: inline-block;
margin: 0;
padding: 0;
border: none;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.content-divider--horizontal {
width: 100%;
height: v-bind('width + "px"');
}
.content-divider--vertical {
height: 100%;
width: v-bind('width + "px"');
}
</style>

View File

@@ -92,12 +92,21 @@ whenever(
const updateItemSize = () => {
if (container.value) {
const firstItem = container.value.querySelector('[data-virtual-grid-item]')
itemHeight.value = firstItem?.clientHeight || defaultItemHeight
itemWidth.value = firstItem?.clientWidth || defaultItemWidth
// Don't update item size if the first item is not rendered yet
if (!firstItem?.clientHeight || !firstItem?.clientWidth) return
if (itemHeight.value !== firstItem.clientHeight) {
itemHeight.value = firstItem.clientHeight
}
if (itemWidth.value !== firstItem.clientWidth) {
itemWidth.value = firstItem.clientWidth
}
}
}
const onResize = debounce(updateItemSize, resizeDebounce)
watch([width, height], onResize, { flush: 'post' })
whenever(() => items, updateItemSize, { flush: 'post' })
onBeforeUnmount(() => {
onResize.cancel() // Clear pending debounced calls
})

View File

@@ -50,4 +50,17 @@ const dialogStore = useDialogStore()
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
@apply pt-0;
}
.manager-dialog {
height: 80vh;
max-width: 1724px;
max-height: 1026px;
}
@media (min-width: 3000px) {
.manager-dialog {
max-width: 2200px;
max-height: 1320px;
}
}
</style>

View File

@@ -5,6 +5,7 @@
title="Missing Node Types"
message="When loading the graph, the following node types were not found"
/>
<MissingCoreNodesMessage :missing-core-nodes="missingCoreNodes" />
<ListBox
:options="uniqueNodes"
option-label="label"
@@ -31,6 +32,12 @@
</template>
</ListBox>
<div v-if="isManagerInstalled" class="flex justify-end py-3">
<PackInstallButton
:disabled="isLoading || !!error || missingNodePacks.length === 0"
:node-packs="missingNodePacks"
variant="black"
:label="$t('manager.installAllMissingNodes')"
/>
<Button label="Open Manager" size="small" outlined @click="openManager" />
</div>
</template>
@@ -41,6 +48,9 @@ import ListBox from 'primevue/listbox'
import { computed } from 'vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import { useDialogService } from '@/services/dialogService'
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
import type { MissingNodeType } from '@/types/comfy'
@@ -52,6 +62,10 @@ const props = defineProps<{
const aboutPanelStore = useAboutPanelStore()
// Get missing node packs from workflow with loading and error states
const { missingNodePacks, isLoading, error, missingCoreNodes } =
useMissingNodes()
// Determines if ComfyUI-Manager is installed by checking for its badge in the about panel
// This allows us to conditionally show the Manager button only when the extension is available
// TODO: Remove this check when Manager functionality is fully migrated into core

View File

@@ -0,0 +1,95 @@
<template>
<Message
v-if="hasMissingCoreNodes"
severity="info"
icon="pi pi-info-circle"
class="my-2 mx-2"
:pt="{
root: { class: 'flex-col' },
text: { class: 'flex-1' }
}"
>
<div class="flex flex-col gap-2">
<div>
{{
currentComfyUIVersion
? $t('loadWorkflowWarning.outdatedVersion', {
version: currentComfyUIVersion
})
: $t('loadWorkflowWarning.outdatedVersionGeneric')
}}
</div>
<div
v-for="[version, nodes] in sortedMissingCoreNodes"
:key="version"
class="ml-4"
>
<div
class="text-sm font-medium text-surface-600 dark-theme:text-surface-400"
>
{{
$t('loadWorkflowWarning.coreNodesFromVersion', {
version: version || 'unknown'
})
}}
</div>
<div class="ml-4 text-sm text-surface-500 dark-theme:text-surface-500">
{{ getUniqueNodeNames(nodes).join(', ') }}
</div>
</div>
</div>
</Message>
</template>
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import { whenever } from '@vueuse/core'
import Message from 'primevue/message'
import { computed, ref } from 'vue'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { compareVersions } from '@/utils/formatUtil'
const props = defineProps<{
missingCoreNodes: Record<string, LGraphNode[]>
}>()
const systemStatsStore = useSystemStatsStore()
const hasMissingCoreNodes = computed(() => {
return Object.keys(props.missingCoreNodes).length > 0
})
const currentComfyUIVersion = ref<string | null>(null)
whenever(
hasMissingCoreNodes,
async () => {
if (!systemStatsStore.systemStats) {
await systemStatsStore.fetchSystemStats()
}
currentComfyUIVersion.value =
systemStatsStore.systemStats?.system?.comfyui_version ?? null
},
{
immediate: true
}
)
const sortedMissingCoreNodes = computed(() => {
return Object.entries(props.missingCoreNodes).sort(([a], [b]) => {
// Sort by version in descending order (newest first)
return compareVersions(b, a) // Reversed for descending order
})
})
const getUniqueNodeNames = (nodes: LGraphNode[]): string[] => {
return nodes
.reduce<string[]>((acc, node) => {
if (node.type && !acc.includes(node.type)) {
acc.push(node.type)
}
return acc
}, [])
.sort()
}
</script>

View File

@@ -1,14 +1,16 @@
<template>
<div
class="flex flex-col mx-auto overflow-hidden h-[83vh] relative"
class="h-full flex flex-col mx-auto overflow-hidden"
:aria-label="$t('manager.title')"
>
<ContentDivider :width="0.3" />
<Button
v-if="isSmallScreen"
:icon="isSideNavOpen ? 'pi pi-chevron-left' : 'pi pi-chevron-right'"
text
severity="secondary"
filled
class="absolute top-1/2 -translate-y-1/2 z-10"
:class="isSideNavOpen ? 'left-[19rem]' : 'left-2'"
:class="isSideNavOpen ? 'left-[12rem]' : 'left-2'"
@click="toggleSideNav"
/>
<div class="flex flex-1 relative overflow-hidden">
@@ -18,20 +20,20 @@
:tabs="tabs"
/>
<div
class="flex-1 overflow-auto pr-80"
class="flex-1 overflow-auto bg-gray-50 dark-theme:bg-neutral-900"
:class="{
'transition-all duration-300': isSmallScreen,
'pl-80': isSideNavOpen || !isSmallScreen,
'pl-8': !isSideNavOpen && isSmallScreen
'transition-all duration-300': isSmallScreen
}"
>
<div class="px-6 pt-6 flex flex-col h-full">
<div class="px-6 flex flex-col h-full">
<RegistrySearchBar
v-model:searchQuery="searchQuery"
v-model:searchMode="searchMode"
v-model:sortField="sortField"
:search-results="searchResults"
:suggestions="suggestions"
:is-missing-tab="isMissingTab"
:sort-options="sortOptions"
/>
<div class="flex-1 overflow-auto">
<div
@@ -57,7 +59,7 @@
<VirtualGrid
id="results-grid"
:items="resultsWithKeys"
:buffer-rows="3"
:buffer-rows="4"
:grid-style="GRID_STYLE"
@approach-end="onApproachEnd"
>
@@ -75,9 +77,9 @@
</div>
</div>
</div>
<div class="w-80 border-l-0 absolute right-0 top-0 bottom-0 flex z-20">
<div class="w-[clamp(250px,33%,306px)] border-l-0 flex z-20">
<ContentDivider orientation="vertical" :width="0.2" />
<div class="flex-1 flex flex-col isolate">
<div class="w-full flex flex-col isolate">
<InfoPanel
v-if="!hasMultipleSelections && selectedNodePack"
:node-pack="selectedNodePack"
@@ -93,7 +95,14 @@
import { whenever } from '@vueuse/core'
import { merge } from 'lodash'
import Button from 'primevue/button'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import {
computed,
onBeforeUnmount,
onMounted,
onUnmounted,
ref,
watch
} from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
@@ -106,6 +115,7 @@ import PackCard from '@/components/dialog/content/manager/packCard/PackCard.vue'
import RegistrySearchBar from '@/components/dialog/content/manager/registrySearchBar/RegistrySearchBar.vue'
import GridSkeleton from '@/components/dialog/content/manager/skeleton/GridSkeleton.vue'
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
import { useManagerStatePersistence } from '@/composables/manager/useManagerStatePersistence'
import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
@@ -116,13 +126,15 @@ import type { TabItem } from '@/types/comfyManagerTypes'
import { ManagerTab } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
const { initialTab = ManagerTab.All } = defineProps<{
initialTab: ManagerTab
const { initialTab } = defineProps<{
initialTab?: ManagerTab
}>()
const { t } = useI18n()
const comfyManagerStore = useComfyManagerStore()
const { getPackById } = useComfyRegistryStore()
const persistedState = useManagerStatePersistence()
const initialState = persistedState.loadStoredState()
const GRID_STYLE = {
display: 'grid',
@@ -156,8 +168,10 @@ const tabs = ref<TabItem[]>([
icon: 'pi-sync'
}
])
const initialTabId = initialTab ?? initialState.selectedTabId
const selectedTab = ref<TabItem>(
tabs.value.find((tab) => tab.id === initialTab) || tabs.value[0]
tabs.value.find((tab) => tab.id === initialTabId) || tabs.value[0]
)
const {
@@ -167,8 +181,13 @@ const {
searchResults,
searchMode,
sortField,
suggestions
} = useRegistrySearch()
suggestions,
sortOptions
} = useRegistrySearch({
initialSortField: initialState.sortField,
initialSearchMode: initialState.searchMode,
initialSearchQuery: initialState.searchQuery
})
pageNumber.value = 0
const onApproachEnd = () => {
pageNumber.value++
@@ -200,10 +219,6 @@ const {
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
whenever(selectedTab, () => {
pageNumber.value = 0
})
const isUpdateAvailableTab = computed(
() => selectedTab.value?.id === ManagerTab.UpdateAvailable
)
@@ -232,7 +247,11 @@ watch(
if (!isEmptySearch.value) {
displayPacks.value = filterOutdatedPacks(installedPacks.value)
} else if (!installedPacks.value.length) {
} else if (
!installedPacks.value.length &&
!installedPacksReady.value &&
!isLoadingInstalled.value
) {
await startFetchInstalled()
} else {
displayPacks.value = filterOutdatedPacks(installedPacks.value)
@@ -426,7 +445,13 @@ whenever(selectedNodePack, async () => {
if (data?.id === pack.id) {
lastFetchedPackId.value = pack.id
const mergedPack = merge({}, pack, data)
selectedNodePacks.value = [mergedPack]
// Update the pack in current selection without changing selection state
const packIndex = selectedNodePacks.value.findIndex(
(p) => p.id === mergedPack.id
)
if (packIndex !== -1) {
selectedNodePacks.value.splice(packIndex, 1, mergedPack)
}
// Replace pack in displayPacks so that children receive a fresh prop reference
const idx = displayPacks.value.findIndex((p) => p.id === mergedPack.id)
if (idx !== -1) {
@@ -439,13 +464,23 @@ let gridContainer: HTMLElement | null = null
onMounted(() => {
gridContainer = document.getElementById('results-grid')
})
watch(searchQuery, () => {
watch([searchQuery, selectedTab], () => {
gridContainer ??= document.getElementById('results-grid')
if (gridContainer) {
pageNumber.value = 0
gridContainer.scrollTop = 0
}
})
onBeforeUnmount(() => {
persistedState.persistState({
selectedTabId: selectedTab.value?.id,
searchQuery: searchQuery.value,
searchMode: searchMode.value,
sortField: sortField.value
})
})
onUnmounted(() => {
getPackById.cancel()
})

View File

@@ -1,14 +1,9 @@
<template>
<div class="w-full">
<div class="px-6 py-4">
<div class="flex items-center">
<h2 class="text-lg font-normal text-left">
{{ $t('manager.discoverCommunityContent') }}
</h2>
</div>
<ContentDivider :width="0.3" />
</div>
</template>
<script setup lang="ts">
import ContentDivider from '@/components/common/ContentDivider.vue'
</script>

View File

@@ -1,8 +1,8 @@
<template>
<aside
class="absolute translate-x-0 top-0 left-0 h-full w-80 shadow-md z-5 transition-transform duration-300 ease-in-out flex"
class="flex translate-x-0 max-w-[250px] w-3/12 z-5 transition-transform duration-300 ease-in-out"
>
<ScrollPanel class="w-80 mt-7">
<ScrollPanel class="flex-1">
<Listbox
v-model="selectedTab"
:options="tabs"
@@ -10,20 +10,20 @@
list-style="max-height:unset"
class="w-full border-0 bg-transparent shadow-none"
:pt="{
list: { class: 'p-5' },
option: { class: 'px-8 py-3 text-lg rounded-xl' },
list: { class: 'p-3 gap-2' },
option: { class: 'px-4 py-2 text-lg rounded-lg' },
optionGroup: { class: 'p-0 text-left text-inherit' }
}"
>
<template #option="slotProps">
<div class="text-left flex items-center">
<i :class="['pi', slotProps.option.icon, 'mr-3']" />
<span class="text-lg">{{ slotProps.option.label }}</span>
<i :class="['pi', slotProps.option.icon, 'text-sm mr-2']" />
<span class="text-sm">{{ slotProps.option.label }}</span>
</div>
</template>
</Listbox>
</ScrollPanel>
<ContentDivider orientation="vertical" />
<ContentDivider orientation="vertical" :width="0.3" />
</aside>
</template>

View File

@@ -1,6 +1,5 @@
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
@@ -33,6 +32,12 @@ vi.mock('@/stores/comfyManagerStore', () => ({
}))
}))
vi.mock('@/composables/nodePack/usePackUpdateStatus', () => ({
usePackUpdateStatus: vi.fn(() => ({
isUpdateAvailable: false
}))
}))
const mockToggle = vi.fn()
const mockHide = vi.fn()
const PopoverStub = {
@@ -62,6 +67,7 @@ describe('PackVersionBadge', () => {
return mount(PackVersionBadge, {
props: {
nodePack: mockNodePack,
isSelected: false,
...props
},
global: {
@@ -77,9 +83,9 @@ describe('PackVersionBadge', () => {
it('renders with installed version from store', () => {
const wrapper = mountComponent()
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe('1.5.0') // From mockInstalledPacks
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe('1.5.0') // From mockInstalledPacks
})
it('falls back to latest_version when not installed', () => {
@@ -96,9 +102,9 @@ describe('PackVersionBadge', () => {
props: { nodePack: uninstalledPack }
})
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe('3.0.0') // From latest_version
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe('3.0.0') // From latest_version
})
it('falls back to NIGHTLY when no latest_version and not installed', () => {
@@ -112,9 +118,9 @@ describe('PackVersionBadge', () => {
props: { nodePack: noVersionPack }
})
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe(SelectedVersion.NIGHTLY)
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe(SelectedVersion.NIGHTLY)
})
it('falls back to NIGHTLY when nodePack.id is missing', () => {
@@ -126,16 +132,16 @@ describe('PackVersionBadge', () => {
props: { nodePack: invalidPack }
})
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe(SelectedVersion.NIGHTLY)
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe(SelectedVersion.NIGHTLY)
})
it('toggles the popover when button is clicked', async () => {
const wrapper = mountComponent()
// Click the button
await wrapper.findComponent(Button).trigger('click')
// Click the badge
await wrapper.find('[role="button"]').trigger('click')
// Verify that the toggle method was called
expect(mockToggle).toHaveBeenCalled()
@@ -162,4 +168,58 @@ describe('PackVersionBadge', () => {
// Verify that the hide method was called
expect(mockHide).toHaveBeenCalled()
})
describe('selection state changes', () => {
it('closes the popover when card is deselected', async () => {
const wrapper = mountComponent({
props: { isSelected: true }
})
// Change isSelected from true to false
await wrapper.setProps({ isSelected: false })
await nextTick()
// Verify that the hide method was called
expect(mockHide).toHaveBeenCalled()
})
it('does not close the popover when card is selected', async () => {
const wrapper = mountComponent({
props: { isSelected: false }
})
// Change isSelected from false to true
await wrapper.setProps({ isSelected: true })
await nextTick()
// Verify that the hide method was NOT called
expect(mockHide).not.toHaveBeenCalled()
})
it('does not close the popover when isSelected remains false', async () => {
const wrapper = mountComponent({
props: { isSelected: false }
})
// Change isSelected from false to false (no change)
await wrapper.setProps({ isSelected: false })
await nextTick()
// Verify that the hide method was NOT called
expect(mockHide).not.toHaveBeenCalled()
})
it('does not close the popover when isSelected remains true', async () => {
const wrapper = mountComponent({
props: { isSelected: true }
})
// Change isSelected from true to true (no change)
await wrapper.setProps({ isSelected: true })
await nextTick()
// Verify that the hide method was NOT called
expect(mockHide).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,18 +1,23 @@
<template>
<div class="relative">
<Button
:label="installedVersion"
severity="secondary"
icon="pi pi-chevron-right"
icon-pos="right"
class="rounded-xl text-xs tracking-tighter p-0"
:pt="{
label: { class: 'pl-2 pr-0 py-0.5' },
icon: { class: 'text-xs pl-0 pr-2 py-0.5' }
}"
<div>
<div
class="inline-flex items-center gap-1 rounded-2xl text-xs cursor-pointer px-2 py-1"
:class="{ 'bg-gray-100 dark-theme:bg-neutral-700': fill }"
aria-haspopup="true"
role="button"
tabindex="0"
@click="toggleVersionSelector"
/>
@keydown.enter="toggleVersionSelector"
@keydown.space="toggleVersionSelector"
>
<i
v-if="isUpdateAvailable"
class="pi pi-arrow-circle-up text-blue-600"
style="font-size: 8px"
/>
<span>{{ installedVersion }}</span>
<i class="pi pi-chevron-right" style="font-size: 8px" />
</div>
<Popover
ref="popoverRef"
@@ -31,11 +36,11 @@
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import PackVersionSelectorPopover from '@/components/dialog/content/manager/PackVersionSelectorPopover.vue'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
@@ -43,10 +48,17 @@ import { isSemVer } from '@/utils/formatUtil'
const TRUNCATED_HASH_LENGTH = 7
const { nodePack } = defineProps<{
const {
nodePack,
isSelected,
fill = true
} = defineProps<{
nodePack: components['schemas']['Node']
isSelected: boolean
fill?: boolean
}>()
const { isUpdateAvailable } = usePackUpdateStatus(nodePack)
const popoverRef = ref()
const managerStore = useComfyManagerStore()
@@ -69,4 +81,14 @@ const toggleVersionSelector = (event: Event) => {
const closeVersionSelector = () => {
popoverRef.value.hide()
}
// If the card is unselected, automatically close the version selector popover
watch(
() => isSelected,
(isSelected, wasSelected) => {
if (wasSelected && !isSelected) {
closeVersionSelector()
}
}
)
</script>

View File

@@ -191,6 +191,100 @@ describe('PackVersionSelectorPopover', () => {
expect(mockGetPackVersions).toHaveBeenCalledWith(newNodePack.id)
})
describe('nodePack.id changes', () => {
it('re-fetches versions when nodePack.id changes', async () => {
// Set up the mock for the initial fetch
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const wrapper = mountComponent()
await waitForPromises()
// Verify initial fetch
expect(mockGetPackVersions).toHaveBeenCalledTimes(1)
expect(mockGetPackVersions).toHaveBeenCalledWith(mockNodePack.id)
// Set up the mock for the second fetch
const newVersions = [
{ version: '2.0.0', createdAt: '2023-06-01' },
{ version: '1.9.0', createdAt: '2023-05-01' }
]
mockGetPackVersions.mockResolvedValueOnce(newVersions)
// Update the nodePack with a new ID
const newNodePack = {
...mockNodePack,
id: 'different-pack',
name: 'Different Pack'
}
await wrapper.setProps({ nodePack: newNodePack })
await waitForPromises()
// Should fetch versions for the new nodePack
expect(mockGetPackVersions).toHaveBeenCalledTimes(2)
expect(mockGetPackVersions).toHaveBeenLastCalledWith(newNodePack.id)
// Check that new versions are displayed
const listbox = wrapper.findComponent(Listbox)
const options = listbox.props('options')!
expect(options.some((o) => o.value === '2.0.0')).toBe(true)
expect(options.some((o) => o.value === '1.9.0')).toBe(true)
})
it('does not re-fetch when nodePack changes but id remains the same', async () => {
// Set up the mock for the initial fetch
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const wrapper = mountComponent()
await waitForPromises()
// Verify initial fetch
expect(mockGetPackVersions).toHaveBeenCalledTimes(1)
// Update the nodePack with same ID but different properties
const updatedNodePack = {
...mockNodePack,
name: 'Updated Test Pack',
description: 'New description'
}
await wrapper.setProps({ nodePack: updatedNodePack })
await waitForPromises()
// Should NOT fetch versions again
expect(mockGetPackVersions).toHaveBeenCalledTimes(1)
})
it('maintains selected version when switching to a new pack', async () => {
// Set up the mock for the initial fetch
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const wrapper = mountComponent()
await waitForPromises()
// Select a specific version
const listbox = wrapper.findComponent(Listbox)
await listbox.setValue('0.9.0')
expect(listbox.props('modelValue')).toBe('0.9.0')
// Set up the mock for the second fetch
mockGetPackVersions.mockResolvedValueOnce([
{ version: '3.0.0', createdAt: '2023-07-01' },
{ version: '0.9.0', createdAt: '2023-04-01' }
])
// Update to a new pack that also has version 0.9.0
const newNodePack = {
id: 'another-pack',
name: 'Another Pack',
latest_version: { version: '3.0.0' }
}
await wrapper.setProps({ nodePack: newNodePack })
await waitForPromises()
// Selected version should remain the same if available
expect(listbox.props('modelValue')).toBe('0.9.0')
})
})
describe('Unclaimed GitHub packs handling', () => {
it('falls back to nightly when no versions exist', async () => {
// Set up the mock to return versions

View File

@@ -62,7 +62,7 @@ import { whenever } from '@vueuse/core'
import Button from 'primevue/button'
import Listbox from 'primevue/listbox'
import ProgressSpinner from 'primevue/progressspinner'
import { onMounted, onUnmounted, ref } from 'vue'
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
@@ -161,9 +161,11 @@ const onNodePackChange = async () => {
}
whenever(
() => nodePack,
() => {
void onNodePackChange()
() => nodePack.id,
(nodePackId, oldNodePackId) => {
if (nodePackId !== oldNodePackId) {
void onNodePackChange()
}
},
{ deep: true, immediate: true }
)
@@ -182,8 +184,4 @@ const handleSubmit = async () => {
isQueueing.value = false
emit('submit')
}
onUnmounted(() => {
managerStore.installPack.clear()
})
</script>

View File

@@ -1,16 +1,18 @@
<template>
<Button
outlined
class="m-0 p-0 rounded-lg border-neutral-700"
:class="{
'w-full': fullWidth,
'w-min-content': !fullWidth
}"
class="!m-0 p-0 rounded-lg text-gray-900 dark-theme:text-gray-50"
:class="[
variant === 'black'
? 'bg-neutral-900 text-white border-neutral-900'
: 'border-neutral-700',
fullWidth ? 'w-full' : 'w-min-content'
]"
:disabled="loading"
v-bind="$attrs"
@click="onClick"
>
<span class="py-2.5 px-3">
<span class="py-2 px-3 whitespace-nowrap">
<template v-if="loading">
{{ loadingMessage ?? $t('g.loading') }}
</template>
@@ -27,12 +29,14 @@ import Button from 'primevue/button'
const {
label,
loadingMessage,
fullWidth = false
fullWidth = false,
variant = 'default'
} = defineProps<{
label: string
loading?: boolean
loadingMessage?: string
fullWidth?: boolean
variant?: 'default' | 'black'
}>()
const emit = defineEmits<{

View File

@@ -2,9 +2,11 @@
<PackActionButton
v-bind="$attrs"
:label="
nodePacks.length > 1 ? $t('manager.installSelected') : $t('g.install')
label ??
(nodePacks.length > 1 ? $t('manager.installSelected') : $t('g.install'))
"
severity="secondary"
:severity="variant === 'black' ? undefined : 'secondary'"
:variant="variant"
:loading="isInstalling"
:loading-message="$t('g.installing')"
@action="installAllPacks"
@@ -27,8 +29,10 @@ import type { components } from '@/types/comfyRegistryTypes'
type NodePack = components['schemas']['Node']
const { nodePacks } = defineProps<{
const { nodePacks, variant, label } = defineProps<{
nodePacks: NodePack[]
variant?: 'default' | 'black'
label?: string
}>()
const isInstalling = inject(IsInstallingKey, ref(false))

View File

@@ -1,6 +1,6 @@
<template>
<template v-if="nodePack">
<div class="flex flex-col h-full z-40 w-80 overflow-hidden relative">
<div class="flex flex-col h-full z-40 overflow-hidden relative">
<div class="top-0 z-10 px-6 pt-6 w-full">
<InfoPanelHeader :node-packs="[nodePack]" />
</div>
@@ -32,7 +32,7 @@
/>
</MetadataRow>
<MetadataRow :label="t('manager.version')">
<PackVersionBadge :node-pack="nodePack" />
<PackVersionBadge :node-pack="nodePack" :is-selected="true" />
</MetadataRow>
</div>
<div class="mb-6 overflow-hidden">
@@ -42,7 +42,7 @@
</div>
</template>
<template v-else>
<div class="mt-4 mx-8 flex-1 overflow-hidden text-sm">
<div class="pt-4 px-8 flex-1 overflow-hidden text-sm">
{{ $t('manager.infoPanelEmpty') }}
</div>
</template>
@@ -118,7 +118,15 @@ const onNodePackChange = () => {
y.value = 0
}
whenever(() => nodePack, onNodePackChange, { immediate: true, deep: true })
whenever(
() => nodePack.id,
(nodePackId, oldNodePackId) => {
if (nodePackId !== oldNodePackId) {
onNodePackChange()
}
},
{ immediate: true }
)
</script>
<style scoped>
.hidden-scrollbar {

View File

@@ -51,7 +51,11 @@ const getPackNodes = async (pack: components['schemas']['Node']) => {
if (!pack.latest_version?.version) return []
const nodeDefs = await getNodeDefs.call({
packId: pack.id,
version: pack.latest_version?.version
version: pack.latest_version?.version,
// Fetch all nodes.
// TODO: Render all nodes previews and handle pagination.
// For determining length, use the `totalNumberOfPages` field of response
limit: 8192
})
return nodeDefs?.comfy_nodes ?? []
}

View File

@@ -51,6 +51,7 @@ const isLoading = ref(false)
const registryNodeDefs = shallowRef<ListComfyNodesResponse | null>(null)
const fetchNodeDefs = async () => {
getNodeDefs.cancel()
isLoading.value = true
const { id: packId } = nodePack

View File

@@ -1,11 +1,37 @@
<template>
<img
:src="isImageError ? DEFAULT_BANNER : imgSrc"
:alt="nodePack.name + ' banner'"
class="object-cover"
:style="{ width: cssWidth, height: cssHeight }"
@error="isImageError = true"
/>
<div class="w-full aspect-[7/3] overflow-hidden">
<!-- default banner show -->
<div v-if="showDefaultBanner" class="w-full h-full">
<img
:src="DEFAULT_BANNER"
alt="default banner"
class="w-full h-full object-cover"
/>
</div>
<!-- banner_url or icon show -->
<div v-else class="relative w-full h-full">
<!-- blur background -->
<div
v-if="imgSrc"
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
:style="{
backgroundImage: `url(${imgSrc})`,
filter: 'blur(10px)'
}"
></div>
<!-- image -->
<img
:src="isImageError ? DEFAULT_BANNER : imgSrc"
:alt="nodePack.name + ' banner'"
:class="
isImageError
? 'relative w-full h-full object-cover z-10'
: 'relative w-full h-full object-contain z-10'
"
@error="isImageError = true"
/>
</div>
</div>
</template>
<script setup lang="ts">
@@ -15,27 +41,12 @@ import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
const {
nodePack,
width = '100%',
height = '12rem'
} = defineProps<{
nodePack: components['schemas']['Node'] & { banner?: string } // Temporary measure until banner is in backend
width?: string
height?: string
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const isImageError = ref(false)
const shouldShowFallback = computed(
() => !nodePack.banner || nodePack.banner.trim() === '' || isImageError.value
)
const imgSrc = computed(() =>
shouldShowFallback.value ? DEFAULT_BANNER : nodePack.banner
)
const convertToCssValue = (value: string | number) =>
typeof value === 'number' ? `${value}rem` : value
const cssWidth = computed(() => convertToCssValue(width))
const cssHeight = computed(() => convertToCssValue(height))
const showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon)
</script>

View File

@@ -1,15 +1,20 @@
<template>
<Card
class="w-full h-full inline-flex flex-col justify-between items-start overflow-hidden rounded-2xl shadow-elevation-3 dark-theme:bg-dark-elevation-2 transition-all duration-200"
class="w-full h-full inline-flex flex-col justify-between items-start overflow-hidden rounded-lg shadow-elevation-3 dark-theme:bg-dark-elevation-2 transition-all duration-200"
:class="{
'outline outline-[6px] outline-[var(--p-primary-color)]': isSelected,
'selected-card': isSelected,
'opacity-60': isDisabled
}"
:pt="{
body: { class: 'p-0 flex flex-col w-full h-full rounded-2xl gap-0' },
content: { class: 'flex-1 flex flex-col rounded-2xl min-h-0' },
body: { class: 'p-0 flex flex-col w-full h-full rounded-lg gap-0' },
content: { class: 'flex-1 flex flex-col rounded-lg min-h-0' },
title: { class: 'w-full h-full rounded-t-lg cursor-pointer' },
footer: { class: 'p-0 m-0' }
footer: {
class: 'p-0 m-0 flex flex-col gap-0',
style: {
borderTop: isLightTheme ? '1px solid #f4f4f4' : '1px solid #2C2C2C'
}
}
}"
>
<template #title>
@@ -29,72 +34,50 @@
</div>
</template>
<template v-else>
<div
class="self-stretch inline-flex flex-col justify-start items-start"
>
<div
class="px-4 py-3 inline-flex justify-start items-start cursor-pointer w-full"
>
<div
class="inline-flex flex-col justify-start items-start overflow-hidden gap-y-3 w-full"
<div class="pt-4 px-4 pb-3 w-full h-full">
<div class="flex flex-col gap-y-1 w-full h-full">
<span
class="text-sm font-bold truncate overflow-hidden text-ellipsis"
>
<span
class="text-base font-bold truncate overflow-hidden text-ellipsis"
>
{{ nodePack.name }}
</span>
<p
v-if="nodePack.description"
class="flex-1 justify-start text-muted text-sm font-medium break-words overflow-hidden min-h-12 line-clamp-3 my-0 leading-5"
>
{{ nodePack.description }}
</p>
<div class="flex flex-col gap-y-2">
{{ nodePack.name }}
</span>
<p
v-if="nodePack.description"
class="flex-1 text-muted text-xs font-medium break-words overflow-hidden min-h-12 line-clamp-3 my-0 leading-4 mb-1 overflow-hidden"
>
{{ nodePack.description }}
</p>
<div class="flex flex-col gap-y-2">
<div class="flex-1 flex items-center gap-2">
<div v-if="nodesCount" class="p-2 pl-0 text-xs">
{{ nodesCount }} {{ $t('g.nodes') }}
</div>
<PackVersionBadge
:node-pack="nodePack"
:is-selected="isSelected"
:fill="false"
/>
<div
class="self-stretch inline-flex justify-start items-center gap-1"
v-if="formattedLatestVersionDate"
class="px-2 py-1 flex justify-center items-center gap-1 text-xs text-muted font-medium"
>
<div
v-if="nodesCount"
class="pr-2 py-1 flex justify-center text-sm items-center gap-1"
>
<div
class="text-center justify-center font-medium leading-3"
>
{{ nodesCount }} {{ $t('g.nodes') }}
</div>
</div>
<div class="px-2 py-1 flex justify-center items-center gap-1">
<div
v-if="isUpdateAvailable"
class="w-4 h-4 relative overflow-hidden"
>
<i class="pi pi-arrow-circle-up text-blue-600" />
</div>
<PackVersionBadge :node-pack="nodePack" />
</div>
<div
v-if="formattedLatestVersionDate"
class="px-2 py-1 flex justify-center items-center gap-1 text-xs text-muted font-medium"
>
{{ formattedLatestVersionDate }}
</div>
</div>
<div class="flex">
<span
v-if="publisherName"
class="text-xs text-muted font-medium leading-3 max-w-40 truncate"
>
{{ publisherName }}
</span>
{{ formattedLatestVersionDate }}
</div>
</div>
<div class="flex">
<span
v-if="publisherName"
class="text-xs text-muted font-medium leading-3 max-w-40 truncate"
>
{{ publisherName }}
</span>
</div>
</div>
</div>
</div>
</template>
</template>
<template #footer>
<ContentDivider :width="0.1" />
<PackCardFooter :node-pack="nodePack" />
</template>
</Card>
@@ -107,27 +90,34 @@ import ProgressSpinner from 'primevue/progressspinner'
import { computed, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
import PackBanner from '@/components/dialog/content/manager/packBanner/PackBanner.vue'
import PackCardFooter from '@/components/dialog/content/manager/packCard/PackCardFooter.vue'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import {
IsInstallingKey,
type MergedNodePack,
type RegistryPack,
isMergedNodePack
} from '@/types/comfyManagerTypes'
const { nodePack, isSelected = false } = defineProps<{
nodePack: components['schemas']['Node']
nodePack: MergedNodePack | RegistryPack
isSelected?: boolean
}>()
const { d } = useI18n()
const colorPaletteStore = useColorPaletteStore()
const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
)
const isInstalling = ref(false)
provide(IsInstallingKey, isInstalling)
const { isPackInstalled, isPackEnabled } = useComfyManagerStore()
const { isUpdateAvailable } = usePackUpdateStatus(nodePack)
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const isDisabled = computed(
@@ -136,9 +126,9 @@ const isDisabled = computed(
whenever(isInstalled, () => (isInstalling.value = false))
// TODO: remove type assertion once comfy_nodes is added to node (pack) info type in backend
const nodesCount = computed(() => (nodePack as any).comfy_nodes?.length)
const nodesCount = computed(() =>
isMergedNodePack(nodePack) ? nodePack.comfy_nodes?.length : undefined
)
const publisherName = computed(() => {
if (!nodePack) return null
@@ -154,3 +144,22 @@ const formattedLatestVersionDate = computed(() => {
})
})
</script>
<style scoped>
.selected-card {
position: relative;
}
.selected-card::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border: 4px solid var(--p-primary-color);
border-radius: 0.5rem;
pointer-events: none;
z-index: 100;
}
</style>

View File

@@ -1,12 +1,13 @@
<template>
<div
class="flex justify-between items-center px-4 py-2 text-xs text-muted font-medium leading-3"
class="min-h-12 flex justify-between items-center px-4 py-2 text-xs text-muted font-medium leading-3"
>
<div v-if="nodePack.downloads" class="flex items-center gap-1.5">
<i class="pi pi-download text-muted"></i>
<span>{{ formattedDownloads }}</span>
</div>
<PackInstallButton :node-packs="[nodePack]" />
<PackInstallButton v-if="!isInstalled" :node-packs="[nodePack]" />
<PackEnableToggle v-else :node-pack="nodePack" />
</div>
</template>
@@ -14,13 +15,18 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import PackEnableToggle from '@/components/dialog/content/manager/button/PackEnableToggle.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const { isPackInstalled } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const { n } = useI18n()
const formattedDownloads = computed(() =>

View File

@@ -1,28 +1,37 @@
<template>
<div class="relative w-full p-6">
<div class="flex items-center w-full">
<AutoComplete
v-model.lazy="searchQuery"
:suggestions="suggestions || []"
:placeholder="$t('manager.searchPlaceholder')"
:complete-on-focus="false"
:delay="8"
option-label="query"
class="w-full"
:pt="{
pcInputText: {
root: {
autofocus: true,
class: 'w-5/12 rounded-2xl'
<div class="h-12 flex items-center gap-1 justify-between">
<div class="flex items-center w-5/12">
<AutoComplete
v-model.lazy="searchQuery"
:suggestions="suggestions || []"
:placeholder="$t('manager.searchPlaceholder')"
:complete-on-focus="false"
:delay="8"
option-label="query"
class="w-full"
:pt="{
pcInputText: {
root: {
autofocus: true,
class: 'w-full rounded-2xl'
}
},
loader: {
style: 'display: none'
}
},
loader: {
style: 'display: none'
}
}"
:show-empty-message="false"
@complete="stubTrue"
@option-select="onOptionSelect"
}"
:show-empty-message="false"
@complete="stubTrue"
@option-select="onOptionSelect"
/>
</div>
<PackInstallButton
v-if="isMissingTab && missingNodePacks.length > 0"
variant="black"
:disabled="isLoading || !!error"
:node-packs="missingNodePacks"
:label="$t('manager.installAllMissingNodes')"
/>
</div>
<div class="flex mt-3 text-sm">
@@ -34,7 +43,7 @@
/>
<SearchFilterDropdown
v-model:modelValue="sortField"
:options="sortOptions"
:options="availableSortOptions"
:label="$t('g.sort')"
/>
</div>
@@ -55,43 +64,55 @@ import AutoComplete, {
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue'
import type { NodesIndexSuggestion } from '@/services/algoliaSearchService'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import {
type SearchOption,
SortableAlgoliaField
} from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import type {
QuerySuggestion,
SearchMode,
SortableField
} from '@/types/searchServiceTypes'
const { searchResults } = defineProps<{
const { searchResults, sortOptions } = defineProps<{
searchResults?: components['schemas']['Node'][]
suggestions?: NodesIndexSuggestion[]
suggestions?: QuerySuggestion[]
sortOptions?: SortableField[]
isMissingTab?: boolean
}>()
const searchQuery = defineModel<string>('searchQuery')
const searchMode = defineModel<string>('searchMode', { default: 'packs' })
const sortField = defineModel<SortableAlgoliaField>('sortField', {
const searchMode = defineModel<SearchMode>('searchMode', { default: 'packs' })
const sortField = defineModel<string>('sortField', {
default: SortableAlgoliaField.Downloads
})
const { t } = useI18n()
// Get missing node packs from workflow with loading and error states
const { missingNodePacks, isLoading, error } = useMissingNodes()
const hasResults = computed(
() => searchQuery.value?.trim() && searchResults?.length
)
const sortOptions: SearchOption<SortableAlgoliaField>[] = [
{ id: SortableAlgoliaField.Downloads, label: t('manager.sort.downloads') },
{ id: SortableAlgoliaField.Created, label: t('manager.sort.created') },
{ id: SortableAlgoliaField.Updated, label: t('manager.sort.updated') },
{ id: SortableAlgoliaField.Publisher, label: t('manager.sort.publisher') },
{ id: SortableAlgoliaField.Name, label: t('g.name') }
]
const filterOptions: SearchOption<string>[] = [
const availableSortOptions = computed<SearchOption<string>[]>(() => {
if (!sortOptions) return []
return sortOptions.map((field) => ({
id: field.id,
label: field.label
}))
})
const filterOptions: SearchOption<SearchMode>[] = [
{ id: 'packs', label: t('manager.filter.nodePack') },
{ id: 'nodes', label: t('g.nodes') }
]
// When a dropdown query suggestion is selected, update the search query
const onOptionSelect = (event: AutoCompleteOptionSelectEvent) => {
searchQuery.value = event.value.query
}

View File

@@ -297,6 +297,7 @@ onMounted(async () => {
throw error
}
}
CORE_SETTINGS.forEach((setting) => {
settingStore.addSetting(setting)
})

View File

@@ -0,0 +1,54 @@
import {
ManagerState,
ManagerTab,
SortableAlgoliaField
} from '@/types/comfyManagerTypes'
const STORAGE_KEY = 'Comfy.Manager.UI.State'
export const useManagerStatePersistence = () => {
/**
* Load the UI state from localStorage.
*/
const loadStoredState = (): ManagerState => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
return JSON.parse(stored)
}
} catch (e) {
console.error('Failed to load manager UI state:', e)
}
return {
selectedTabId: ManagerTab.All,
searchQuery: '',
searchMode: 'packs',
sortField: SortableAlgoliaField.Downloads
}
}
/**
* Persist the UI state to localStorage.
*/
const persistState = (state: ManagerState) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
}
/**
* Reset the UI state to the default values.
*/
const reset = () => {
persistState({
selectedTabId: ManagerTab.All,
searchQuery: '',
searchMode: 'packs',
sortField: SortableAlgoliaField.Downloads
})
}
return {
loadStoredState,
persistState,
reset
}
}

View File

@@ -3,15 +3,29 @@ import type { LGraphNode } from '@comfyorg/litegraph'
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
import { useNodePaste } from '@/composables/node/useNodePaste'
import type { ResultItemType } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useToastStore } from '@/stores/toastStore'
const PASTED_IMAGE_EXPIRY_MS = 2000
const uploadFile = async (file: File, isPasted: boolean) => {
interface ImageUploadFormFields {
/**
* The folder to upload the file to.
* @example 'input', 'output', 'temp'
*/
type: ResultItemType
}
const uploadFile = async (
file: File,
isPasted: boolean,
formFields: Partial<ImageUploadFormFields> = {}
) => {
const body = new FormData()
body.append('image', file)
if (isPasted) body.append('subfolder', 'pasted')
if (formFields.type) body.append('type', formFields.type)
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
@@ -36,6 +50,11 @@ interface ImageUploadOptions {
* @example 'image/png,image/jpeg,image/webp,video/webm,video/mp4'
*/
accept?: string
/**
* The folder to upload the file to.
* @example 'input', 'output', 'temp'
*/
folder?: ResultItemType
}
/**
@@ -53,7 +72,9 @@ export const useNodeImageUpload = (
const handleUpload = async (file: File) => {
try {
const path = await uploadFile(file, isPastedFile(file))
const path = await uploadFile(file, isPastedFile(file), {
type: options.folder
})
if (!path) return
return path
} catch (error) {

View File

@@ -1,3 +1,4 @@
import { whenever } from '@vueuse/core'
import { computed, onUnmounted } from 'vue'
import { useNodePacks } from '@/composables/nodePack/useNodePacks'
@@ -18,6 +19,16 @@ export const useInstalledPacks = (options: UseNodePacksOptions = {}) => {
const filterInstalledPack = (packs: components['schemas']['Node'][]) =>
packs.filter((pack) => comfyManagerStore.isPackInstalled(pack.id))
const startFetchInstalled = async () => {
await comfyManagerStore.refreshInstalledList()
await startFetch()
}
// When installedPackIds changes, we need to update the nodePacks
whenever(installedPackIds, async () => {
await startFetch()
})
onUnmounted(() => {
cleanup()
})
@@ -27,7 +38,7 @@ export const useInstalledPacks = (options: UseNodePacksOptions = {}) => {
isLoading,
isReady,
installedPacks: nodePacks,
startFetchInstalled: startFetch,
startFetchInstalled,
filterInstalledPack
}
}

View File

@@ -0,0 +1,76 @@
import { LGraphNode } from '@comfyorg/litegraph'
import { NodeProperty } from '@comfyorg/litegraph/dist/LGraphNode'
import { groupBy } from 'lodash'
import { computed, onMounted } from 'vue'
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
import { app } from '@/scripts/app'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { components } from '@/types/comfyRegistryTypes'
/**
* Composable to find missing NodePacks from workflow
* Uses the same filtering approach as ManagerDialogContent.vue
* Automatically fetches workflow pack data when initialized
*/
export const useMissingNodes = () => {
const nodeDefStore = useNodeDefStore()
const comfyManagerStore = useComfyManagerStore()
const { workflowPacks, isLoading, error, startFetchWorkflowPacks } =
useWorkflowPacks()
// Same filtering logic as ManagerDialogContent.vue
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
// Filter only uninstalled packs from workflow packs
const missingNodePacks = computed(() => {
if (!workflowPacks.value.length) return []
return filterMissingPacks(workflowPacks.value)
})
/**
* Check if a pack is the ComfyUI builtin node pack (nodes that come pre-installed)
* @param packId - The id of the pack to check
* @returns True if the pack is the comfy-core pack, false otherwise
*/
const isCorePack = (packId: NodeProperty) => {
return packId === 'comfy-core'
}
/**
* Check if a node is a missing core node
* A missing core node is a node that is in the workflow and originates from
* the comfy-core pack (pre-installed) but not registered in the node def
* store (the node def was not found on the server)
* @param node - The node to check
* @returns True if the node is a missing core node, false otherwise
*/
const isMissingCoreNode = (node: LGraphNode) => {
const packId = node.properties?.cnr_id
if (packId === undefined || !isCorePack(packId)) return false
const nodeName = node.type
const isRegisteredNodeDef = !!nodeDefStore.nodeDefsByName[nodeName]
return !isRegisteredNodeDef
}
const missingCoreNodes = computed<Record<string, LGraphNode[]>>(() => {
const missingNodes = app.graph.nodes.filter(isMissingCoreNode)
return groupBy(missingNodes, (node) => String(node.properties?.ver || ''))
})
// Automatically fetch workflow pack data when composable is used
onMounted(async () => {
if (!workflowPacks.value.length && !isLoading.value) {
await startFetchWorkflowPacks()
}
})
return {
missingNodePacks,
missingCoreNodes,
isLoading,
error
}
}

View File

@@ -658,19 +658,19 @@ export function useCoreCommands(): ComfyCommand[] {
{
id: 'Comfy.Manager.CustomNodesManager',
icon: 'pi pi-puzzle',
label: 'Custom Nodes Manager',
label: 'Toggle the Custom Nodes Manager',
versionAdded: '1.12.10',
function: () => {
dialogService.showManagerDialog()
dialogService.toggleManagerDialog()
}
},
{
id: 'Comfy.Manager.ToggleManagerProgressDialog',
icon: 'pi pi-spinner',
label: 'Toggle Progress Dialog',
label: 'Toggle the Custom Nodes Manager Progress Bar',
versionAdded: '1.13.9',
function: () => {
dialogService.showManagerProgressDialog()
dialogService.toggleManagerProgressDialog()
}
},
{

View File

@@ -15,7 +15,10 @@ export const useProgressFavicon = () => {
if (isIdle) {
favicon.value = defaultFavicon
} else {
const frame = Math.floor(progress * totalFrames)
const frame = Math.min(
Math.max(0, Math.floor(progress * totalFrames)),
totalFrames - 1
)
favicon.value = `/assets/images/favicon_progress_16x16/frame_${frame}.png`
}
}

View File

@@ -1,91 +1,61 @@
import { watchDebounced } from '@vueuse/core'
import type { Hit } from 'algoliasearch/dist/lite/browser'
import { memoize, orderBy } from 'lodash'
import { computed, onUnmounted, ref, watch } from 'vue'
import { orderBy } from 'lodash'
import { computed, ref, watch } from 'vue'
import {
AlgoliaNodePack,
SearchAttribute,
useAlgoliaSearchService
} from '@/services/algoliaSearchService'
import type { NodesIndexSuggestion } from '@/services/algoliaSearchService'
import { DEFAULT_PAGE_SIZE } from '@/constants/searchConstants'
import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway'
import type { SearchAttribute } from '@/types/algoliaTypes'
import { SortableAlgoliaField } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import type { QuerySuggestion, SearchMode } from '@/types/searchServiceTypes'
type RegistryNodePack = components['schemas']['Node']
const SEARCH_DEBOUNCE_TIME = 320
const DEFAULT_PAGE_SIZE = 64
const DEFAULT_SORT_FIELD = SortableAlgoliaField.Downloads // Set in the index configuration
const DEFAULT_MAX_CACHE_SIZE = 64
const SORT_DIRECTIONS: Record<SortableAlgoliaField, 'asc' | 'desc'> = {
[SortableAlgoliaField.Downloads]: 'desc',
[SortableAlgoliaField.Created]: 'desc',
[SortableAlgoliaField.Updated]: 'desc',
[SortableAlgoliaField.Publisher]: 'asc',
[SortableAlgoliaField.Name]: 'asc'
}
const isDateField = (field: SortableAlgoliaField): boolean =>
field === SortableAlgoliaField.Created ||
field === SortableAlgoliaField.Updated
/**
* Composable for managing UI state of Comfy Node Registry search.
*/
export function useRegistrySearch(
options: {
maxCacheSize?: number
initialSortField?: string
initialSearchMode?: SearchMode
initialSearchQuery?: string
initialPageNumber?: number
} = {}
) {
const { maxCacheSize = DEFAULT_MAX_CACHE_SIZE } = options
const {
initialSortField = DEFAULT_SORT_FIELD,
initialSearchMode = 'packs',
initialSearchQuery = '',
initialPageNumber = 0
} = options
const isLoading = ref(false)
const sortField = ref<SortableAlgoliaField>(SortableAlgoliaField.Downloads)
const searchMode = ref<'nodes' | 'packs'>('packs')
const sortField = ref<string>(initialSortField)
const searchMode = ref<SearchMode>(initialSearchMode)
const pageSize = ref(DEFAULT_PAGE_SIZE)
const pageNumber = ref(0)
const searchQuery = ref('')
const results = ref<AlgoliaNodePack[]>([])
const suggestions = ref<NodesIndexSuggestion[]>([])
const pageNumber = ref(initialPageNumber)
const searchQuery = ref(initialSearchQuery)
const searchResults = ref<RegistryNodePack[]>([])
const suggestions = ref<QuerySuggestion[]>([])
const searchAttributes = computed<SearchAttribute[]>(() =>
searchMode.value === 'nodes' ? ['comfy_nodes'] : ['name', 'description']
)
const resultsAsRegistryPacks = computed(() =>
results.value ? results.value.map(algoliaToRegistry) : []
)
const resultsAsNodes = computed(() =>
results.value
? results.value.reduce(
(acc, hit) => acc.concat(hit.comfy_nodes),
[] as string[]
)
: []
)
const searchGateway = useRegistrySearchGateway()
const { searchPacksCached, toRegistryPack, clearSearchPacksCache } =
useAlgoliaSearchService({
maxCacheSize
})
const algoliaToRegistry = memoize(
toRegistryPack,
(algoliaNode: AlgoliaNodePack) => algoliaNode.id
)
const getSortValue = (pack: Hit<AlgoliaNodePack>) => {
if (isDateField(sortField.value)) {
const value = pack[sortField.value]
return value ? new Date(value).getTime() : 0
} else {
const value = pack[sortField.value]
return value ?? 0
}
}
const { searchPacks, clearSearchCache, getSortValue, getSortableFields } =
searchGateway
const updateSearchResults = async (options: { append?: boolean }) => {
isLoading.value = true
if (!options.append) {
pageNumber.value = 0
}
const { nodePacks, querySuggestions } = await searchPacksCached(
const { nodePacks, querySuggestions } = await searchPacks(
searchQuery.value,
{
pageSize: pageSize.value,
@@ -98,17 +68,22 @@ export function useRegistrySearch(
// Results are sorted by the default field to begin with -- so don't manually sort again
if (sortField.value && sortField.value !== DEFAULT_SORT_FIELD) {
// Get the sort direction from the provider's sortable fields
const sortableFields = getSortableFields()
const fieldConfig = sortableFields.find((f) => f.id === sortField.value)
const direction = fieldConfig?.direction || 'desc'
sortedPacks = orderBy(
nodePacks,
[getSortValue],
[SORT_DIRECTIONS[sortField.value]]
[(pack) => getSortValue(pack, sortField.value)],
[direction]
)
}
if (options.append && results.value?.length) {
results.value = results.value.concat(sortedPacks)
if (options.append && searchResults.value?.length) {
searchResults.value = searchResults.value.concat(sortedPacks)
} else {
results.value = sortedPacks
searchResults.value = sortedPacks
}
suggestions.value = querySuggestions
isLoading.value = false
@@ -124,7 +99,9 @@ export function useRegistrySearch(
immediate: true
})
onUnmounted(clearSearchPacksCache)
const sortOptions = computed(() => {
return getSortableFields()
})
return {
isLoading,
@@ -134,7 +111,8 @@ export function useRegistrySearch(
searchMode,
searchQuery,
suggestions,
searchResults: resultsAsRegistryPacks,
nodeSearchResults: resultsAsNodes
searchResults,
sortOptions,
clearCache: clearSearchCache
}
}

View File

@@ -5,10 +5,11 @@ import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
import { useValueTransform } from '@/composables/useValueTransform'
import { t } from '@/i18n'
import type { ResultItem } from '@/schemas/apiSchema'
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { isImageUploadInput } from '@/types/nodeDefAugmentation'
import { createAnnotatedPath } from '@/utils/formatUtil'
import { addToComboValues } from '@/utils/litegraphUtil'
@@ -33,8 +34,15 @@ export const useImageUploadWidget = () => {
inputName: string,
inputData: InputSpec
) => {
const inputOptions = inputData[1] ?? {}
if (!isImageUploadInput(inputData)) {
throw new Error(
'Image upload widget requires imageInputName augmentation'
)
}
const inputOptions = inputData[1]
const { imageInputName, allow_batch, image_folder = 'input' } = inputOptions
const folder: ResultItemType | undefined = image_folder
const nodeOutputStore = useNodeOutputStore()
const isAnimated = !!inputOptions.animated_image_upload
@@ -43,11 +51,9 @@ export const useImageUploadWidget = () => {
const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node)
const fileFilter = isVideo ? isVideoFile : isImageFile
// @ts-expect-error InputSpec is not typed correctly
const fileComboWidget = findFileComboWidget(node, imageInputName)
const initialFile = `${fileComboWidget.value}`
const formatPath = (value: InternalFile) =>
// @ts-expect-error InputSpec is not typed correctly
createAnnotatedPath(value, { rootFolder: image_folder })
const transform = (internalValue: InternalValue): ExposedValue => {
@@ -67,10 +73,10 @@ export const useImageUploadWidget = () => {
// Setup file upload handling
const { openFileSelection } = useNodeImageUpload(node, {
// @ts-expect-error InputSpec is not typed correctly
allow_batch,
fileFilter,
accept,
folder,
onUploadComplete: (output) => {
output.forEach((path) => addToComboValues(fileComboWidget, path))
// @ts-expect-error litegraph combo value type does not support arrays yet

View File

@@ -2,7 +2,9 @@ import { LGraphNode } from '@comfyorg/litegraph'
import { IWidget } from '@comfyorg/litegraph'
import axios from 'axios'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
const MAX_RETRIES = 5
const TIMEOUT = 4096
@@ -220,6 +222,46 @@ export function useRemoteWidget<
node.addWidget('button', 'refresh', 'refresh', widget.refresh)
}
/**
* Add auto-refresh toggle widget and execution success listener
*/
function addAutoRefreshToggle() {
let autoRefreshEnabled = false
// Handler for execution success
const handleExecutionSuccess = () => {
if (autoRefreshEnabled && widget.refresh) {
widget.refresh()
}
}
// Add toggle widget
const autoRefreshWidget = node.addWidget(
'toggle',
'Auto-refresh after generation',
false,
(value: boolean) => {
autoRefreshEnabled = value
},
{
serialize: false
}
)
// Register event listener
api.addEventListener('execution_success', handleExecutionSuccess)
// Cleanup on node removal
node.onRemoved = useChainCallback(node.onRemoved, function () {
api.removeEventListener('execution_success', handleExecutionSuccess)
})
return autoRefreshWidget
}
// Always add auto-refresh toggle for remote widgets
addAutoRefreshToggle()
return {
getCachedValue,
getValue,

View File

@@ -430,14 +430,7 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: 'Top',
name: 'Use new menu',
type: 'combo',
options: ['Disabled', 'Top', 'Bottom'],
migrateDeprecatedValue: (value: string) => {
// Floating is now supported by dragging the docked actionbar off.
if (value === 'Floating') {
return 'Top'
}
return value
}
options: ['Disabled', 'Top', 'Bottom']
},
{
id: 'Comfy.Workflow.WorkflowTabsPosition',
@@ -470,15 +463,7 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'hidden',
defaultValue: [] as Keybinding[],
versionAdded: '1.3.7',
versionModified: '1.7.3',
migrateDeprecatedValue: (value: any[]) => {
return value.map((keybinding) => {
if (keybinding['targetSelector'] === '#graph-canvas') {
keybinding['targetElementId'] = 'graph-canvas'
}
return keybinding
})
}
versionModified: '1.7.3'
},
{
id: 'Comfy.Keybinding.NewBindings',
@@ -716,11 +701,7 @@ export const CORE_SETTINGS: SettingParams[] = [
name: 'The active color palette id',
type: 'hidden',
defaultValue: 'dark',
versionModified: '1.6.7',
migrateDeprecatedValue(value: string) {
// Legacy custom palettes were prefixed with 'custom_'
return value.startsWith('custom_') ? value.replace('custom_', '') : value
}
versionModified: '1.6.7'
},
{
id: 'Comfy.CustomColorPalettes',

View File

@@ -0,0 +1,3 @@
export const SEARCH_CACHE_MAX_SIZE = 64
export const DEFAULT_PAGE_SIZE = 64
export const MIN_CHARS_FOR_SUGGESTIONS_ALGOLIA = 2

View File

@@ -5,7 +5,7 @@ import { EventManagerInterface, PreviewManagerInterface } from './interfaces'
export class PreviewManager implements PreviewManagerInterface {
previewCamera: THREE.Camera
previewContainer: HTMLDivElement = {} as HTMLDivElement
previewContainer: HTMLDivElement = null!
showPreview: boolean = true
previewWidth: number = 120

View File

@@ -5,6 +5,7 @@ import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
import { useNodePaste } from '@/composables/node/useNodePaste'
import { t } from '@/i18n'
import type { ResultItemType } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { DOMWidget } from '@/scripts/domWidget'
import { useToastStore } from '@/stores/toastStore'
@@ -12,8 +13,6 @@ import { useToastStore } from '@/stores/toastStore'
import { api } from '../../scripts/api'
import { app } from '../../scripts/app'
type FolderType = 'input' | 'output' | 'temp'
function splitFilePath(path: string): [string, string] {
const folder_separator = path.lastIndexOf('/')
if (folder_separator === -1) {
@@ -28,7 +27,7 @@ function splitFilePath(path: string): [string, string] {
function getResourceURL(
subfolder: string,
filename: string,
type: FolderType = 'input'
type: ResultItemType = 'input'
): string {
const params = [
'filename=' + encodeURIComponent(filename),

View File

@@ -44,6 +44,18 @@
"Comfy_Canvas_FitView": {
"label": "Fit view to selected nodes"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "Move Selected Nodes Down"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "Move Selected Nodes Left"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "Move Selected Nodes Right"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "Move Selected Nodes Up"
},
"Comfy_Canvas_ResetView": {
"label": "Reset View"
},
@@ -135,10 +147,10 @@
"label": "Load Default Workflow"
},
"Comfy_Manager_CustomNodesManager": {
"label": "Custom Nodes Manager"
"label": "Toggle the Custom Nodes Manager"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Toggle Progress Dialog"
"label": "Toggle the Custom Nodes Manager Progress Bar"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "Open Mask Editor for Selected Node"

View File

@@ -161,6 +161,7 @@
"lastUpdated": "Last Updated",
"noDescription": "No description available",
"installSelected": "Install Selected",
"installAllMissingNodes": "Install All Missing Nodes",
"packsSelected": "Packs Selected",
"status": {
"active": "Active",
@@ -794,6 +795,10 @@
"Browse Templates": "Browse Templates",
"Delete Selected Items": "Delete Selected Items",
"Fit view to selected nodes": "Fit view to selected nodes",
"Move Selected Nodes Down": "Move Selected Nodes Down",
"Move Selected Nodes Left": "Move Selected Nodes Left",
"Move Selected Nodes Right": "Move Selected Nodes Right",
"Move Selected Nodes Up": "Move Selected Nodes Up",
"Reset View": "Reset View",
"Resize Selected Nodes": "Resize Selected Nodes",
"Canvas Toggle Link Visibility": "Canvas Toggle Link Visibility",
@@ -824,8 +829,8 @@
"ComfyUI Issues": "ComfyUI Issues",
"Interrupt": "Interrupt",
"Load Default Workflow": "Load Default Workflow",
"Custom Nodes Manager": "Custom Nodes Manager",
"Toggle Progress Dialog": "Toggle Progress Dialog",
"Toggle the Custom Nodes Manager": "Toggle the Custom Nodes Manager",
"Toggle the Custom Nodes Manager Progress Bar": "Toggle the Custom Nodes Manager Progress Bar",
"Open Mask Editor for Selected Node": "Open Mask Editor for Selected Node",
"New": "New",
"Clipspace": "Clipspace",
@@ -1190,6 +1195,11 @@
"missingModels": "Missing Models",
"missingModelsMessage": "When loading the graph, the following models were not found"
},
"loadWorkflowWarning": {
"outdatedVersion": "Some nodes require a newer version of ComfyUI (current: {version}). Please update to use all nodes.",
"outdatedVersionGeneric": "Some nodes require a newer version of ComfyUI. Please update to use all nodes.",
"coreNodesFromVersion": "Requires ComfyUI {version}:"
},
"errorDialog": {
"defaultTitle": "An error occurred",
"loadWorkflowTitle": "Loading aborted due to error reloading workflow data",

View File

@@ -44,6 +44,18 @@
"Comfy_Canvas_FitView": {
"label": "Ajustar vista a los nodos seleccionados"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "Mover nodos seleccionados hacia abajo"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "Mover nodos seleccionados a la izquierda"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "Mover nodos seleccionados a la derecha"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "Mover nodos seleccionados hacia arriba"
},
"Comfy_Canvas_ResetView": {
"label": "Restablecer vista"
},

View File

@@ -551,6 +551,11 @@
"uploadBackgroundImage": "Subir imagen de fondo",
"uploadTexture": "Subir textura"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "Requiere ComfyUI {version}:",
"outdatedVersion": "Algunos nodos requieren una versión más reciente de ComfyUI (actual: {version}). Por favor, actualiza para usar todos los nodos.",
"outdatedVersionGeneric": "Algunos nodos requieren una versión más reciente de ComfyUI. Por favor, actualiza para usar todos los nodos."
},
"maintenance": {
"None": "Ninguno",
"OK": "OK",
@@ -586,6 +591,7 @@
},
"inWorkflow": "En Flujo de Trabajo",
"infoPanelEmpty": "Haz clic en un elemento para ver la información",
"installAllMissingNodes": "Instalar todos los nodos faltantes",
"installSelected": "Instalar Seleccionado",
"installationQueue": "Cola de Instalación",
"lastUpdated": "Última Actualización",
@@ -694,7 +700,6 @@
"ComfyUI Issues": "Problemas de ComfyUI",
"Contact Support": "Contactar soporte",
"Convert selected nodes to group node": "Convertir nodos seleccionados en nodo de grupo",
"Custom Nodes Manager": "Gestor de nodos personalizados",
"Delete Selected Items": "Eliminar elementos seleccionados",
"Desktop User Guide": "Guía de usuario de escritorio",
"Duplicate Current Workflow": "Duplicar flujo de trabajo actual",
@@ -709,6 +714,10 @@
"Interrupt": "Interrumpir",
"Load Default Workflow": "Cargar flujo de trabajo predeterminado",
"Manage group nodes": "Gestionar nodos de grupo",
"Move Selected Nodes Down": "Mover nodos seleccionados hacia abajo",
"Move Selected Nodes Left": "Mover nodos seleccionados hacia la izquierda",
"Move Selected Nodes Right": "Mover nodos seleccionados hacia la derecha",
"Move Selected Nodes Up": "Mover nodos seleccionados hacia arriba",
"Mute/Unmute Selected Nodes": "Silenciar/Activar sonido de nodos seleccionados",
"New": "Nuevo",
"Next Opened Workflow": "Siguiente flujo de trabajo abierto",
@@ -744,12 +753,13 @@
"Toggle Logs Bottom Panel": "Alternar panel inferior de registros",
"Toggle Model Library Sidebar": "Alternar barra lateral de biblioteca de modelos",
"Toggle Node Library Sidebar": "Alternar barra lateral de biblioteca de nodos",
"Toggle Progress Dialog": "Alternar diálogo de progreso",
"Toggle Queue Sidebar": "Alternar barra lateral de cola",
"Toggle Search Box": "Alternar caja de búsqueda",
"Toggle Terminal Bottom Panel": "Alternar panel inferior de terminal",
"Toggle Theme (Dark/Light)": "Alternar tema (Oscuro/Claro)",
"Toggle Workflows Sidebar": "Alternar barra lateral de flujos de trabajo",
"Toggle the Custom Nodes Manager": "Alternar el Administrador de Nodos Personalizados",
"Toggle the Custom Nodes Manager Progress Bar": "Alternar la Barra de Progreso del Administrador de Nodos Personalizados",
"Undo": "Deshacer",
"Ungroup selected group nodes": "Desagrupar nodos de grupo seleccionados",
"Workflow": "Flujo de trabajo",

View File

@@ -44,6 +44,18 @@
"Comfy_Canvas_FitView": {
"label": "Ajuster la vue aux nœuds sélectionnés"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "Déplacer les nœuds sélectionnés vers le bas"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "Déplacer les nœuds sélectionnés vers la gauche"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "Déplacer les nœuds sélectionnés vers la droite"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "Déplacer les nœuds sélectionnés vers le haut"
},
"Comfy_Canvas_ResetView": {
"label": "Réinitialiser la vue"
},

View File

@@ -551,6 +551,11 @@
"uploadBackgroundImage": "Télécharger l'image de fond",
"uploadTexture": "Télécharger Texture"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "Nécessite ComfyUI {version} :",
"outdatedVersion": "Certains nœuds nécessitent une version plus récente de ComfyUI (actuelle : {version}). Veuillez mettre à jour pour utiliser tous les nœuds.",
"outdatedVersionGeneric": "Certains nœuds nécessitent une version plus récente de ComfyUI. Veuillez mettre à jour pour utiliser tous les nœuds."
},
"maintenance": {
"None": "Aucun",
"OK": "OK",
@@ -586,6 +591,7 @@
},
"inWorkflow": "Dans le flux de travail",
"infoPanelEmpty": "Cliquez sur un élément pour voir les informations",
"installAllMissingNodes": "Installer tous les nœuds manquants",
"installSelected": "Installer sélectionné",
"installationQueue": "File d'attente d'installation",
"lastUpdated": "Dernière mise à jour",
@@ -694,7 +700,6 @@
"ComfyUI Issues": "Problèmes de ComfyUI",
"Contact Support": "Contacter le support",
"Convert selected nodes to group node": "Convertir les nœuds sélectionnés en nœud de groupe",
"Custom Nodes Manager": "Gestionnaire de Nœuds Personnalisés",
"Delete Selected Items": "Supprimer les éléments sélectionnés",
"Desktop User Guide": "Guide de l'utilisateur de bureau",
"Duplicate Current Workflow": "Dupliquer le flux de travail actuel",
@@ -709,6 +714,10 @@
"Interrupt": "Interrompre",
"Load Default Workflow": "Charger le flux de travail par défaut",
"Manage group nodes": "Gérer les nœuds de groupe",
"Move Selected Nodes Down": "Déplacer les nœuds sélectionnés vers le bas",
"Move Selected Nodes Left": "Déplacer les nœuds sélectionnés vers la gauche",
"Move Selected Nodes Right": "Déplacer les nœuds sélectionnés vers la droite",
"Move Selected Nodes Up": "Déplacer les nœuds sélectionnés vers le haut",
"Mute/Unmute Selected Nodes": "Mettre en sourdine/Activer le son des nœuds sélectionnés",
"New": "Nouveau",
"Next Opened Workflow": "Prochain flux de travail ouvert",
@@ -744,12 +753,13 @@
"Toggle Logs Bottom Panel": "Basculer le panneau inférieur des journaux",
"Toggle Model Library Sidebar": "Basculer la barre latérale de la bibliothèque de modèles",
"Toggle Node Library Sidebar": "Basculer la barre latérale de la bibliothèque de nœuds",
"Toggle Progress Dialog": "Basculer la boîte de dialogue de progression",
"Toggle Queue Sidebar": "Basculer la barre latérale de la file d'attente",
"Toggle Search Box": "Basculer la boîte de recherche",
"Toggle Terminal Bottom Panel": "Basculer le panneau inférieur du terminal",
"Toggle Theme (Dark/Light)": "Basculer le thème (Sombre/Clair)",
"Toggle Workflows Sidebar": "Basculer la barre latérale des flux de travail",
"Toggle the Custom Nodes Manager": "Basculer le gestionnaire de nœuds personnalisés",
"Toggle the Custom Nodes Manager Progress Bar": "Basculer la barre de progression du gestionnaire de nœuds personnalisés",
"Undo": "Annuler",
"Ungroup selected group nodes": "Dégrouper les nœuds de groupe sélectionnés",
"Workflow": "Flux de travail",

View File

@@ -44,6 +44,18 @@
"Comfy_Canvas_FitView": {
"label": "選択したノードにビューを合わせる"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "選択したノードを下に移動"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "選択したノードを左に移動"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "選択したノードを右に移動"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "選択したノードを上に移動"
},
"Comfy_Canvas_ResetView": {
"label": "ビューをリセット"
},

View File

@@ -551,6 +551,11 @@
"uploadBackgroundImage": "背景画像をアップロード",
"uploadTexture": "テクスチャをアップロード"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "ComfyUI {version} が必要です:",
"outdatedVersion": "一部のードはより新しいバージョンのComfyUIが必要です現在のバージョン{version})。すべてのノードを使用するにはアップデートしてください。",
"outdatedVersionGeneric": "一部のードはより新しいバージョンのComfyUIが必要です。すべてのードを使用するにはアップデートしてください。"
},
"maintenance": {
"None": "なし",
"OK": "OK",
@@ -586,6 +591,7 @@
},
"inWorkflow": "ワークフロー内",
"infoPanelEmpty": "アイテムをクリックして情報を表示します",
"installAllMissingNodes": "すべての不足しているノードをインストール",
"installSelected": "選択したものをインストール",
"installationQueue": "インストールキュー",
"lastUpdated": "最終更新日",
@@ -694,7 +700,6 @@
"ComfyUI Issues": "ComfyUIの問題",
"Contact Support": "サポートに連絡",
"Convert selected nodes to group node": "選択したノードをグループノードに変換",
"Custom Nodes Manager": "カスタムノードマネージャ",
"Delete Selected Items": "選択したアイテムを削除",
"Desktop User Guide": "デスクトップユーザーガイド",
"Duplicate Current Workflow": "現在のワークフローを複製",
@@ -709,6 +714,10 @@
"Interrupt": "中断",
"Load Default Workflow": "デフォルトワークフローを読み込む",
"Manage group nodes": "グループノードを管理",
"Move Selected Nodes Down": "選択したノードを下へ移動",
"Move Selected Nodes Left": "選択したノードを左へ移動",
"Move Selected Nodes Right": "選択したノードを右へ移動",
"Move Selected Nodes Up": "選択したノードを上へ移動",
"Mute/Unmute Selected Nodes": "選択したノードのミュート/ミュート解除",
"New": "新規",
"Next Opened Workflow": "次に開いたワークフロー",
@@ -744,12 +753,13 @@
"Toggle Logs Bottom Panel": "ログパネル下部を切り替え",
"Toggle Model Library Sidebar": "モデルライブラリサイドバーを切り替え",
"Toggle Node Library Sidebar": "ノードライブラリサイドバーを切り替え",
"Toggle Progress Dialog": "進行状況ダイアログの切り替え",
"Toggle Queue Sidebar": "キューサイドバーを切り替え",
"Toggle Search Box": "検索ボックスの切り替え",
"Toggle Terminal Bottom Panel": "ターミナルパネル下部を切り替え",
"Toggle Theme (Dark/Light)": "テーマを切り替え(ダーク/ライト)",
"Toggle Workflows Sidebar": "ワークフローサイドバーを切り替え",
"Toggle the Custom Nodes Manager": "カスタムノードマネージャーを切り替え",
"Toggle the Custom Nodes Manager Progress Bar": "カスタムノードマネージャーの進行状況バーを切り替え",
"Undo": "元に戻す",
"Ungroup selected group nodes": "選択したグループノードのグループ解除",
"Workflow": "ワークフロー",

View File

@@ -44,6 +44,18 @@
"Comfy_Canvas_FitView": {
"label": "선택한 노드에 뷰 맞추기"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "선택한 노드 아래로 이동"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "선택한 노드 왼쪽으로 이동"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "선택한 노드 오른쪽으로 이동"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "선택한 노드 위로 이동"
},
"Comfy_Canvas_ResetView": {
"label": "뷰 재설정"
},

View File

@@ -551,6 +551,11 @@
"uploadBackgroundImage": "배경 이미지 업로드",
"uploadTexture": "텍스처 업로드"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "ComfyUI {version} 이상 필요:",
"outdatedVersion": "일부 노드는 더 최신 버전의 ComfyUI가 필요합니다 (현재: {version}). 모든 노드를 사용하려면 업데이트해 주세요.",
"outdatedVersionGeneric": "일부 노드는 더 최신 버전의 ComfyUI가 필요합니다. 모든 노드를 사용하려면 업데이트해 주세요."
},
"maintenance": {
"None": "없음",
"OK": "확인",
@@ -586,6 +591,7 @@
},
"inWorkflow": "워크플로우 내",
"infoPanelEmpty": "정보를 보려면 항목을 클릭하세요",
"installAllMissingNodes": "모든 누락된 노드 설치",
"installSelected": "선택한 항목 설치",
"installationQueue": "설치 대기열",
"lastUpdated": "마지막 업데이트",
@@ -694,7 +700,6 @@
"ComfyUI Issues": "ComfyUI 이슈 페이지",
"Contact Support": "고객 지원 문의",
"Convert selected nodes to group node": "선택한 노드를 그룹 노드로 변환",
"Custom Nodes Manager": "사용자 정의 노드 관리자",
"Delete Selected Items": "선택한 항목 삭제",
"Desktop User Guide": "데스크톱 사용자 가이드",
"Duplicate Current Workflow": "현재 워크플로 복제",
@@ -709,6 +714,10 @@
"Interrupt": "중단",
"Load Default Workflow": "기본 워크플로 불러오기",
"Manage group nodes": "그룹 노드 관리",
"Move Selected Nodes Down": "선택한 노드 아래로 이동",
"Move Selected Nodes Left": "선택한 노드 왼쪽으로 이동",
"Move Selected Nodes Right": "선택한 노드 오른쪽으로 이동",
"Move Selected Nodes Up": "선택한 노드 위로 이동",
"Mute/Unmute Selected Nodes": "선택한 노드 활성화/비활성화",
"New": "새로 만들기",
"Next Opened Workflow": "다음 열린 워크플로",
@@ -744,12 +753,13 @@
"Toggle Logs Bottom Panel": "로그 하단 패널 전환",
"Toggle Model Library Sidebar": "모델 라이브러리 사이드바 전환",
"Toggle Node Library Sidebar": "노드 라이브러리 사이드바 전환",
"Toggle Progress Dialog": "진행 상황 대화 상자 전환",
"Toggle Queue Sidebar": "실행 대기열 사이드바 전환",
"Toggle Search Box": "검색 상자 전환",
"Toggle Terminal Bottom Panel": "터미널 하단 패널 전환",
"Toggle Theme (Dark/Light)": "테마 전환 (어두운/밝은)",
"Toggle Workflows Sidebar": "워크플로 사이드바 전환",
"Toggle the Custom Nodes Manager": "커스텀 노드 매니저 전환",
"Toggle the Custom Nodes Manager Progress Bar": "커스텀 노드 매니저 진행률 표시줄 전환",
"Undo": "실행 취소",
"Ungroup selected group nodes": "선택한 그룹 노드 그룹 해제",
"Workflow": "워크플로",

View File

@@ -44,6 +44,18 @@
"Comfy_Canvas_FitView": {
"label": "Подогнать вид к выбранным нодам"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "Переместить выбранные узлы вниз"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "Переместить выбранные узлы влево"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "Переместить выбранные узлы вправо"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "Переместить выбранные узлы вверх"
},
"Comfy_Canvas_ResetView": {
"label": "Сбросить вид"
},

View File

@@ -551,6 +551,11 @@
"uploadBackgroundImage": "Загрузить фоновое изображение",
"uploadTexture": "Загрузить текстуру"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "Требуется ComfyUI {version}:",
"outdatedVersion": "Некоторые узлы требуют более новой версии ComfyUI (текущая: {version}). Пожалуйста, обновите, чтобы использовать все узлы.",
"outdatedVersionGeneric": "Некоторые узлы требуют более новой версии ComfyUI. Пожалуйста, обновите, чтобы использовать все узлы."
},
"maintenance": {
"None": "Нет",
"OK": "OK",
@@ -586,6 +591,7 @@
},
"inWorkflow": "В рабочем процессе",
"infoPanelEmpty": "Нажмите на элемент, чтобы увидеть информацию",
"installAllMissingNodes": "Установить все отсутствующие узлы",
"installSelected": "Установить выбранное",
"installationQueue": "Очередь установки",
"lastUpdated": "Последнее обновление",
@@ -694,7 +700,6 @@
"ComfyUI Issues": "Проблемы ComfyUI",
"Contact Support": "Связаться с поддержкой",
"Convert selected nodes to group node": "Преобразовать выбранные ноды в групповую ноду",
"Custom Nodes Manager": "Менеджер Пользовательских Узлов",
"Delete Selected Items": "Удалить выбранные элементы",
"Desktop User Guide": "Руководство пользователя для настольных ПК",
"Duplicate Current Workflow": "Дублировать текущий рабочий процесс",
@@ -709,6 +714,10 @@
"Interrupt": "Прервать",
"Load Default Workflow": "Загрузить стандартный рабочий процесс",
"Manage group nodes": "Управление групповыми нодами",
"Move Selected Nodes Down": "Переместить выбранные узлы вниз",
"Move Selected Nodes Left": "Переместить выбранные узлы влево",
"Move Selected Nodes Right": "Переместить выбранные узлы вправо",
"Move Selected Nodes Up": "Переместить выбранные узлы вверх",
"Mute/Unmute Selected Nodes": "Отключить/включить звук для выбранных нод",
"New": "Новый",
"Next Opened Workflow": "Следующий открытый рабочий процесс",
@@ -744,12 +753,13 @@
"Toggle Logs Bottom Panel": "Переключение нижней панели журналов",
"Toggle Model Library Sidebar": "Переключение боковой панели библиотеки моделей",
"Toggle Node Library Sidebar": "Переключение боковой панели библиотеки нод",
"Toggle Progress Dialog": "Переключить диалоговое окно прогресса",
"Toggle Queue Sidebar": "Переключение боковой панели очереди",
"Toggle Search Box": "Переключить поисковую панель",
"Toggle Terminal Bottom Panel": "Переключение нижней панели терминала",
"Toggle Theme (Dark/Light)": "Переключение темы (Тёмная/Светлая)",
"Toggle Workflows Sidebar": "Переключение боковой панели рабочих процессов",
"Toggle the Custom Nodes Manager": "Переключить менеджер пользовательских узлов",
"Toggle the Custom Nodes Manager Progress Bar": "Переключить индикатор выполнения менеджера пользовательских узлов",
"Undo": "Отменить",
"Ungroup selected group nodes": "Разгруппировать выбранные групповые ноды",
"Workflow": "Рабочий процесс",

View File

@@ -44,6 +44,18 @@
"Comfy_Canvas_FitView": {
"label": "适应视图到选中节点"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "下移选中的节点"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "向左移动选中节点"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "向右移动选中节点"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "上移选中的节点"
},
"Comfy_Canvas_ResetView": {
"label": "重置视图"
},

View File

@@ -551,6 +551,11 @@
"uploadBackgroundImage": "上传背景图片",
"uploadTexture": "上传纹理"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "需要 ComfyUI {version}",
"outdatedVersion": "某些节点需要更高版本的 ComfyUI当前版本{version})。请更新以使用所有节点。",
"outdatedVersionGeneric": "某些节点需要更高版本的 ComfyUI。请更新以使用所有节点。"
},
"maintenance": {
"None": "无",
"OK": "确定",
@@ -586,6 +591,7 @@
},
"inWorkflow": "在工作流中",
"infoPanelEmpty": "点击一个项目查看信息",
"installAllMissingNodes": "安装所有缺失节点",
"installSelected": "安装选定",
"installationQueue": "安装队列",
"lastUpdated": "最后更新",
@@ -694,7 +700,6 @@
"ComfyUI Issues": "ComfyUI 问题",
"Contact Support": "联系支持",
"Convert selected nodes to group node": "将选中节点转换为组节点",
"Custom Nodes Manager": "自定义节点管理器",
"Delete Selected Items": "删除选定的项目",
"Desktop User Guide": "桌面端用户指南",
"Duplicate Current Workflow": "复制当前工作流",
@@ -709,6 +714,10 @@
"Interrupt": "中断",
"Load Default Workflow": "加载默认工作流",
"Manage group nodes": "管理组节点",
"Move Selected Nodes Down": "下移所选节点",
"Move Selected Nodes Left": "左移所选节点",
"Move Selected Nodes Right": "右移所选节点",
"Move Selected Nodes Up": "上移所选节点",
"Mute/Unmute Selected Nodes": "静音/取消静音选定节点",
"New": "新建",
"Next Opened Workflow": "下一个打开的工作流",
@@ -744,12 +753,13 @@
"Toggle Logs Bottom Panel": "切换日志底部面板",
"Toggle Model Library Sidebar": "切换模型库侧边栏",
"Toggle Node Library Sidebar": "切换节点库侧边栏",
"Toggle Progress Dialog": "切换进度对话框",
"Toggle Queue Sidebar": "切换队列侧边栏",
"Toggle Search Box": "切换搜索框",
"Toggle Terminal Bottom Panel": "切换终端底部面板",
"Toggle Theme (Dark/Light)": "切换主题(暗/亮)",
"Toggle Workflows Sidebar": "切换工作流侧边栏",
"Toggle the Custom Nodes Manager": "切换自定义节点管理器",
"Toggle the Custom Nodes Manager Progress Bar": "切换自定义节点管理器进度条",
"Undo": "撤销",
"Ungroup selected group nodes": "解散选中组节点",
"Workflow": "工作流",
@@ -1033,9 +1043,9 @@
"Extension": "扩展",
"General": "常规",
"Graph": "画面",
"Group": "组节点",
"Group": "组",
"Keybinding": "快捷键",
"Light": "浅色",
"Light": "光照",
"Link": "连线",
"LinkRelease": "释放链接",
"LiteGraph": "画面",

View File

@@ -11,10 +11,13 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
const zNodeType = z.string()
const zQueueIndex = z.number()
const zPromptId = z.string()
export const resultItemType = z.enum(['input', 'output', 'temp'])
export type ResultItemType = z.infer<typeof resultItemType>
const zResultItem = z.object({
filename: z.string().optional(),
subfolder: z.string().optional(),
type: z.string().optional()
type: resultItemType.optional()
})
export type ResultItem = z.infer<typeof zResultItem>
const zOutputs = z

View File

@@ -46,22 +46,26 @@ export function transformNodeDefV1ToV2(
const outputs: OutputSpecV2[] = []
if (nodeDefV1.output) {
nodeDefV1.output.forEach((outputType, index) => {
const outputSpec: OutputSpecV2 = {
index,
name: nodeDefV1.output_name?.[index] || `output_${index}`,
type: Array.isArray(outputType) ? 'COMBO' : outputType,
is_list: nodeDefV1.output_is_list?.[index] || false,
tooltip: nodeDefV1.output_tooltips?.[index]
}
if (Array.isArray(nodeDefV1.output)) {
nodeDefV1.output.forEach((outputType, index) => {
const outputSpec: OutputSpecV2 = {
index,
name: nodeDefV1.output_name?.[index] || `output_${index}`,
type: Array.isArray(outputType) ? 'COMBO' : outputType,
is_list: nodeDefV1.output_is_list?.[index] || false,
tooltip: nodeDefV1.output_tooltips?.[index]
}
// Add options for combo outputs
if (Array.isArray(outputType)) {
outputSpec.options = outputType
}
// Add options for combo outputs
if (Array.isArray(outputType)) {
outputSpec.options = outputType
}
outputs.push(outputSpec)
})
outputs.push(outputSpec)
})
} else {
console.warn('nodeDefV1.output is not an array:', nodeDefV1.output)
}
}
// Create the V2 node definition

View File

@@ -1,6 +1,8 @@
import { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
import { resultItemType } from '@/schemas/apiSchema'
const zComboOption = z.union([z.string(), z.number()])
const zRemoteWidgetConfig = z.object({
route: z.string().url().or(z.string().startsWith('/')),
@@ -72,7 +74,7 @@ export const zStringInputOptions = zBaseInputOptions.extend({
export const zComboInputOptions = zBaseInputOptions.extend({
control_after_generate: z.boolean().optional(),
image_upload: z.boolean().optional(),
image_folder: z.enum(['input', 'output', 'temp']).optional(),
image_folder: resultItemType.optional(),
allow_batch: z.boolean().optional(),
video_upload: z.boolean().optional(),
animated_image_upload: z.boolean().optional(),

View File

@@ -1,245 +0,0 @@
import QuickLRU from '@alloc/quick-lru'
import type {
BaseSearchParamsWithoutQuery,
Hit,
SearchQuery,
SearchResponse
} from 'algoliasearch/dist/lite/browser'
import { liteClient as algoliasearch } from 'algoliasearch/dist/lite/builds/browser'
import { omit } from 'lodash'
import { components } from '@/types/comfyRegistryTypes'
import { paramsToCacheKey } from '@/utils/formatUtil'
const DEFAULT_MAX_CACHE_SIZE = 64
const DEFAULT_MIN_CHARS_FOR_SUGGESTIONS = 2
type SafeNestedProperty<
T,
K1 extends keyof T,
K2 extends keyof NonNullable<T[K1]>
> = T[K1] extends undefined | null ? undefined : NonNullable<T[K1]>[K2]
type RegistryNodePack = components['schemas']['Node']
type SearchPacksResult = {
nodePacks: Hit<AlgoliaNodePack>[]
querySuggestions: Hit<NodesIndexSuggestion>[]
}
export interface AlgoliaNodePack {
objectID: RegistryNodePack['id']
name: RegistryNodePack['name']
publisher_id: SafeNestedProperty<RegistryNodePack, 'publisher', 'id'>
description: RegistryNodePack['description']
comfy_nodes: string[]
total_install: RegistryNodePack['downloads']
id: RegistryNodePack['id']
create_time: string
update_time: SafeNestedProperty<
RegistryNodePack,
'latest_version',
'createdAt'
>
license: RegistryNodePack['license']
repository_url: RegistryNodePack['repository']
status: RegistryNodePack['status']
latest_version: SafeNestedProperty<
RegistryNodePack,
'latest_version',
'version'
>
latest_version_status: SafeNestedProperty<
RegistryNodePack,
'latest_version',
'status'
>
comfy_node_extract_status: SafeNestedProperty<
RegistryNodePack,
'latest_version',
'comfy_node_extract_status'
>
icon_url: RegistryNodePack['icon']
}
export type SearchAttribute = keyof AlgoliaNodePack
const RETRIEVE_ATTRIBUTES: SearchAttribute[] = [
'comfy_nodes',
'name',
'description',
'latest_version',
'status',
'publisher_id',
'total_install',
'create_time',
'update_time',
'license',
'repository_url',
'latest_version_status',
'comfy_node_extract_status',
'id',
'icon_url'
]
export interface NodesIndexSuggestion {
nb_words: number
nodes_index: {
exact_nb_hits: number
facets: {
exact_matches: Record<string, number>
analytics: Record<string, any>
}
}
objectID: RegistryNodePack['id']
popularity: number
query: string
}
type SearchNodePacksParams = BaseSearchParamsWithoutQuery & {
pageSize: number
pageNumber: number
restrictSearchableAttributes: SearchAttribute[]
}
interface AlgoliaSearchServiceOptions {
/**
* Maximum number of search results to store in the cache.
* The cache is automatically cleared when the component is unmounted.
* @default 64
*/
maxCacheSize?: number
/**
* Minimum number of characters for suggestions. An additional query
* will be made to the suggestions/completions index for queries that
* are this length or longer.
* @default 3
*/
minCharsForSuggestions?: number
}
export const useAlgoliaSearchService = (
options: AlgoliaSearchServiceOptions = {}
) => {
const {
maxCacheSize = DEFAULT_MAX_CACHE_SIZE,
minCharsForSuggestions = DEFAULT_MIN_CHARS_FOR_SUGGESTIONS
} = options
const searchClient = algoliasearch(__ALGOLIA_APP_ID__, __ALGOLIA_API_KEY__)
const searchPacksCache = new QuickLRU<string, SearchPacksResult>({
maxSize: maxCacheSize
})
const toRegistryLatestVersion = (
algoliaNode: AlgoliaNodePack
): RegistryNodePack['latest_version'] => {
return {
version: algoliaNode.latest_version,
createdAt: algoliaNode.update_time,
status: algoliaNode.latest_version_status,
comfy_node_extract_status:
algoliaNode.comfy_node_extract_status ?? undefined
}
}
const toRegistryPublisher = (
algoliaNode: AlgoliaNodePack
): RegistryNodePack['publisher'] => {
return {
id: algoliaNode.publisher_id,
name: algoliaNode.publisher_id
}
}
/**
* Convert from node pack in Algolia format to Comfy Registry format
*/
function toRegistryPack(algoliaNode: AlgoliaNodePack): RegistryNodePack {
return {
id: algoliaNode.id ?? algoliaNode.objectID,
name: algoliaNode.name,
description: algoliaNode.description,
repository: algoliaNode.repository_url,
license: algoliaNode.license,
downloads: algoliaNode.total_install,
status: algoliaNode.status,
icon: algoliaNode.icon_url,
latest_version: toRegistryLatestVersion(algoliaNode),
publisher: toRegistryPublisher(algoliaNode),
// @ts-expect-error remove when comfy_nodes is added to node (pack) info
comfy_nodes: algoliaNode.comfy_nodes
}
}
/**
* Search for node packs in Algolia
*/
const searchPacks = async (
query: string,
params: SearchNodePacksParams
): Promise<SearchPacksResult> => {
const { pageSize, pageNumber } = params
const rest = omit(params, ['pageSize', 'pageNumber'])
const requests: SearchQuery[] = [
{
query,
indexName: 'nodes_index',
attributesToRetrieve: RETRIEVE_ATTRIBUTES,
...rest,
hitsPerPage: pageSize,
page: pageNumber
}
]
const shouldQuerySuggestions = query.length >= minCharsForSuggestions
// If the query is long enough, also query the suggestions index
if (shouldQuerySuggestions) {
requests.push({
indexName: 'nodes_index_query_suggestions',
query
})
}
const { results } = await searchClient.search<
AlgoliaNodePack | NodesIndexSuggestion
>({
requests,
strategy: 'none'
})
const [nodePacks, querySuggestions = { hits: [] }] = results as [
SearchResponse<AlgoliaNodePack>,
SearchResponse<NodesIndexSuggestion>
]
return {
nodePacks: nodePacks.hits,
querySuggestions: querySuggestions.hits
}
}
const searchPacksCached = async (
query: string,
params: SearchNodePacksParams
): Promise<SearchPacksResult> => {
const cacheKey = paramsToCacheKey({ query, ...params })
const cachedResult = searchPacksCache.get(cacheKey)
if (cachedResult !== undefined) return cachedResult
const result = await searchPacks(query, params)
searchPacksCache.set(cacheKey, result)
return result
}
const clearSearchPacksCache = () => {
searchPacksCache.clear()
}
return {
searchPacks,
searchPacksCached,
toRegistryPack,
clearSearchPacksCache
}
}

View File

@@ -21,7 +21,6 @@ import TemplateWorkflowsDialogHeader from '@/components/templates/TemplateWorkfl
import { t } from '@/i18n'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import { type ShowDialogOptions, useDialogStore } from '@/stores/dialogStore'
import { ManagerTab } from '@/types/comfyManagerTypes'
export type ConfirmationDialogType =
| 'default'
@@ -129,19 +128,26 @@ export const useDialogService = () => {
}
function showManagerDialog(
props: InstanceType<typeof ManagerDialogContent>['$props'] = {
initialTab: ManagerTab.All
}
props: InstanceType<typeof ManagerDialogContent>['$props'] = {}
) {
dialogStore.showDialog({
key: 'global-manager',
component: ManagerDialogContent,
headerComponent: ManagerHeader,
dialogComponentProps: {
closable: false,
closable: true,
pt: {
header: { class: '!p-0 !m-0' },
content: { class: '!px-0 h-[83vh] w-[90vw] overflow-y-hidden' }
pcCloseButton: {
root: {
class:
'bg-gray-500 dark-theme:bg-neutral-700 w-9 h-9 p-1.5 rounded-full text-white'
}
},
header: { class: '!py-0 px-6 !m-0 h-[68px]' },
content: {
class: '!p-0 h-full w-[90vw] max-w-full flex-1 overflow-hidden'
},
root: { class: 'manager-dialog' }
}
},
props
@@ -157,6 +163,7 @@ export const useDialogService = () => {
headerComponent: ManagerProgressHeader,
footerComponent: ManagerProgressFooter,
props: options?.props,
priority: 2,
dialogComponentProps: {
closable: false,
modal: false,
@@ -397,6 +404,26 @@ export const useDialogService = () => {
}
}
function toggleManagerDialog(
props?: InstanceType<typeof ManagerDialogContent>['$props']
) {
if (dialogStore.isDialogOpen('global-manager')) {
dialogStore.closeDialog({ key: 'global-manager' })
} else {
showManagerDialog(props)
}
}
function toggleManagerProgressDialog(
props?: InstanceType<typeof ManagerProgressDialogContent>['$props']
) {
if (dialogStore.isDialogOpen('global-manager-progress-dialog')) {
dialogStore.closeDialog({ key: 'global-manager-progress-dialog' })
} else {
showManagerProgressDialog({ props })
}
}
return {
showLoadWorkflowWarning,
showMissingModelsWarning,
@@ -414,6 +441,8 @@ export const useDialogService = () => {
showUpdatePasswordDialog,
showExtensionDialog,
prompt,
confirm
confirm,
toggleManagerDialog,
toggleManagerProgressDialog
}
}

View File

@@ -0,0 +1,227 @@
import { useAlgoliaSearchProvider } from '@/services/providers/algoliaSearchProvider'
import { useComfyRegistrySearchProvider } from '@/services/providers/registrySearchProvider'
import type { SearchNodePacksParams } from '@/types/algoliaTypes'
import type { components } from '@/types/comfyRegistryTypes'
import type {
NodePackSearchProvider,
SearchPacksResult
} from '@/types/searchServiceTypes'
type RegistryNodePack = components['schemas']['Node']
interface ProviderState {
provider: NodePackSearchProvider
name: string
isHealthy: boolean
lastError?: Error
lastAttempt?: Date
consecutiveFailures: number
}
const CIRCUIT_BREAKER_THRESHOLD = 3 // Number of failures before circuit opens
const CIRCUIT_BREAKER_TIMEOUT = 60000 // 1 minute before retry
/**
* API Gateway for registry search providers with circuit breaker pattern.
* Acts as a single entry point that routes search requests to appropriate providers
* and handles failures gracefully by falling back to alternative providers.
*
* Implements:
* - Gateway pattern: Single entry point for all search requests
* - Circuit breaker: Prevents repeated calls to failed services
* - Automatic failover: Cascades through providers on failure
*/
export const useRegistrySearchGateway = (): NodePackSearchProvider => {
const providers: ProviderState[] = []
let activeProviderIndex = 0
// Initialize providers in priority order
try {
providers.push({
provider: useAlgoliaSearchProvider(),
name: 'Algolia',
isHealthy: true,
consecutiveFailures: 0
})
} catch (error) {
console.warn('Failed to initialize Algolia provider:', error)
}
providers.push({
provider: useComfyRegistrySearchProvider(),
name: 'ComfyRegistry',
isHealthy: true,
consecutiveFailures: 0
})
// TODO: Add an "offline" provider that operates on a local cache of the registry.
/**
* Check if a provider's circuit breaker should be closed (available to try)
*/
const isCircuitClosed = (providerState: ProviderState): boolean => {
if (providerState.consecutiveFailures < CIRCUIT_BREAKER_THRESHOLD) {
return true
}
// Check if enough time has passed to retry
if (providerState.lastAttempt) {
const timeSinceLastAttempt =
Date.now() - providerState.lastAttempt.getTime()
if (timeSinceLastAttempt > CIRCUIT_BREAKER_TIMEOUT) {
console.info(
`Retrying ${providerState.name} provider after circuit breaker timeout`
)
return true
}
}
return false
}
/**
* Record a successful call to a provider
*/
const recordSuccess = (providerState: ProviderState) => {
providerState.isHealthy = true
providerState.consecutiveFailures = 0
providerState.lastError = undefined
}
/**
* Record a failed call to a provider
*/
const recordFailure = (providerState: ProviderState, error: Error) => {
providerState.consecutiveFailures++
providerState.lastError = error
providerState.lastAttempt = new Date()
if (providerState.consecutiveFailures >= CIRCUIT_BREAKER_THRESHOLD) {
providerState.isHealthy = false
console.warn(
`${providerState.name} provider circuit breaker opened after ${providerState.consecutiveFailures} failures`
)
}
}
/**
* Get the currently active provider based on circuit breaker states
*/
const getActiveProvider = (): NodePackSearchProvider => {
// First, try to use the current active provider if it's healthy
const currentProvider = providers[activeProviderIndex]
if (currentProvider && isCircuitClosed(currentProvider)) {
return currentProvider.provider
}
// Otherwise, find the first healthy provider
for (let i = 0; i < providers.length; i++) {
const providerState = providers[i]
if (isCircuitClosed(providerState)) {
activeProviderIndex = i
return providerState.provider
}
}
throw new Error('No available search providers')
}
/**
* Update the active provider index after a failure.
* Move to the next provider if available.
*/
const updateActiveProviderOnFailure = () => {
if (activeProviderIndex < providers.length - 1) {
activeProviderIndex++
}
}
/**
* Search for node packs.
*/
const searchPacks = async (
query: string,
params: SearchNodePacksParams
): Promise<SearchPacksResult> => {
let lastError: Error | null = null
// Start with the current active provider
for (let attempts = 0; attempts < providers.length; attempts++) {
try {
const provider = getActiveProvider()
const providerState = providers[activeProviderIndex]
const result = await provider.searchPacks(query, params)
recordSuccess(providerState)
return result
} catch (error) {
lastError = error as Error
const providerState = providers[activeProviderIndex]
recordFailure(providerState, lastError)
console.warn(
`${providerState.name} search provider failed (${providerState.consecutiveFailures} failures):`,
error
)
// Try the next provider
updateActiveProviderOnFailure()
}
}
// If we get here, all providers failed
throw new Error(
`All search providers failed. Last error: ${lastError?.message || 'Unknown error'}`
)
}
/**
* Clear the search cache for all providers that implement it.
*/
const clearSearchCache = () => {
for (const providerState of providers) {
try {
providerState.provider.clearSearchCache()
} catch (error) {
console.warn(
`Failed to clear cache for ${providerState.name} provider:`,
error
)
}
}
}
/**
* Get the sort value for a pack.
* @example
* const pack = {
* id: '123',
* name: 'Test Pack',
* downloads: 100
* }
* const sortValue = getSortValue(pack, 'downloads')
* console.log(sortValue) // 100
*/
const getSortValue = (
pack: RegistryNodePack,
sortField: string
): string | number => {
return getActiveProvider().getSortValue(pack, sortField)
}
/**
* Get the sortable fields for the active provider.
* @example
* const sortableFields = getSortableFields()
* console.log(sortableFields) // ['downloads', 'created', 'updated', 'publisher', 'name']
*/
const getSortableFields = () => {
return getActiveProvider().getSortableFields()
}
return {
searchPacks,
clearSearchCache,
getSortValue,
getSortableFields
}
}

View File

@@ -0,0 +1,247 @@
import QuickLRU from '@alloc/quick-lru'
import type {
SearchQuery,
SearchResponse
} from 'algoliasearch/dist/lite/browser'
import { liteClient as algoliasearch } from 'algoliasearch/dist/lite/builds/browser'
import { memoize, omit } from 'lodash'
import {
MIN_CHARS_FOR_SUGGESTIONS_ALGOLIA,
SEARCH_CACHE_MAX_SIZE
} from '@/constants/searchConstants'
import type {
AlgoliaNodePack,
NodesIndexSuggestion,
SearchAttribute,
SearchNodePacksParams
} from '@/types/algoliaTypes'
import { SortableAlgoliaField } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import type {
NodePackSearchProvider,
SearchPacksResult,
SortableField
} from '@/types/searchServiceTypes'
import { paramsToCacheKey } from '@/utils/formatUtil'
type RegistryNodePack = components['schemas']['Node']
const RETRIEVE_ATTRIBUTES: SearchAttribute[] = [
'comfy_nodes',
'name',
'description',
'latest_version',
'status',
'publisher_id',
'total_install',
'create_time',
'update_time',
'license',
'repository_url',
'latest_version_status',
'comfy_node_extract_status',
'id',
'icon_url',
'github_stars',
'supported_os',
'supported_comfyui_version',
'supported_comfyui_frontend_version',
'supported_accelerators',
'banner_url'
]
const searchPacksCache = new QuickLRU<string, SearchPacksResult>({
maxSize: SEARCH_CACHE_MAX_SIZE
})
const toRegistryLatestVersion = (
algoliaNode: AlgoliaNodePack
): RegistryNodePack['latest_version'] => {
return {
version: algoliaNode.latest_version,
createdAt: algoliaNode.update_time,
status: algoliaNode.latest_version_status,
comfy_node_extract_status:
algoliaNode.comfy_node_extract_status ?? undefined
}
}
const toRegistryPublisher = (
algoliaNode: AlgoliaNodePack
): RegistryNodePack['publisher'] => {
return {
id: algoliaNode.publisher_id,
name: algoliaNode.publisher_id
}
}
/**
* Convert from node pack in Algolia format to Comfy Registry format
*/
const toRegistryPack = memoize(
(
algoliaNode: AlgoliaNodePack
): RegistryNodePack & { comfy_nodes: string[] } => {
return {
id: algoliaNode.id ?? algoliaNode.objectID,
name: algoliaNode.name,
description: algoliaNode.description,
repository: algoliaNode.repository_url,
license: algoliaNode.license,
downloads: algoliaNode.total_install,
status: algoliaNode.status,
icon: algoliaNode.icon_url,
latest_version: toRegistryLatestVersion(algoliaNode),
publisher: toRegistryPublisher(algoliaNode),
created_at: algoliaNode.create_time,
category: algoliaNode.category,
author: algoliaNode.author,
tags: algoliaNode.tags,
github_stars: algoliaNode.github_stars,
supported_os: algoliaNode.supported_os,
supported_comfyui_version: algoliaNode.supported_comfyui_version,
supported_comfyui_frontend_version:
algoliaNode.supported_comfyui_frontend_version,
supported_accelerators: algoliaNode.supported_accelerators,
banner_url: algoliaNode.banner_url,
comfy_nodes: algoliaNode.comfy_nodes
}
},
(algoliaNode: AlgoliaNodePack) => algoliaNode.id
)
export const useAlgoliaSearchProvider = (): NodePackSearchProvider => {
const searchClient = algoliasearch(__ALGOLIA_APP_ID__, __ALGOLIA_API_KEY__)
/**
* Search for node packs in Algolia (internal method)
*/
const searchPacksInternal = async (
query: string,
params: SearchNodePacksParams
): Promise<SearchPacksResult> => {
const { pageSize, pageNumber } = params
const rest = omit(params, ['pageSize', 'pageNumber'])
const requests: SearchQuery[] = [
{
query,
indexName: 'nodes_index',
attributesToRetrieve: RETRIEVE_ATTRIBUTES,
...rest,
hitsPerPage: pageSize,
page: pageNumber
}
]
const shouldQuerySuggestions =
query.length >= MIN_CHARS_FOR_SUGGESTIONS_ALGOLIA
// If the query is long enough, also query the suggestions index
if (shouldQuerySuggestions) {
requests.push({
indexName: 'nodes_index_query_suggestions',
query
})
}
const { results } = await searchClient.search<
AlgoliaNodePack | NodesIndexSuggestion
>({
requests,
strategy: 'none'
})
const [nodePacks, querySuggestions = { hits: [] }] = results as [
SearchResponse<AlgoliaNodePack>,
SearchResponse<NodesIndexSuggestion>
]
// Convert Algolia hits to RegistryNodePack format
const registryPacks = nodePacks.hits.map(toRegistryPack)
// Extract query suggestions from search results
const suggestions = querySuggestions.hits.map((suggestion) => ({
query: suggestion.query,
popularity: suggestion.popularity
}))
return {
nodePacks: registryPacks,
querySuggestions: suggestions
}
}
/**
* Search for node packs in Algolia with caching.
*/
const searchPacks = async (
query: string,
params: SearchNodePacksParams
): Promise<SearchPacksResult> => {
const cacheKey = paramsToCacheKey({ query, ...params })
const cachedResult = searchPacksCache.get(cacheKey)
if (cachedResult !== undefined) return cachedResult
const result = await searchPacksInternal(query, params)
searchPacksCache.set(cacheKey, result)
return result
}
const clearSearchCache = () => {
searchPacksCache.clear()
}
const getSortValue = (
pack: RegistryNodePack,
sortField: string
): string | number => {
// For Algolia, we rely on the default sorting behavior
// The results are already sorted by the index configuration
// This is mainly used for re-sorting after results are fetched
switch (sortField) {
case SortableAlgoliaField.Downloads:
return pack.downloads ?? 0
case SortableAlgoliaField.Created: {
const createTime = pack.created_at
return createTime ? new Date(createTime).getTime() : 0
}
case SortableAlgoliaField.Updated:
return pack.latest_version?.createdAt
? new Date(pack.latest_version.createdAt).getTime()
: 0
case SortableAlgoliaField.Publisher:
return pack.publisher?.name ?? ''
case SortableAlgoliaField.Name:
return pack.name ?? ''
default:
return 0
}
}
const getSortableFields = (): SortableField[] => {
return [
{
id: SortableAlgoliaField.Downloads,
label: 'Downloads',
direction: 'desc'
},
{ id: SortableAlgoliaField.Created, label: 'Created', direction: 'desc' },
{ id: SortableAlgoliaField.Updated, label: 'Updated', direction: 'desc' },
{
id: SortableAlgoliaField.Publisher,
label: 'Publisher',
direction: 'asc'
},
{ id: SortableAlgoliaField.Name, label: 'Name', direction: 'asc' }
]
}
return {
searchPacks,
clearSearchCache,
getSortValue,
getSortableFields
}
}

View File

@@ -0,0 +1,92 @@
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { SearchNodePacksParams } from '@/types/algoliaTypes'
import type { components } from '@/types/comfyRegistryTypes'
import type {
NodePackSearchProvider,
SearchPacksResult,
SortableField
} from '@/types/searchServiceTypes'
type RegistryNodePack = components['schemas']['Node']
/**
* Search provider for the Comfy Registry.
* Uses public Comfy Registry API.
*/
export const useComfyRegistrySearchProvider = (): NodePackSearchProvider => {
const registryStore = useComfyRegistryStore()
/**
* Search for node packs using the Comfy Registry API.
*/
const searchPacks = async (
query: string,
params: SearchNodePacksParams
): Promise<SearchPacksResult> => {
const { pageSize, pageNumber, restrictSearchableAttributes } = params
// Determine search mode based on searchable attributes
const isNodeSearch = restrictSearchableAttributes?.includes('comfy_nodes')
const searchParams = {
search: isNodeSearch ? undefined : query,
comfy_node_search: isNodeSearch ? query : undefined,
limit: pageSize,
page: pageNumber + 1 // Registry API uses 1-based pagination
}
const searchResult = await registryStore.search.call(searchParams)
if (!searchResult || !searchResult.nodes) {
return {
nodePacks: [],
querySuggestions: []
}
}
return {
nodePacks: searchResult.nodes,
querySuggestions: [] // Registry doesn't support query suggestions
}
}
const clearSearchCache = () => {
registryStore.search.clear()
}
const getSortValue = (
pack: RegistryNodePack,
sortField: string
): string | number => {
switch (sortField) {
case 'downloads':
return pack.downloads ?? 0
case 'name':
return pack.name ?? ''
case 'publisher':
return pack.publisher?.name ?? ''
case 'updated':
return pack.latest_version?.createdAt
? new Date(pack.latest_version.createdAt).getTime()
: 0
default:
return 0
}
}
const getSortableFields = (): SortableField[] => {
return [
{ id: 'downloads', label: 'Downloads', direction: 'desc' },
{ id: 'name', label: 'Name', direction: 'asc' },
{ id: 'publisher', label: 'Publisher', direction: 'asc' },
{ id: 'updated', label: 'Updated', direction: 'desc' }
]
}
return {
searchPacks,
clearSearchCache,
getSortValue,
getSortableFields
}
}

View File

@@ -213,6 +213,7 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
isPackInstalled: isInstalledPackId,
isPackEnabled: isEnabledPackId,
getInstalledPackVersion,
refreshInstalledList,
// Pack actions
installPack,

View File

@@ -40,6 +40,7 @@ interface DialogInstance {
contentProps: Record<string, any>
footerComponent?: Component
dialogComponentProps: DialogComponentProps
priority: number
}
export interface ShowDialogOptions {
@@ -50,6 +51,12 @@ export interface ShowDialogOptions {
component: Component
props?: Record<string, any>
dialogComponentProps?: DialogComponentProps
/**
* Optional priority for dialog stacking.
* A dialog will never be shown above a dialog with a higher priority.
* @default 1
*/
priority?: number
}
export const useDialogStore = defineStore('dialog', () => {
@@ -57,13 +64,29 @@ export const useDialogStore = defineStore('dialog', () => {
const genDialogKey = () => `dialog-${Math.random().toString(36).slice(2, 9)}`
/**
* Inserts a dialog into the stack at the correct position based on priority.
* Higher priority dialogs are placed before lower priority ones.
*/
function insertDialogByPriority(dialog: DialogInstance) {
const insertIndex = dialogStack.value.findIndex(
(d) => d.priority <= dialog.priority
)
dialogStack.value.splice(
insertIndex === -1 ? dialogStack.value.length : insertIndex,
0,
dialog
)
}
function riseDialog(options: { key: string }) {
const dialogKey = options.key
const index = dialogStack.value.findIndex((d) => d.key === dialogKey)
if (index !== -1) {
const dialogs = dialogStack.value.splice(index, 1)
dialogStack.value.push(...dialogs)
const [dialog] = dialogStack.value.splice(index, 1)
insertDialogByPriority(dialog)
}
}
@@ -85,12 +108,13 @@ export const useDialogStore = defineStore('dialog', () => {
component: Component
props?: Record<string, any>
dialogComponentProps?: DialogComponentProps
priority?: number
}) {
if (dialogStack.value.length >= 10) {
dialogStack.value.shift()
}
const dialog = {
const dialog: DialogInstance = {
key: options.key,
visible: true,
title: options.title,
@@ -102,6 +126,7 @@ export const useDialogStore = defineStore('dialog', () => {
: undefined,
component: markRaw(options.component),
contentProps: { ...options.props },
priority: options.priority ?? 1,
dialogComponentProps: {
maximizable: false,
modal: true,
@@ -110,6 +135,7 @@ export const useDialogStore = defineStore('dialog', () => {
dismissableMask: true,
...options.dialogComponentProps,
maximized: false,
// @ts-expect-error TODO: fix this
onMaximize: () => {
dialog.dialogComponentProps.maximized = true
},
@@ -128,7 +154,8 @@ export const useDialogStore = defineStore('dialog', () => {
})
}
}
dialogStack.value.push(dialog)
insertDialogByPriority(dialog)
return dialog
}
@@ -169,11 +196,16 @@ export const useDialogStore = defineStore('dialog', () => {
return dialog
}
function isDialogOpen(key: string) {
return dialogStack.value.some((d) => d.key === key)
}
return {
dialogStack,
riseDialog,
showDialog,
closeDialog,
showExtensionDialog
showExtensionDialog,
isDialogOpen
}
})

View File

@@ -88,54 +88,30 @@ export const useExecutionStore = defineStore('execution', () => {
if (!activePrompt.value) return 0
const total = totalNodesToExecute.value
const done = nodesExecuted.value
return done / total
return total > 0 ? done / total : 0
})
function bindExecutionEvents() {
api.addEventListener(
'execution_start',
handleExecutionStart as EventListener
)
api.addEventListener(
'execution_cached',
handleExecutionCached as EventListener
)
api.addEventListener('executed', handleExecuted as EventListener)
api.addEventListener('executing', handleExecuting as EventListener)
api.addEventListener('progress', handleProgress as EventListener)
api.addEventListener('status', handleStatus as EventListener)
api.addEventListener(
'execution_error',
handleExecutionError as EventListener
)
api.addEventListener('execution_start', handleExecutionStart)
api.addEventListener('execution_cached', handleExecutionCached)
api.addEventListener('executed', handleExecuted)
api.addEventListener('executing', handleExecuting)
api.addEventListener('progress', handleProgress)
api.addEventListener('status', handleStatus)
api.addEventListener('execution_error', handleExecutionError)
}
api.addEventListener('progress_text', handleProgressText as EventListener)
api.addEventListener(
'display_component',
handleDisplayComponent as EventListener
)
api.addEventListener('progress_text', handleProgressText)
api.addEventListener('display_component', handleDisplayComponent)
function unbindExecutionEvents() {
api.removeEventListener(
'execution_start',
handleExecutionStart as EventListener
)
api.removeEventListener(
'execution_cached',
handleExecutionCached as EventListener
)
api.removeEventListener('executed', handleExecuted as EventListener)
api.removeEventListener('executing', handleExecuting as EventListener)
api.removeEventListener('progress', handleProgress as EventListener)
api.removeEventListener('status', handleStatus as EventListener)
api.removeEventListener(
'execution_error',
handleExecutionError as EventListener
)
api.removeEventListener(
'progress_text',
handleProgressText as EventListener
)
api.removeEventListener('execution_start', handleExecutionStart)
api.removeEventListener('execution_cached', handleExecutionCached)
api.removeEventListener('executed', handleExecuted)
api.removeEventListener('executing', handleExecuting)
api.removeEventListener('progress', handleProgress)
api.removeEventListener('status', handleStatus)
api.removeEventListener('execution_error', handleExecutionError)
api.removeEventListener('progress_text', handleProgressText)
}
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
@@ -184,7 +160,7 @@ export const useExecutionStore = defineStore('execution', () => {
clientId.value = api.clientId
// Once we've received the clientId we no longer need to listen
api.removeEventListener('status', handleStatus as EventListener)
api.removeEventListener('status', handleStatus)
}
}

View File

@@ -1,7 +1,11 @@
import { LGraphNode } from '@comfyorg/litegraph'
import { defineStore } from 'pinia'
import { ExecutedWsMessage, ResultItem } from '@/schemas/apiSchema'
import {
ExecutedWsMessage,
ResultItem,
ResultItemType
} from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { parseFilePath } from '@/utils/formatUtil'
@@ -9,7 +13,7 @@ import { isVideoNode } from '@/utils/litegraphUtil'
const createOutputs = (
filenames: string[],
type: string,
type: ResultItemType,
isAnimated: boolean
): ExecutedWsMessage['output'] => {
return {
@@ -88,7 +92,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
{
folder = 'input',
isAnimated = false
}: { folder?: string; isAnimated?: boolean } = {}
}: { folder?: ResultItemType; isAnimated?: boolean } = {}
) {
if (!filenames || !node) return

View File

@@ -7,6 +7,7 @@ import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import type { SettingParams } from '@/types/settingTypes'
import type { TreeNode } from '@/types/treeExplorerTypes'
import { runSettingMigrations } from '@/utils/migration/settingsMigration'
export const getSettingInfo = (setting: SettingParams) => {
const parts = setting.category || setting.id.split('.')
@@ -20,10 +21,6 @@ export interface SettingTreeNode extends TreeNode {
data?: SettingParams
}
function tryMigrateDeprecatedValue(setting: SettingParams, value: any) {
return setting?.migrateDeprecatedValue?.(value) ?? value
}
function onChange(setting: SettingParams, newValue: any, oldValue: any) {
if (setting?.onChange) {
setting.onChange(newValue, oldValue)
@@ -54,16 +51,12 @@ export const useSettingStore = defineStore('setting', () => {
async function set<K extends keyof Settings>(key: K, value: Settings[K]) {
// Clone the incoming value to prevent external mutations
const clonedValue = _.cloneDeep(value)
const newValue = tryMigrateDeprecatedValue(
settingsById.value[key],
clonedValue
)
const oldValue = get(key)
if (newValue === oldValue) return
if (clonedValue === oldValue) return
onChange(settingsById.value[key], newValue, oldValue)
settingValues.value[key] = newValue
await api.storeSetting(key, newValue)
onChange(settingsById.value[key], clonedValue, oldValue)
settingValues.value[key] = clonedValue
await api.storeSetting(key, clonedValue)
}
/**
@@ -102,12 +95,7 @@ export const useSettingStore = defineStore('setting', () => {
settingsById.value[setting.id] = setting
if (settingValues.value[setting.id] !== undefined) {
settingValues.value[setting.id] = tryMigrateDeprecatedValue(
setting,
settingValues.value[setting.id]
)
}
// Trigger onChange callback with current value
onChange(setting, get(setting.id), undefined)
}
@@ -122,6 +110,9 @@ export const useSettingStore = defineStore('setting', () => {
)
}
settingValues.value = await api.getSettings()
// Run migrations after loading values but before settings are registered
await runSettingMigrations()
}
return {

102
src/types/algoliaTypes.ts Normal file
View File

@@ -0,0 +1,102 @@
import type {
BaseSearchParamsWithoutQuery,
Hit
} from 'algoliasearch/dist/lite/browser'
import type { components } from '@/types/comfyRegistryTypes'
type SafeNestedProperty<
T,
K1 extends keyof T,
K2 extends keyof NonNullable<T[K1]>
> = T[K1] extends undefined | null ? undefined : NonNullable<T[K1]>[K2]
type RegistryNodePack = components['schemas']['Node']
/**
* Result of searching the Algolia index.
* Represents the entire result of a search query.
*/
export type SearchPacksResult = {
nodePacks: Hit<AlgoliaNodePack>[]
querySuggestions: Hit<NodesIndexSuggestion>[]
}
/**
* Node pack record after it has been mapped to Algolia index format.
* @see https://github.com/Comfy-Org/comfy-api/blob/main/mapper/algolia.go
*/
export interface AlgoliaNodePack {
objectID: RegistryNodePack['id']
name: RegistryNodePack['name']
publisher_id: SafeNestedProperty<RegistryNodePack, 'publisher', 'id'>
description: RegistryNodePack['description']
comfy_nodes: string[]
total_install: RegistryNodePack['downloads']
id: RegistryNodePack['id']
create_time: string
update_time: SafeNestedProperty<
RegistryNodePack,
'latest_version',
'createdAt'
>
license: RegistryNodePack['license']
repository_url: RegistryNodePack['repository']
status: RegistryNodePack['status']
latest_version: SafeNestedProperty<
RegistryNodePack,
'latest_version',
'version'
>
latest_version_status: SafeNestedProperty<
RegistryNodePack,
'latest_version',
'status'
>
comfy_node_extract_status: SafeNestedProperty<
RegistryNodePack,
'latest_version',
'comfy_node_extract_status'
>
icon_url: RegistryNodePack['icon']
category: RegistryNodePack['category']
author: RegistryNodePack['author']
tags: RegistryNodePack['tags']
github_stars: RegistryNodePack['github_stars']
supported_os: RegistryNodePack['supported_os']
supported_comfyui_version: RegistryNodePack['supported_comfyui_version']
supported_comfyui_frontend_version: RegistryNodePack['supported_comfyui_frontend_version']
supported_accelerators: RegistryNodePack['supported_accelerators']
banner_url: RegistryNodePack['banner_url']
}
/**
* An attribute that can be used to search the Algolia index by.
*/
export type SearchAttribute = keyof AlgoliaNodePack
/**
* Suggestion for a search query (autocomplete).
*/
export interface NodesIndexSuggestion {
nb_words: number
nodes_index: {
exact_nb_hits: number
facets: {
exact_matches: Record<string, number>
analytics: Record<string, any>
}
}
objectID: RegistryNodePack['id']
popularity: number
query: string
}
/**
* Parameters for searching the Algolia index.
*/
export type SearchNodePacksParams = BaseSearchParamsWithoutQuery & {
pageSize: number
pageNumber: number
restrictSearchableAttributes?: SearchAttribute[]
}

View File

@@ -1,10 +1,18 @@
import type { InjectionKey, Ref } from 'vue'
import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import type { AlgoliaNodePack } from '@/types/algoliaTypes'
import type { components } from '@/types/comfyRegistryTypes'
import type { SearchMode } from '@/types/searchServiceTypes'
type RegistryPack = components['schemas']['Node']
type WorkflowNodeProperties = ComfyWorkflowJSON['nodes'][0]['properties']
export type RegistryPack = components['schemas']['Node']
export type MergedNodePack = RegistryPack & AlgoliaNodePack
export const isMergedNodePack = (
nodePack: RegistryPack | AlgoliaNodePack
): nodePack is MergedNodePack => 'comfy_nodes' in nodePack
export type PackField = keyof RegistryPack | null
export const IsInstallingKey: InjectionKey<Ref<boolean>> =
@@ -32,7 +40,7 @@ export enum SortableAlgoliaField {
}
export interface TabItem {
id: string
id: ManagerTab
label: string
icon: string
}
@@ -227,3 +235,10 @@ export interface InstallPackParams extends ManagerPackInfo {
export interface UpdateAllPacksParams {
mode?: ManagerDatabaseSource
}
export interface ManagerState {
selectedTabId: ManagerTab
searchQuery: string
searchMode: SearchMode
sortField: string
}

View File

@@ -896,6 +896,23 @@ export interface paths {
patch?: never
trace?: never
}
'/nodes/update-github-stars': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
/** Update GitHub stars for nodes */
post: operations['updateGithubStars']
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/nodes': {
parameters: {
query?: never
@@ -1078,6 +1095,30 @@ export interface paths {
patch?: never
trace?: never
}
'/releases': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
/**
* Get release notes
* @description Fetch release notes from Strapi with caching
*/
get: operations['getReleaseNotes']
put?: never
/**
* Process Github release webhook
* @description Webhook endpoint to process Github release events and generate release notes
*/
post: operations['processReleaseWebhook']
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/security-scan': {
parameters: {
query?: never
@@ -3207,6 +3248,13 @@ export interface components {
preempted_comfy_node_names?: string[]
/** @description URL to the node's banner. */
banner_url?: string
/** @description Number of stars on the GitHub repository. */
github_stars?: number
/**
* Format: date-time
* @description The date and time when the node was created
*/
created_at?: string
}
NodeVersion: {
id?: string
@@ -3345,6 +3393,8 @@ export interface components {
stripe_id?: string
/** @description The Metronome customer ID */
metronome_id?: string
/** @description Whether the user has funds */
has_fund?: boolean
}
AuditLog: {
/** @description the type of the event */
@@ -8692,6 +8742,292 @@ export interface components {
MoonvalleyUploadResponse: {
access_url?: string
}
/** @description GitHub release webhook payload based on official webhook documentation */
GithubReleaseWebhook: {
/**
* @description The action performed on the release
* @enum {string}
*/
action:
| 'published'
| 'unpublished'
| 'created'
| 'edited'
| 'deleted'
| 'prereleased'
| 'released'
/** @description The release object */
release: {
/** @description The ID of the release */
id: number
/** @description The node ID of the release */
node_id: string
/** @description The API URL of the release */
url: string
/** @description The HTML URL of the release */
html_url: string
/** @description The URL to the release assets */
assets_url?: string
/** @description The URL to upload release assets */
upload_url?: string
/** @description The tag name of the release */
tag_name: string
/** @description The branch or commit the release was created from */
target_commitish: string
/** @description The name of the release */
name?: string | null
/** @description The release notes/body */
body?: string | null
/** @description Whether the release is a draft */
draft: boolean
/** @description Whether the release is a prerelease */
prerelease: boolean
/**
* Format: date-time
* @description When the release was created
*/
created_at: string
/**
* Format: date-time
* @description When the release was published
*/
published_at?: string | null
author: components['schemas']['GithubUser']
/** @description URL to the tarball */
tarball_url: string
/** @description URL to the zipball */
zipball_url: string
/** @description Array of release assets */
assets: components['schemas']['GithubReleaseAsset'][]
}
repository: components['schemas']['GithubRepository']
sender: components['schemas']['GithubUser']
organization?: components['schemas']['GithubOrganization']
installation?: components['schemas']['GithubInstallation']
enterprise?: components['schemas']['GithubEnterprise']
}
/** @description A GitHub user */
GithubUser: {
/** @description The user's login name */
login: string
/** @description The user's ID */
id: number
/** @description The user's node ID */
node_id: string
/** @description URL to the user's avatar */
avatar_url: string
/** @description The user's gravatar ID */
gravatar_id?: string | null
/** @description The API URL of the user */
url: string
/** @description The HTML URL of the user */
html_url: string
/**
* @description The type of user
* @enum {string}
*/
type: 'Bot' | 'User' | 'Organization'
/** @description Whether the user is a site admin */
site_admin: boolean
}
/** @description A GitHub repository */
GithubRepository: {
/** @description The repository ID */
id: number
/** @description The repository node ID */
node_id: string
/** @description The name of the repository */
name: string
/** @description The full name of the repository (owner/repo) */
full_name: string
/** @description Whether the repository is private */
private: boolean
owner: components['schemas']['GithubUser']
/** @description The HTML URL of the repository */
html_url: string
/** @description The repository description */
description?: string | null
/** @description Whether the repository is a fork */
fork: boolean
/** @description The API URL of the repository */
url: string
/** @description The clone URL of the repository */
clone_url: string
/** @description The git URL of the repository */
git_url: string
/** @description The SSH URL of the repository */
ssh_url: string
/** @description The default branch of the repository */
default_branch: string
/**
* Format: date-time
* @description When the repository was created
*/
created_at: string
/**
* Format: date-time
* @description When the repository was last updated
*/
updated_at: string
/**
* Format: date-time
* @description When the repository was last pushed to
*/
pushed_at: string
}
/** @description A GitHub release asset */
GithubReleaseAsset: {
/** @description The asset ID */
id: number
/** @description The asset node ID */
node_id: string
/** @description The name of the asset */
name: string
/** @description The label of the asset */
label?: string | null
/** @description The content type of the asset */
content_type: string
/**
* @description The state of the asset
* @enum {string}
*/
state: 'uploaded' | 'open'
/** @description The size of the asset in bytes */
size: number
/** @description The number of downloads */
download_count: number
/**
* Format: date-time
* @description When the asset was created
*/
created_at: string
/**
* Format: date-time
* @description When the asset was last updated
*/
updated_at: string
/** @description The browser download URL */
browser_download_url: string
uploader: components['schemas']['GithubUser']
}
/** @description A GitHub organization */
GithubOrganization: {
/** @description The organization's login name */
login: string
/** @description The organization ID */
id: number
/** @description The organization node ID */
node_id: string
/** @description The API URL of the organization */
url: string
/** @description The API URL of the organization's repositories */
repos_url: string
/** @description The API URL of the organization's events */
events_url: string
/** @description The API URL of the organization's hooks */
hooks_url: string
/** @description The API URL of the organization's issues */
issues_url: string
/** @description The API URL of the organization's members */
members_url: string
/** @description The API URL of the organization's public members */
public_members_url: string
/** @description URL to the organization's avatar */
avatar_url: string
/** @description The organization description */
description?: string | null
}
/** @description A GitHub App installation */
GithubInstallation: {
/** @description The installation ID */
id: number
account: components['schemas']['GithubUser']
/**
* @description Repository selection for the installation
* @enum {string}
*/
repository_selection: 'selected' | 'all'
/** @description The API URL for access tokens */
access_tokens_url: string
/** @description The API URL for repositories */
repositories_url: string
/** @description The HTML URL of the installation */
html_url: string
/** @description The GitHub App ID */
app_id: number
/** @description The target ID */
target_id: number
/** @description The target type */
target_type: string
/** @description The installation permissions */
permissions: Record<string, never>
/** @description The events the installation subscribes to */
events: string[]
/**
* Format: date-time
* @description When the installation was created
*/
created_at: string
/**
* Format: date-time
* @description When the installation was last updated
*/
updated_at: string
/** @description The single file name if applicable */
single_file_name?: string | null
}
/** @description A GitHub enterprise */
GithubEnterprise: {
/** @description The enterprise ID */
id: number
/** @description The enterprise slug */
slug: string
/** @description The enterprise name */
name: string
/** @description The enterprise node ID */
node_id: string
/** @description URL to the enterprise avatar */
avatar_url: string
/** @description The enterprise description */
description?: string | null
/** @description The enterprise website URL */
website_url?: string | null
/** @description The HTML URL of the enterprise */
html_url: string
/**
* Format: date-time
* @description When the enterprise was created
*/
created_at: string
/**
* Format: date-time
* @description When the enterprise was last updated
*/
updated_at: string
}
ReleaseNote: {
/** @description Unique identifier for the release note */
id: number
/**
* @description The project this release note belongs to
* @enum {string}
*/
project: 'comfyui' | 'comfyui_frontend' | 'desktop'
/** @description The version of the release */
version: string
/**
* @description The attention level for this release
* @enum {string}
*/
attention: 'low' | 'medium' | 'high'
/** @description The content of the release note in markdown format */
content: string
/**
* Format: date-time
* @description When the release note was published
*/
published_at: string
}
}
responses: never
parameters: {
@@ -10795,8 +11131,6 @@ export interface operations {
query?: {
/** @description Maximum number of nodes to send to algolia at a time */
max_batch?: number
/** @description Minimum interval from the last time the nodes were indexed to algolia */
min_age?: string
}
header?: never
path?: never
@@ -10831,6 +11165,52 @@ export interface operations {
}
}
}
updateGithubStars: {
parameters: {
query?: {
/** @description Maximum number of nodes to update in one batch */
max_batch?: number
}
header?: never
path?: never
cookie?: never
}
requestBody?: never
responses: {
/** @description Update GithubStars request triggered successfully */
200: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Bad request. */
400: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
}
}
listAllNodes: {
parameters: {
query?: {
@@ -10852,6 +11232,8 @@ export interface operations {
sort?: string[]
/** @description node_id to use as filter */
node_id?: string[]
/** @description Comfy UI version */
comfyui_version?: string
/** @description The platform requesting the nodes */
form_factor?: string
}
@@ -11418,6 +11800,115 @@ export interface operations {
}
}
}
getReleaseNotes: {
parameters: {
query: {
/** @description The project to get release notes for */
project: 'comfyui' | 'comfyui_frontend' | 'desktop'
/** @description The current version to filter release notes */
current_version?: string
/** @description The locale for the release notes */
locale?: 'en' | 'es' | 'fr' | 'ja' | 'ko' | 'ru' | 'zh'
/** @description The platform requesting the release notes */
form_factor?: string
}
header?: never
path?: never
cookie?: never
}
requestBody?: never
responses: {
/** @description Release notes retrieved successfully */
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ReleaseNote'][]
}
}
/** @description Bad request */
400: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
}
}
processReleaseWebhook: {
parameters: {
query?: never
header: {
/** @description The name of the event that triggered the delivery */
'X-GitHub-Event': 'release'
/** @description A globally unique identifier (GUID) to identify the event */
'X-GitHub-Delivery': string
/** @description The unique identifier of the webhook */
'X-GitHub-Hook-ID': string
/** @description HMAC hex digest of the request body using SHA-256 hash function */
'X-Hub-Signature-256'?: string
/** @description The type of resource where the webhook was created */
'X-GitHub-Hook-Installation-Target-Type'?: string
/** @description The unique identifier of the resource where the webhook was created */
'X-GitHub-Hook-Installation-Target-ID'?: string
}
path?: never
cookie?: never
}
requestBody: {
content: {
'application/json': components['schemas']['GithubReleaseWebhook']
}
}
responses: {
/** @description Webhook processed successfully */
200: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Bad request */
400: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Validation failed or endpoint has been spammed */
422: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
}
}
securityScan: {
parameters: {
query?: {

View File

@@ -0,0 +1,38 @@
/**
* Frontend augmentations for node definitions.
*
* This module defines type extensions that augment the backend node definition
* types with frontend-specific properties. These augmentations are applied at
* runtime and are not part of the backend API contract.
*/
import type { ComboInputOptions, InputSpec } from '@/schemas/nodeDefSchema'
/**
* Frontend augmentation for image upload combo inputs.
* This extends ComboInputOptions with properties injected by the uploadImage extension.
*/
export interface ImageUploadComboOptions extends ComboInputOptions {
/**
* Reference to the associated filename combo widget.
* Injected by uploadImage.ts to link upload buttons with their combo widgets.
*
* @remarks This property exists only in the frontend runtime.
*/
imageInputName: string
}
/**
* Type guard to check if an InputSpec has image upload augmentations.
* Narrows from base InputSpec to augmented type.
*/
export function isImageUploadInput(
inputData: InputSpec
): inputData is [string, ImageUploadComboOptions] {
const options = inputData[1]
return (
options !== undefined &&
typeof options === 'object' &&
'imageInputName' in options &&
typeof options.imageInputName === 'string'
)
}

View File

@@ -0,0 +1,49 @@
import type { SearchNodePacksParams } from '@/types/algoliaTypes'
import type { components } from '@/types/comfyRegistryTypes'
type RegistryNodePack = components['schemas']['Node']
/**
* Search mode for filtering results
*/
export type SearchMode = 'nodes' | 'packs'
export type QuerySuggestion = {
query: string
popularity: number
}
export interface SearchPacksResult {
nodePacks: RegistryNodePack[]
querySuggestions: QuerySuggestion[]
}
export interface SortableField<T = string> {
id: T
label: string
direction: 'asc' | 'desc'
}
export interface NodePackSearchProvider {
/**
* Search for node packs
*/
searchPacks(
query: string,
params: SearchNodePacksParams
): Promise<SearchPacksResult>
/**
* Clear the search cache
*/
clearSearchCache(): void
/**
* Get the sort value for a pack based on the sort field
*/
getSortValue(pack: RegistryNodePack, sortField: string): string | number
/**
* Get the list of sortable fields supported by this provider
*/
getSortableFields(): SortableField[]
}

View File

@@ -43,8 +43,6 @@ export interface SettingParams extends FormItem {
category?: string[]
experimental?: boolean
deprecated?: boolean
// Deprecated values are mapped to new values.
migrateDeprecatedValue?: (value: any) => any
// Version of the setting when it was added
versionAdded?: string
// Version of the setting when it was last modified

View File

@@ -4,12 +4,12 @@ import type { InjectionKey, ModelRef } from 'vue'
export interface TreeNode extends PrimeVueTreeNode {
label: string
children?: TreeNode[]
children?: this[]
}
export interface TreeExplorerNode<T = any> extends TreeNode {
data?: T
children?: TreeExplorerNode<T>[]
children?: this[]
icon?: string
/**
* Function to override what icon to use for the node.
@@ -62,7 +62,7 @@ export interface TreeExplorerNode<T = any> extends TreeNode {
}
export interface RenderedTreeExplorerNode<T = any> extends TreeExplorerNode<T> {
children?: RenderedTreeExplorerNode<T>[]
children?: this[]
icon: string
type: 'folder' | 'node'
/** Total number of leaves in the subtree */

View File

@@ -0,0 +1,88 @@
import type { Keybinding } from '@/schemas/keyBindingSchema'
import { useSettingStore } from '@/stores/settingStore'
export interface SettingMigration {
condition: () => boolean
migrate: () => Promise<void>
}
/**
* Setting value migrations that transform deprecated values to new formats.
* These run after settings are loaded from the server but before they are
* registered, ensuring settings have valid values when the app initializes.
*/
export const SETTING_MIGRATIONS: SettingMigration[] = [
// Migrate Comfy.UseNewMenu "Floating" value to "Top"
{
condition: () => {
const settingStore = useSettingStore()
return (
settingStore.exists('Comfy.UseNewMenu') &&
(settingStore.get('Comfy.UseNewMenu') as string) === 'Floating'
)
},
migrate: async () => {
const settingStore = useSettingStore()
await settingStore.set('Comfy.UseNewMenu', 'Top')
}
},
// Migrate Comfy.Keybinding.UnsetBindings targetSelector to targetElementId
{
condition: () => {
const settingStore = useSettingStore()
if (!settingStore.exists('Comfy.Keybinding.UnsetBindings')) return false
const keybindings = settingStore.get(
'Comfy.Keybinding.UnsetBindings'
) as Keybinding[]
return keybindings.some((kb: any) => 'targetSelector' in kb)
},
migrate: async () => {
const settingStore = useSettingStore()
const keybindings = settingStore.get(
'Comfy.Keybinding.UnsetBindings'
) as Keybinding[]
const migrated = keybindings.map((keybinding) => {
// Create a new object to avoid mutating the original
const newKeybinding = { ...keybinding }
if (
'targetSelector' in newKeybinding &&
newKeybinding['targetSelector'] === '#graph-canvas'
) {
newKeybinding['targetElementId'] = 'graph-canvas'
delete newKeybinding['targetSelector']
}
return newKeybinding
})
await settingStore.set('Comfy.Keybinding.UnsetBindings', migrated)
}
},
// Migrate Comfy.ColorPalette custom_ prefix
{
condition: () => {
const settingStore = useSettingStore()
return (
settingStore.exists('Comfy.ColorPalette') &&
(settingStore.get('Comfy.ColorPalette') as string).startsWith('custom_')
)
},
migrate: async () => {
const settingStore = useSettingStore()
const value = settingStore.get('Comfy.ColorPalette') as string
await settingStore.set('Comfy.ColorPalette', value.replace('custom_', ''))
}
}
]
/**
* Runs all setting migrations that meet their conditions.
* This is called after loadSettingValues() to ensure all deprecated
* setting values are migrated to their current format before the
* application starts using them.
*/
export async function runSettingMigrations(): Promise<void> {
for (const migration of SETTING_MIGRATIONS) {
if (migration.condition()) {
await migration.migrate()
}
}
}

View File

@@ -61,8 +61,8 @@ const mergeNumericInputSpec = <T extends IntInputSpec | FloatInputSpec>(
}
return mergeCommonInputSpec(
[type, { ...options1, ...mergedOptions }] as unknown as T,
[type, { ...options2, ...mergedOptions }] as unknown as T
[type, { ...options1, ...mergedOptions }] as T,
[type, { ...options2, ...mergedOptions }] as T
)
}
@@ -84,8 +84,8 @@ const mergeComboInputSpec = <T extends ComboInputSpec | ComboInputSpecV2>(
}
return mergeCommonInputSpec(
['COMBO', { ...options1, options: intersection }] as unknown as T,
['COMBO', { ...options2, options: intersection }] as unknown as T
['COMBO', { ...options1, options: intersection }] as T,
['COMBO', { ...options2, options: intersection }] as T
)
}
@@ -107,9 +107,7 @@ const mergeCommonInputSpec = <T extends InputSpec>(
return value1 === value2 || (_.isNil(value1) && _.isNil(value2))
})
return mergeIsValid
? ([type, { ...options1, ...options2 }] as unknown as T)
: null
return mergeIsValid ? ([type, { ...options1, ...options2 }] as T) : null
}
/**

View File

@@ -116,7 +116,7 @@ export const findNodeByKey = <T extends TreeNode>(
return null
}
for (const child of root.children) {
const result = findNodeByKey(child as T, key)
const result = findNodeByKey(child, key)
if (result) {
return result
}
@@ -130,11 +130,11 @@ export const findNodeByKey = <T extends TreeNode>(
* @returns A deep clone of the node.
*/
export function cloneTree<T extends TreeNode>(node: T): T {
const clone: T = { ...node } as T
const clone = { ...node }
// Clone children recursively
if (node.children && node.children.length > 0) {
clone.children = node.children.map((child) => cloneTree(child as T))
clone.children = node.children.map((child) => cloneTree(child))
}
return clone

View File

@@ -0,0 +1,205 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Message from 'primevue/message'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
// Mock the stores
vi.mock('@/stores/systemStatsStore', () => ({
useSystemStatsStore: vi.fn()
}))
const createMockNode = (type: string, version?: string): LGraphNode =>
// @ts-expect-error - Creating a partial mock of LGraphNode for testing purposes.
// We only need specific properties for our tests, not the full LGraphNode interface.
({
type,
properties: { cnr_id: 'comfy-core', ver: version },
id: 1,
title: type,
pos: [0, 0],
size: [100, 100],
flags: {},
graph: null,
mode: 0,
inputs: [],
outputs: []
})
describe('MissingCoreNodesMessage', () => {
const mockSystemStatsStore = {
systemStats: null as { system?: { comfyui_version?: string } } | null,
fetchSystemStats: vi.fn()
}
beforeEach(() => {
vi.clearAllMocks()
// Reset the mock store state
mockSystemStatsStore.systemStats = null
mockSystemStatsStore.fetchSystemStats = vi.fn()
// @ts-expect-error - Mocking the return value of useSystemStatsStore for testing.
// The actual store has more properties, but we only need these for our tests.
useSystemStatsStore.mockReturnValue(mockSystemStatsStore)
})
const mountComponent = (props = {}) => {
return mount(MissingCoreNodesMessage, {
global: {
plugins: [PrimeVue],
components: { Message },
mocks: {
$t: (key: string, params?: { version?: string }) => {
const translations: Record<string, string> = {
'loadWorkflowWarning.outdatedVersion': `Some nodes require a newer version of ComfyUI (current: ${params?.version}). Please update to use all nodes.`,
'loadWorkflowWarning.outdatedVersionGeneric':
'Some nodes require a newer version of ComfyUI. Please update to use all nodes.',
'loadWorkflowWarning.coreNodesFromVersion': `Requires ComfyUI ${params?.version}:`
}
return translations[key] || key
}
}
},
props: {
missingCoreNodes: {},
...props
}
})
}
it('does not render when there are no missing core nodes', () => {
const wrapper = mountComponent()
expect(wrapper.findComponent(Message).exists()).toBe(false)
})
it('renders message when there are missing core nodes', async () => {
const missingCoreNodes = {
'1.2.0': [createMockNode('TestNode', '1.2.0')]
}
const wrapper = mountComponent({ missingCoreNodes })
await nextTick()
expect(wrapper.findComponent(Message).exists()).toBe(true)
})
it('fetches and displays current ComfyUI version', async () => {
// Start with no systemStats to trigger fetch
mockSystemStatsStore.fetchSystemStats.mockImplementation(() => {
// Simulate the fetch setting the systemStats
mockSystemStatsStore.systemStats = {
system: { comfyui_version: '1.0.0' }
}
return Promise.resolve()
})
const missingCoreNodes = {
'1.2.0': [createMockNode('TestNode', '1.2.0')]
}
const wrapper = mountComponent({ missingCoreNodes })
// Wait for all async operations
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 0))
await nextTick()
expect(mockSystemStatsStore.fetchSystemStats).toHaveBeenCalled()
expect(wrapper.text()).toContain(
'Some nodes require a newer version of ComfyUI (current: 1.0.0)'
)
})
it('displays generic message when version is unavailable', async () => {
// Mock fetchSystemStats to resolve without setting systemStats
mockSystemStatsStore.fetchSystemStats.mockResolvedValue(undefined)
const missingCoreNodes = {
'1.2.0': [createMockNode('TestNode', '1.2.0')]
}
const wrapper = mountComponent({ missingCoreNodes })
// Wait for the async operations to complete
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 0))
await nextTick()
expect(wrapper.text()).toContain(
'Some nodes require a newer version of ComfyUI. Please update to use all nodes.'
)
})
it('groups nodes by version and displays them', async () => {
const missingCoreNodes = {
'1.2.0': [
createMockNode('NodeA', '1.2.0'),
createMockNode('NodeB', '1.2.0')
],
'1.3.0': [createMockNode('NodeC', '1.3.0')]
}
const wrapper = mountComponent({ missingCoreNodes })
await nextTick()
const text = wrapper.text()
expect(text).toContain('Requires ComfyUI 1.3.0:')
expect(text).toContain('NodeC')
expect(text).toContain('Requires ComfyUI 1.2.0:')
expect(text).toContain('NodeA, NodeB')
})
it('sorts versions in descending order', async () => {
const missingCoreNodes = {
'1.1.0': [createMockNode('Node1', '1.1.0')],
'1.3.0': [createMockNode('Node3', '1.3.0')],
'1.2.0': [createMockNode('Node2', '1.2.0')]
}
const wrapper = mountComponent({ missingCoreNodes })
await nextTick()
const text = wrapper.text()
const version13Index = text.indexOf('1.3.0')
const version12Index = text.indexOf('1.2.0')
const version11Index = text.indexOf('1.1.0')
expect(version13Index).toBeLessThan(version12Index)
expect(version12Index).toBeLessThan(version11Index)
})
it('removes duplicate node names within the same version', async () => {
const missingCoreNodes = {
'1.2.0': [
createMockNode('DuplicateNode', '1.2.0'),
createMockNode('DuplicateNode', '1.2.0'),
createMockNode('UniqueNode', '1.2.0')
]
}
const wrapper = mountComponent({ missingCoreNodes })
await nextTick()
const text = wrapper.text()
// Should only appear once in the sorted list
expect(text).toContain('DuplicateNode, UniqueNode')
// Count occurrences of 'DuplicateNode' - should be only 1
const matches = text.match(/DuplicateNode/g) || []
expect(matches.length).toBe(1)
})
it('handles nodes with missing version info', async () => {
const missingCoreNodes = {
'': [createMockNode('NoVersionNode')]
}
const wrapper = mountComponent({ missingCoreNodes })
await nextTick()
expect(wrapper.text()).toContain('Requires ComfyUI unknown:')
expect(wrapper.text()).toContain('NoVersionNode')
})
})

View File

@@ -0,0 +1,385 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
import { app } from '@/scripts/app'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
// Mock Vue's onMounted to execute immediately for testing
vi.mock('vue', async () => {
const actual = await vi.importActual<typeof import('vue')>('vue')
return {
...actual,
onMounted: (cb: () => void) => cb()
}
})
// Mock the dependencies
vi.mock('@/composables/nodePack/useWorkflowPacks', () => ({
useWorkflowPacks: vi.fn()
}))
vi.mock('@/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn()
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: vi.fn()
}))
vi.mock('@/scripts/app', () => ({
app: {
graph: {
nodes: []
}
}
}))
const mockUseWorkflowPacks = vi.mocked(useWorkflowPacks)
const mockUseComfyManagerStore = vi.mocked(useComfyManagerStore)
const mockUseNodeDefStore = vi.mocked(useNodeDefStore)
describe('useMissingNodes', () => {
const mockWorkflowPacks = [
{
id: 'pack-1',
name: 'Test Pack 1',
latest_version: { version: '1.0.0' }
},
{
id: 'pack-2',
name: 'Test Pack 2',
latest_version: { version: '2.0.0' }
},
{
id: 'pack-3',
name: 'Installed Pack',
latest_version: { version: '1.5.0' }
}
]
const mockStartFetchWorkflowPacks = vi.fn()
const mockIsPackInstalled = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
// 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
})
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(false),
filterWorkflowPack: vi.fn()
})
// Reset node def store mock
// @ts-expect-error - Mocking partial NodeDefStore for testing.
// We only need nodeDefsByName for these tests.
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {}
})
// Reset app.graph.nodes
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
app.graph.nodes = []
})
describe('core filtering logic', () => {
it('filters out installed packs correctly', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref(mockWorkflowPacks),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
})
const { missingNodePacks } = useMissingNodes()
// Should only include packs that are not installed (pack-1, pack-2)
expect(missingNodePacks.value).toHaveLength(2)
expect(missingNodePacks.value[0].id).toBe('pack-1')
expect(missingNodePacks.value[1].id).toBe('pack-2')
expect(
missingNodePacks.value.find((pack) => pack.id === 'pack-3')
).toBeUndefined()
})
it('returns empty array when all packs are installed', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref(mockWorkflowPacks),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
})
// Mock all packs as installed
mockIsPackInstalled.mockReturnValue(true)
const { missingNodePacks } = useMissingNodes()
expect(missingNodePacks.value).toEqual([])
})
it('returns all packs when none are installed', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref(mockWorkflowPacks),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
})
// Mock no packs as installed
mockIsPackInstalled.mockReturnValue(false)
const { missingNodePacks } = useMissingNodes()
expect(missingNodePacks.value).toHaveLength(3)
expect(missingNodePacks.value).toEqual(mockWorkflowPacks)
})
it('returns empty array when no workflow packs exist', () => {
const { missingNodePacks } = useMissingNodes()
expect(missingNodePacks.value).toEqual([])
})
})
describe('automatic data fetching', () => {
it('fetches workflow packs automatically when none exist', async () => {
useMissingNodes()
expect(mockStartFetchWorkflowPacks).toHaveBeenCalledOnce()
})
it('does not fetch when packs already exist', async () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref(mockWorkflowPacks),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
})
useMissingNodes()
expect(mockStartFetchWorkflowPacks).not.toHaveBeenCalled()
})
it('does not fetch when already loading', async () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
isLoading: ref(true),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(false),
filterWorkflowPack: vi.fn()
})
useMissingNodes()
expect(mockStartFetchWorkflowPacks).not.toHaveBeenCalled()
})
})
describe('state management', () => {
it('exposes loading state from useWorkflowPacks', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
isLoading: ref(true),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(false),
filterWorkflowPack: vi.fn()
})
const { isLoading } = useMissingNodes()
expect(isLoading.value).toBe(true)
})
it('exposes error state from useWorkflowPacks', () => {
const testError = 'Failed to fetch workflow packs'
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
isLoading: ref(false),
error: ref(testError),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(false),
filterWorkflowPack: vi.fn()
})
const { error } = useMissingNodes()
expect(error.value).toBe(testError)
})
})
describe('reactivity', () => {
it('updates when workflow packs change', async () => {
const workflowPacksRef = ref([])
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: workflowPacksRef,
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
})
const { missingNodePacks } = useMissingNodes()
// Initially empty
expect(missingNodePacks.value).toEqual([])
// Update workflow packs
// @ts-expect-error - mockWorkflowPacks is a simplified version without full WorkflowPack interface.
workflowPacksRef.value = mockWorkflowPacks
await nextTick()
// Should update missing packs (2 missing since pack-3 is installed)
expect(missingNodePacks.value).toHaveLength(2)
})
})
describe('missing core nodes detection', () => {
const createMockNode = (
type: string,
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.
({
type,
properties: { cnr_id: packId, ver: version },
id: 1,
title: type,
pos: [0, 0],
size: [100, 100],
flags: {},
graph: null,
mode: 0,
inputs: [],
outputs: []
})
it('identifies missing core nodes not in nodeDefStore', () => {
const coreNode1 = createMockNode('CoreNode1', 'comfy-core', '1.2.0')
const coreNode2 = createMockNode('CoreNode2', 'comfy-core', '1.2.0')
const registeredNode = createMockNode(
'RegisteredNode',
'comfy-core',
'1.0.0'
)
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
app.graph.nodes = [coreNode1, coreNode2, registeredNode]
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {
// @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing.
// Only including required properties for our test assertions.
RegisteredNode: { name: 'RegisteredNode' }
}
})
const { missingCoreNodes } = useMissingNodes()
expect(Object.keys(missingCoreNodes.value)).toHaveLength(1)
expect(missingCoreNodes.value['1.2.0']).toHaveLength(2)
expect(missingCoreNodes.value['1.2.0'][0].type).toBe('CoreNode1')
expect(missingCoreNodes.value['1.2.0'][1].type).toBe('CoreNode2')
})
it('groups missing core nodes by version', () => {
const node120 = createMockNode('Node120', 'comfy-core', '1.2.0')
const node130 = createMockNode('Node130', 'comfy-core', '1.3.0')
const nodeNoVer = createMockNode('NodeNoVer', 'comfy-core')
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
app.graph.nodes = [node120, node130, nodeNoVer]
// @ts-expect-error - Mocking partial NodeDefStore for testing.
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {}
})
const { missingCoreNodes } = useMissingNodes()
expect(Object.keys(missingCoreNodes.value)).toHaveLength(3)
expect(missingCoreNodes.value['1.2.0']).toHaveLength(1)
expect(missingCoreNodes.value['1.3.0']).toHaveLength(1)
expect(missingCoreNodes.value['']).toHaveLength(1)
})
it('ignores non-core nodes', () => {
const coreNode = createMockNode('CoreNode', 'comfy-core', '1.2.0')
const customNode = createMockNode('CustomNode', 'custom-pack', '1.0.0')
const noPackNode = createMockNode('NoPackNode')
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
app.graph.nodes = [coreNode, customNode, noPackNode]
// @ts-expect-error - Mocking partial NodeDefStore for testing.
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {}
})
const { missingCoreNodes } = useMissingNodes()
expect(Object.keys(missingCoreNodes.value)).toHaveLength(1)
expect(missingCoreNodes.value['1.2.0']).toHaveLength(1)
expect(missingCoreNodes.value['1.2.0'][0].type).toBe('CoreNode')
})
it('returns empty object when no core nodes are missing', () => {
const registeredNode1 = createMockNode(
'RegisteredNode1',
'comfy-core',
'1.0.0'
)
const registeredNode2 = createMockNode(
'RegisteredNode2',
'comfy-core',
'1.1.0'
)
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
app.graph.nodes = [registeredNode1, registeredNode2]
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' }
}
})
const { missingCoreNodes } = useMissingNodes()
expect(Object.keys(missingCoreNodes.value)).toHaveLength(0)
})
})
})

View File

@@ -26,6 +26,22 @@ vi.mock('@/stores/settingStore', () => ({
})
}))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
vi.mock('@/composables/functional/useChainCallback', () => ({
useChainCallback: vi.fn((original, ...callbacks) => {
return function (this: any, ...args: any[]) {
original?.apply(this, args)
callbacks.forEach((cb: any) => cb.apply(this, args))
}
})
}))
const FIRST_BACKOFF = 1000 // backoff is 1s on first retry
const DEFAULT_VALUE = 'Loading...'
@@ -40,7 +56,9 @@ function createMockConfig(overrides = {}): RemoteWidgetConfig {
const createMockOptions = (inputOverrides = {}) => ({
remoteConfig: createMockConfig(inputOverrides),
defaultValue: DEFAULT_VALUE,
node: {} as any,
node: {
addWidget: vi.fn()
} as any,
widget: {} as any
})
@@ -499,4 +517,168 @@ describe('useRemoteWidget', () => {
expect(data2).toEqual(DEFAULT_VALUE)
})
})
describe('auto-refresh on task completion', () => {
it('should add auto-refresh toggle widget', () => {
const mockNode = {
addWidget: vi.fn(),
widgets: []
}
const mockWidget = {
refresh: vi.fn()
}
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode as any,
widget: mockWidget as any
})
// Should add auto-refresh toggle widget
expect(mockNode.addWidget).toHaveBeenCalledWith(
'toggle',
'Auto-refresh after generation',
false,
expect.any(Function),
{
serialize: false
}
)
})
it('should register event listener when enabled', async () => {
const { api } = await import('@/scripts/api')
const mockNode = {
addWidget: vi.fn(),
widgets: []
}
const mockWidget = {
refresh: vi.fn()
}
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode as any,
widget: mockWidget as any
})
// Event listener should be registered immediately
expect(api.addEventListener).toHaveBeenCalledWith(
'execution_success',
expect.any(Function)
)
})
it('should refresh widget when workflow completes successfully', async () => {
const { api } = await import('@/scripts/api')
let executionSuccessHandler: (() => void) | undefined
// Capture the event handler
vi.mocked(api.addEventListener).mockImplementation((event, handler) => {
if (event === 'execution_success') {
executionSuccessHandler = handler as () => void
}
})
const mockNode = {
addWidget: vi.fn(),
widgets: []
}
const mockWidget = {} as any
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode as any,
widget: mockWidget
})
// Spy on the refresh function that was added by useRemoteWidget
const refreshSpy = vi.spyOn(mockWidget, 'refresh')
// Get the toggle callback and enable auto-refresh
const toggleCallback = mockNode.addWidget.mock.calls.find(
(call) => call[0] === 'toggle'
)?.[3]
toggleCallback?.(true)
// Simulate workflow completion
executionSuccessHandler?.()
expect(refreshSpy).toHaveBeenCalled()
})
it('should not refresh when toggle is disabled', async () => {
const { api } = await import('@/scripts/api')
let executionSuccessHandler: (() => void) | undefined
// Capture the event handler
vi.mocked(api.addEventListener).mockImplementation((event, handler) => {
if (event === 'execution_success') {
executionSuccessHandler = handler as () => void
}
})
const mockNode = {
addWidget: vi.fn(),
widgets: []
}
const mockWidget = {} as any
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode as any,
widget: mockWidget
})
// Spy on the refresh function that was added by useRemoteWidget
const refreshSpy = vi.spyOn(mockWidget, 'refresh')
// Toggle is disabled by default
// Simulate workflow completion
executionSuccessHandler?.()
expect(refreshSpy).not.toHaveBeenCalled()
})
it('should cleanup event listener on node removal', async () => {
const { api } = await import('@/scripts/api')
let executionSuccessHandler: (() => void) | undefined
// Capture the event handler
vi.mocked(api.addEventListener).mockImplementation((event, handler) => {
if (event === 'execution_success') {
executionSuccessHandler = handler as () => void
}
})
const mockNode = {
addWidget: vi.fn(),
widgets: [],
onRemoved: undefined as any
}
const mockWidget = {
refresh: vi.fn()
}
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode as any,
widget: mockWidget as any
})
// Simulate node removal
mockNode.onRemoved?.()
expect(api.removeEventListener).toHaveBeenCalledWith(
'execution_success',
executionSuccessHandler
)
})
})
})

View File

@@ -0,0 +1,374 @@
import { liteClient as algoliasearch } from 'algoliasearch/dist/lite/builds/browser'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAlgoliaSearchProvider } from '@/services/providers/algoliaSearchProvider'
import { SortableAlgoliaField } from '@/types/comfyManagerTypes'
// Mock global Algolia constants
;(global as any).__ALGOLIA_APP_ID__ = 'test-app-id'
;(global as any).__ALGOLIA_API_KEY__ = 'test-api-key'
// Mock algoliasearch
vi.mock('algoliasearch/dist/lite/builds/browser', () => ({
liteClient: vi.fn()
}))
describe('useAlgoliaSearchProvider', () => {
let mockSearchClient: any
beforeEach(() => {
vi.clearAllMocks()
// Create mock search client
mockSearchClient = {
search: vi.fn()
}
vi.mocked(algoliasearch).mockReturnValue(mockSearchClient)
})
afterEach(() => {
// Clear the module-level cache between tests
const provider = useAlgoliaSearchProvider()
provider.clearSearchCache()
})
describe('searchPacks', () => {
it('should search for packs and convert results', async () => {
const mockAlgoliaResults = {
results: [
{
hits: [
{
objectID: 'algolia-1',
id: 'pack-1',
name: 'Test Pack',
description: 'A test pack',
publisher_id: 'publisher-1',
total_install: 500,
create_time: '2024-01-01T00:00:00Z',
update_time: '2024-01-15T00:00:00Z',
repository_url: 'https://github.com/test/pack',
license: 'MIT',
status: 'active',
latest_version: '1.0.0',
latest_version_status: 'published',
icon_url: 'https://example.com/icon.png',
comfy_nodes: ['LoadImage', 'SaveImage']
}
]
},
{ hits: [] } // Query suggestions
]
}
mockSearchClient.search.mockResolvedValue(mockAlgoliaResults)
const provider = useAlgoliaSearchProvider()
const result = await provider.searchPacks('test', {
pageSize: 10,
pageNumber: 0
})
expect(mockSearchClient.search).toHaveBeenCalledWith({
requests: [
{
query: 'test',
indexName: 'nodes_index',
attributesToRetrieve: expect.any(Array),
hitsPerPage: 10,
page: 0
},
{
query: 'test',
indexName: 'nodes_index_query_suggestions'
}
],
strategy: 'none'
})
expect(result.nodePacks).toHaveLength(1)
expect(result.nodePacks[0]).toEqual({
id: 'pack-1',
name: 'Test Pack',
description: 'A test pack',
repository: 'https://github.com/test/pack',
license: 'MIT',
downloads: 500,
status: 'active',
icon: 'https://example.com/icon.png',
latest_version: {
version: '1.0.0',
createdAt: '2024-01-15T00:00:00Z',
status: 'published',
comfy_node_extract_status: undefined
},
publisher: {
id: 'publisher-1',
name: 'publisher-1'
},
created_at: '2024-01-01T00:00:00Z',
comfy_nodes: ['LoadImage', 'SaveImage'],
category: undefined,
author: undefined,
tags: undefined,
github_stars: undefined,
supported_os: undefined,
supported_comfyui_version: undefined,
supported_comfyui_frontend_version: undefined,
supported_accelerators: undefined,
banner_url: undefined
})
})
it('should include query suggestions when query is long enough', async () => {
const mockAlgoliaResults = {
results: [
{ hits: [] }, // Main results
{
hits: [
{ query: 'test query', popularity: 10 },
{ query: 'test pack', popularity: 5 }
]
}
]
}
mockSearchClient.search.mockResolvedValue(mockAlgoliaResults)
const provider = useAlgoliaSearchProvider()
const result = await provider.searchPacks('test', {
pageSize: 10,
pageNumber: 0
})
// Should make 2 requests (main + suggestions)
expect(mockSearchClient.search).toHaveBeenCalledWith({
requests: [
expect.objectContaining({ indexName: 'nodes_index' }),
expect.objectContaining({
indexName: 'nodes_index_query_suggestions'
})
],
strategy: 'none'
})
expect(result.querySuggestions).toEqual([
{ query: 'test query', popularity: 10 },
{ query: 'test pack', popularity: 5 }
])
})
it('should not query suggestions for short queries', async () => {
mockSearchClient.search.mockResolvedValue({
results: [{ hits: [] }]
})
const provider = useAlgoliaSearchProvider()
await provider.searchPacks('a', {
pageSize: 10,
pageNumber: 0
})
// Should only make 1 request (no suggestions)
expect(mockSearchClient.search).toHaveBeenCalledWith({
requests: [expect.objectContaining({ indexName: 'nodes_index' })],
strategy: 'none'
})
})
it('should cache search results', async () => {
mockSearchClient.search.mockResolvedValue({
results: [{ hits: [] }, { hits: [] }]
})
const provider = useAlgoliaSearchProvider()
const params = { pageSize: 10, pageNumber: 0 }
// First call
await provider.searchPacks('test', params)
expect(mockSearchClient.search).toHaveBeenCalledTimes(1)
// Second call with same params should use cache
await provider.searchPacks('test', params)
expect(mockSearchClient.search).toHaveBeenCalledTimes(1)
// Different params should make new request
await provider.searchPacks('test', { ...params, pageNumber: 1 })
expect(mockSearchClient.search).toHaveBeenCalledTimes(2)
})
it('should handle missing objectID by using id field', async () => {
const mockAlgoliaResults = {
results: [
{
hits: [
{
id: 'pack-id-only',
name: 'Pack without objectID',
// ... other required fields
publisher_id: 'pub',
total_install: 0,
comfy_nodes: []
}
]
},
{ hits: [] }
]
}
mockSearchClient.search.mockResolvedValue(mockAlgoliaResults)
const provider = useAlgoliaSearchProvider()
const result = await provider.searchPacks('test', {
pageSize: 10,
pageNumber: 0
})
expect(result.nodePacks[0].id).toBe('pack-id-only')
})
})
describe('clearSearchCache', () => {
it('should clear the cache', async () => {
mockSearchClient.search.mockResolvedValue({
results: [{ hits: [] }, { hits: [] }]
})
const provider = useAlgoliaSearchProvider()
const params = { pageSize: 10, pageNumber: 0 }
// Populate cache
await provider.searchPacks('test', params)
expect(mockSearchClient.search).toHaveBeenCalledTimes(1)
// Clear cache
provider.clearSearchCache()
// Same search should hit API again
await provider.searchPacks('test', params)
expect(mockSearchClient.search).toHaveBeenCalledTimes(2)
})
})
describe('getSortValue', () => {
const testPack = {
id: '1',
name: 'Test Pack',
downloads: 100,
publisher: { id: 'pub1', name: 'Publisher One' },
latest_version: {
version: '1.0.0',
createdAt: '2024-01-15T10:00:00Z'
},
created_at: '2024-01-01T10:00:00Z'
}
it('should return correct values for each sort field', () => {
const provider = useAlgoliaSearchProvider()
expect(
provider.getSortValue(testPack, SortableAlgoliaField.Downloads)
).toBe(100)
expect(provider.getSortValue(testPack, SortableAlgoliaField.Name)).toBe(
'Test Pack'
)
expect(
provider.getSortValue(testPack, SortableAlgoliaField.Publisher)
).toBe('Publisher One')
const createdTimestamp = new Date('2024-01-01T10:00:00Z').getTime()
expect(
provider.getSortValue(testPack as any, SortableAlgoliaField.Created)
).toBe(createdTimestamp)
const updatedTimestamp = new Date('2024-01-15T10:00:00Z').getTime()
expect(
provider.getSortValue(testPack, SortableAlgoliaField.Updated)
).toBe(updatedTimestamp)
})
it('should handle missing values', () => {
const incompletePack = { id: '1', name: 'Incomplete' }
const provider = useAlgoliaSearchProvider()
expect(
provider.getSortValue(incompletePack, SortableAlgoliaField.Downloads)
).toBe(0)
expect(
provider.getSortValue(incompletePack, SortableAlgoliaField.Publisher)
).toBe('')
expect(
provider.getSortValue(
incompletePack as any,
SortableAlgoliaField.Created
)
).toBe(0)
expect(
provider.getSortValue(incompletePack, SortableAlgoliaField.Updated)
).toBe(0)
})
})
describe('getSortableFields', () => {
it('should return all Algolia sort fields', () => {
const provider = useAlgoliaSearchProvider()
const fields = provider.getSortableFields()
expect(fields).toEqual([
{
id: SortableAlgoliaField.Downloads,
label: 'Downloads',
direction: 'desc'
},
{
id: SortableAlgoliaField.Created,
label: 'Created',
direction: 'desc'
},
{
id: SortableAlgoliaField.Updated,
label: 'Updated',
direction: 'desc'
},
{
id: SortableAlgoliaField.Publisher,
label: 'Publisher',
direction: 'asc'
},
{ id: SortableAlgoliaField.Name, label: 'Name', direction: 'asc' }
])
})
})
describe('memoization', () => {
it('should memoize toRegistryPack conversions', async () => {
const mockHit = {
objectID: 'algolia-1',
id: 'pack-1',
name: 'Test Pack',
publisher_id: 'pub1',
total_install: 100,
comfy_nodes: []
}
mockSearchClient.search.mockResolvedValue({
results: [
{ hits: [mockHit, mockHit, mockHit] }, // Same object 3 times
{ hits: [] }
]
})
const provider = useAlgoliaSearchProvider()
const result = await provider.searchPacks('test', {
pageSize: 10,
pageNumber: 0
})
// All 3 results should be the same object reference due to memoization
expect(result.nodePacks[0]).toBe(result.nodePacks[1])
expect(result.nodePacks[1]).toBe(result.nodePacks[2])
})
})
})

View File

@@ -0,0 +1,445 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway'
import { useAlgoliaSearchProvider } from '@/services/providers/algoliaSearchProvider'
import { useComfyRegistrySearchProvider } from '@/services/providers/registrySearchProvider'
// Mock the provider modules to control their behavior
vi.mock('@/services/providers/algoliaSearchProvider')
vi.mock('@/services/providers/registrySearchProvider')
describe('useRegistrySearchGateway', () => {
let consoleWarnSpy: any
let consoleInfoSpy: any
beforeEach(() => {
vi.clearAllMocks()
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
})
afterEach(() => {
consoleWarnSpy.mockRestore()
consoleInfoSpy.mockRestore()
vi.useRealTimers()
})
describe('Provider initialization', () => {
it('should initialize with both providers', () => {
const mockAlgoliaProvider = {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
mockRegistryProvider
)
const gateway = useRegistrySearchGateway()
expect(useAlgoliaSearchProvider).toHaveBeenCalled()
expect(useComfyRegistrySearchProvider).toHaveBeenCalled()
expect(gateway).toBeDefined()
})
it('should handle Algolia initialization failure gracefully', () => {
vi.mocked(useAlgoliaSearchProvider).mockImplementation(() => {
throw new Error('Algolia init failed')
})
const mockRegistryProvider = {
searchPacks: vi
.fn()
.mockResolvedValue({ nodePacks: [], querySuggestions: [] }),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
mockRegistryProvider
)
const gateway = useRegistrySearchGateway()
// Gateway should still work with just the Registry provider
expect(gateway).toBeDefined()
expect(typeof gateway.searchPacks).toBe('function')
// Verify it can still search using the fallback provider
return expect(
gateway.searchPacks('test', { pageSize: 10, pageNumber: 0 })
).resolves.toBeDefined()
})
})
describe('Search functionality', () => {
it('should use Algolia provider by default and fallback on failure', async () => {
const algoliaResult = {
nodePacks: [{ id: 'algolia-1', name: 'Algolia Pack' }],
querySuggestions: []
}
const registryResult = {
nodePacks: [{ id: 'registry-1', name: 'Registry Pack' }],
querySuggestions: []
}
const mockAlgoliaProvider = {
searchPacks: vi
.fn()
.mockResolvedValueOnce(algoliaResult)
.mockRejectedValueOnce(new Error('Algolia failed')),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi.fn().mockResolvedValue(registryResult),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
mockRegistryProvider
)
const gateway = useRegistrySearchGateway()
// First call should use Algolia
const result1 = await gateway.searchPacks('test', {
pageSize: 10,
pageNumber: 0
})
expect(result1.nodePacks[0].name).toBe('Algolia Pack')
// Second call should fallback to Registry when Algolia fails
const result2 = await gateway.searchPacks('test', {
pageSize: 10,
pageNumber: 0
})
expect(result2.nodePacks[0].name).toBe('Registry Pack')
})
it('should throw error when all providers fail', async () => {
const mockAlgoliaProvider = {
searchPacks: vi.fn().mockRejectedValue(new Error('Algolia failed')),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi.fn().mockRejectedValue(new Error('Registry failed')),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
mockRegistryProvider
)
const gateway = useRegistrySearchGateway()
await expect(
gateway.searchPacks('test', { pageSize: 10, pageNumber: 0 })
).rejects.toThrow('All search providers failed')
})
})
describe('Circuit breaker functionality', () => {
it('should switch to fallback provider after failure and log warnings', async () => {
const registryResult = {
nodePacks: [{ id: 'registry-1', name: 'Registry Pack' }],
querySuggestions: []
}
// Create mock that fails
const mockAlgoliaProvider = {
searchPacks: vi.fn().mockRejectedValue(new Error('Algolia failed')),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi.fn().mockResolvedValue(registryResult),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
mockRegistryProvider
)
const gateway = useRegistrySearchGateway()
// First call should try Algolia, fail, and use Registry
const result = await gateway.searchPacks('test', {
pageSize: 10,
pageNumber: 0
})
expect(mockAlgoliaProvider.searchPacks).toHaveBeenCalledTimes(1)
expect(mockRegistryProvider.searchPacks).toHaveBeenCalledTimes(1)
expect(result.nodePacks[0].name).toBe('Registry Pack')
// Circuit breaker behavior is internal implementation detail
// We only test the observable behavior (fallback works)
})
it('should have circuit breaker timeout mechanism', () => {
// This test verifies that the constants exist for circuit breaker behavior
// The actual circuit breaker logic is tested in integration with real provider behavior
expect(typeof useRegistrySearchGateway).toBe('function')
// We can test that the gateway logs circuit breaker behavior
const mockAlgoliaProvider = {
searchPacks: vi.fn().mockRejectedValue(new Error('Persistent failure')),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi
.fn()
.mockResolvedValue({ nodePacks: [], querySuggestions: [] }),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
mockRegistryProvider
)
const gateway = useRegistrySearchGateway()
expect(gateway).toBeDefined()
})
})
describe('Cache management', () => {
it('should clear cache for all providers', () => {
const mockAlgoliaProvider = {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
mockRegistryProvider
)
const gateway = useRegistrySearchGateway()
gateway.clearSearchCache()
expect(mockAlgoliaProvider.clearSearchCache).toHaveBeenCalled()
expect(mockRegistryProvider.clearSearchCache).toHaveBeenCalled()
})
it('should handle cache clear failures gracefully', () => {
const mockAlgoliaProvider = {
searchPacks: vi.fn(),
clearSearchCache: vi.fn().mockImplementation(() => {
throw new Error('Cache clear failed')
}),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
mockRegistryProvider
)
const gateway = useRegistrySearchGateway()
// Should not throw when clearing cache even if one provider fails
expect(() => gateway.clearSearchCache()).not.toThrow()
// Should still attempt to clear cache for all providers
expect(mockAlgoliaProvider.clearSearchCache).toHaveBeenCalled()
expect(mockRegistryProvider.clearSearchCache).toHaveBeenCalled()
})
})
describe('Sort functionality', () => {
it('should use sort fields from active provider', () => {
const algoliaFields = [
{ id: 'downloads', label: 'Downloads', direction: 'desc' }
]
const mockAlgoliaProvider = {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue(algoliaFields)
}
const mockRegistryProvider = {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
mockRegistryProvider
)
const gateway = useRegistrySearchGateway()
const sortFields = gateway.getSortableFields()
expect(sortFields).toEqual(algoliaFields)
})
it('should switch sort fields when provider changes', async () => {
const algoliaFields = [
{ id: 'downloads', label: 'Downloads', direction: 'desc' }
]
const registryFields = [{ id: 'name', label: 'Name', direction: 'asc' }]
const mockAlgoliaProvider = {
searchPacks: vi.fn().mockRejectedValue(new Error('Algolia failed')),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue(algoliaFields)
}
const mockRegistryProvider = {
searchPacks: vi
.fn()
.mockResolvedValue({ nodePacks: [], querySuggestions: [] }),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue(registryFields)
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
mockRegistryProvider
)
const gateway = useRegistrySearchGateway()
// Initially should use Algolia's sort fields
expect(gateway.getSortableFields()).toEqual(algoliaFields)
// Force a search to trigger provider switch
await gateway.searchPacks('test', { pageSize: 10, pageNumber: 0 })
// Now should use Registry's sort fields
expect(gateway.getSortableFields()).toEqual(registryFields)
})
it('should delegate getSortValue to active provider', () => {
const mockAlgoliaProvider = {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn().mockReturnValue(100),
getSortableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
mockRegistryProvider
)
const gateway = useRegistrySearchGateway()
const pack = { id: '1', name: 'Test Pack' }
const value = gateway.getSortValue(pack, 'downloads')
expect(mockAlgoliaProvider.getSortValue).toHaveBeenCalledWith(
pack,
'downloads'
)
expect(value).toBe(100)
})
})
describe('Provider recovery', () => {
it('should use fallback provider when primary fails', async () => {
const algoliaError = new Error('Algolia service unavailable')
const registryResult = {
nodePacks: [{ id: 'registry-1', name: 'Registry Pack' }],
querySuggestions: []
}
const mockAlgoliaProvider = {
searchPacks: vi.fn().mockRejectedValue(algoliaError),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi.fn().mockResolvedValue(registryResult),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
mockRegistryProvider
)
const gateway = useRegistrySearchGateway()
// Should fallback to Registry when Algolia fails
const result = await gateway.searchPacks('test', {
pageSize: 10,
pageNumber: 0
})
expect(result.nodePacks[0].name).toBe('Registry Pack')
expect(mockAlgoliaProvider.searchPacks).toHaveBeenCalledTimes(1)
expect(mockRegistryProvider.searchPacks).toHaveBeenCalledTimes(1)
// The gateway successfully handled the failure and returned results
})
})
})

View File

@@ -0,0 +1,186 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useComfyRegistrySearchProvider } from '@/services/providers/registrySearchProvider'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
// Mock the store
vi.mock('@/stores/comfyRegistryStore', () => ({
useComfyRegistryStore: vi.fn()
}))
describe('useComfyRegistrySearchProvider', () => {
const mockSearchCall = vi.fn()
const mockSearchClear = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
// Setup store mock
vi.mocked(useComfyRegistryStore).mockReturnValue({
search: {
call: mockSearchCall,
clear: mockSearchClear
}
} as any)
})
describe('searchPacks', () => {
it('should search for packs by name', async () => {
const mockResults = {
nodes: [
{ id: '1', name: 'Test Pack 1' },
{ id: '2', name: 'Test Pack 2' }
]
}
mockSearchCall.mockResolvedValue(mockResults)
const provider = useComfyRegistrySearchProvider()
const result = await provider.searchPacks('test', {
pageSize: 10,
pageNumber: 0,
restrictSearchableAttributes: ['name', 'description']
})
expect(mockSearchCall).toHaveBeenCalledWith({
search: 'test',
comfy_node_search: undefined,
limit: 10,
page: 1
})
expect(result.nodePacks).toEqual(mockResults.nodes)
expect(result.querySuggestions).toEqual([])
})
it('should search for packs by node names', async () => {
const mockResults = {
nodes: [{ id: '1', name: 'Pack with LoadImage node' }]
}
mockSearchCall.mockResolvedValue(mockResults)
const provider = useComfyRegistrySearchProvider()
const result = await provider.searchPacks('LoadImage', {
pageSize: 20,
pageNumber: 1,
restrictSearchableAttributes: ['comfy_nodes']
})
expect(mockSearchCall).toHaveBeenCalledWith({
search: undefined,
comfy_node_search: 'LoadImage',
limit: 20,
page: 2
})
expect(result.nodePacks).toEqual(mockResults.nodes)
})
it('should handle empty results', async () => {
mockSearchCall.mockResolvedValue({ nodes: [] })
const provider = useComfyRegistrySearchProvider()
const result = await provider.searchPacks('nonexistent', {
pageSize: 10,
pageNumber: 0
})
expect(result.nodePacks).toEqual([])
expect(result.querySuggestions).toEqual([])
})
it('should handle null results', async () => {
mockSearchCall.mockResolvedValue(null)
const provider = useComfyRegistrySearchProvider()
const result = await provider.searchPacks('test', {
pageSize: 10,
pageNumber: 0
})
expect(result.nodePacks).toEqual([])
expect(result.querySuggestions).toEqual([])
})
it('should handle results without nodes property', async () => {
mockSearchCall.mockResolvedValue({})
const provider = useComfyRegistrySearchProvider()
const result = await provider.searchPacks('test', {
pageSize: 10,
pageNumber: 0
})
expect(result.nodePacks).toEqual([])
expect(result.querySuggestions).toEqual([])
})
})
describe('clearSearchCache', () => {
it('should delegate to store search.clear', () => {
const provider = useComfyRegistrySearchProvider()
provider.clearSearchCache()
expect(mockSearchClear).toHaveBeenCalled()
})
})
describe('getSortValue', () => {
const testPack = {
id: '1',
name: 'Test Pack',
downloads: 100,
publisher: { id: 'pub1', name: 'Publisher One' },
latest_version: {
version: '1.0.0',
createdAt: '2024-01-15T10:00:00Z'
}
}
it('should return download count for downloads field', () => {
const provider = useComfyRegistrySearchProvider()
expect(provider.getSortValue(testPack, 'downloads')).toBe(100)
})
it('should return pack name for name field', () => {
const provider = useComfyRegistrySearchProvider()
expect(provider.getSortValue(testPack, 'name')).toBe('Test Pack')
})
it('should return publisher name for publisher field', () => {
const provider = useComfyRegistrySearchProvider()
expect(provider.getSortValue(testPack, 'publisher')).toBe('Publisher One')
})
it('should return timestamp for updated field', () => {
const provider = useComfyRegistrySearchProvider()
const timestamp = new Date('2024-01-15T10:00:00Z').getTime()
expect(provider.getSortValue(testPack, 'updated')).toBe(timestamp)
})
it('should handle missing values gracefully', () => {
const incompletePack = { id: '1', name: 'Incomplete' }
const provider = useComfyRegistrySearchProvider()
expect(provider.getSortValue(incompletePack, 'downloads')).toBe(0)
expect(provider.getSortValue(incompletePack, 'publisher')).toBe('')
expect(provider.getSortValue(incompletePack, 'updated')).toBe(0)
})
it('should return 0 for unknown sort fields', () => {
const provider = useComfyRegistrySearchProvider()
expect(provider.getSortValue(testPack, 'unknown')).toBe(0)
})
})
describe('getSortableFields', () => {
it('should return supported sort fields', () => {
const provider = useComfyRegistrySearchProvider()
const fields = provider.getSortableFields()
expect(fields).toEqual([
{ id: 'downloads', label: 'Downloads', direction: 'desc' },
{ id: 'name', label: 'Name', direction: 'asc' },
{ id: 'publisher', label: 'Publisher', direction: 'asc' },
{ id: 'updated', label: 'Updated', direction: 'desc' }
])
})
})
})

View File

@@ -0,0 +1,175 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { defineComponent } from 'vue'
import { useDialogStore } from '@/stores/dialogStore'
const MockComponent = defineComponent({
name: 'MockComponent',
template: '<div>Mock</div>'
})
describe('dialogStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('priority system', () => {
it('should create dialogs in correct priority order', () => {
const store = useDialogStore()
// Create dialogs with different priorities
store.showDialog({
key: 'low-priority',
component: MockComponent,
priority: 0
})
store.showDialog({
key: 'high-priority',
component: MockComponent,
priority: 10
})
store.showDialog({
key: 'medium-priority',
component: MockComponent,
priority: 5
})
store.showDialog({
key: 'no-priority',
component: MockComponent
})
// Check order: high (2) -> medium (1) -> low (0)
expect(store.dialogStack.map((d) => d.key)).toEqual([
'high-priority',
'medium-priority',
'no-priority',
'low-priority'
])
})
it('should maintain priority order when rising dialogs', () => {
const store = useDialogStore()
// Create dialogs with different priorities
store.showDialog({
key: 'priority-2',
component: MockComponent,
priority: 2
})
store.showDialog({
key: 'priority-1',
component: MockComponent,
priority: 1
})
store.showDialog({
key: 'priority-0',
component: MockComponent,
priority: 0
})
// Try to rise the lowest priority dialog
store.riseDialog({ key: 'priority-0' })
// Should still be at the bottom because of its priority
expect(store.dialogStack.map((d) => d.key)).toEqual([
'priority-2',
'priority-1',
'priority-0'
])
// Rise the medium priority dialog
store.riseDialog({ key: 'priority-1' })
// Should be above priority-0 but below priority-2
expect(store.dialogStack.map((d) => d.key)).toEqual([
'priority-2',
'priority-1',
'priority-0'
])
})
it('should keep high priority dialogs on top when creating new lower priority dialogs', () => {
const store = useDialogStore()
// Create a high priority dialog (like manager progress)
store.showDialog({
key: 'manager-progress',
component: MockComponent,
priority: 10
})
store.showDialog({
key: 'dialog-2',
component: MockComponent,
priority: 0
})
store.showDialog({
key: 'dialog-3',
component: MockComponent
// Default priority is 1
})
// Manager progress should still be on top
expect(store.dialogStack[0].key).toBe('manager-progress')
// Check full order
expect(store.dialogStack.map((d) => d.key)).toEqual([
'manager-progress', // priority 2
'dialog-3', // priority 1 (default)
'dialog-2' // priority 0
])
})
})
describe('basic dialog operations', () => {
it('should show and close dialogs', () => {
const store = useDialogStore()
store.showDialog({
key: 'test-dialog',
component: MockComponent
})
expect(store.dialogStack).toHaveLength(1)
expect(store.isDialogOpen('test-dialog')).toBe(true)
store.closeDialog({ key: 'test-dialog' })
expect(store.dialogStack).toHaveLength(0)
expect(store.isDialogOpen('test-dialog')).toBe(false)
})
it('should reuse existing dialog when showing with same key', () => {
const store = useDialogStore()
store.showDialog({
key: 'reusable-dialog',
component: MockComponent,
title: 'Original Title'
})
// First call should create the dialog
expect(store.dialogStack).toHaveLength(1)
expect(store.dialogStack[0].title).toBe('Original Title')
// Second call with same key should reuse the dialog
store.showDialog({
key: 'reusable-dialog',
component: MockComponent,
title: 'New Title' // This should be ignored
})
// Should still have only one dialog with original title
expect(store.dialogStack).toHaveLength(1)
expect(store.dialogStack[0].key).toBe('reusable-dialog')
expect(store.dialogStack[0].title).toBe('Original Title')
})
})
})

View File

@@ -92,21 +92,6 @@ describe('useSettingStore', () => {
'Setting test.setting must have a unique ID.'
)
})
it('should migrate deprecated values', () => {
const setting: SettingParams = {
id: 'test.setting',
name: 'test.setting',
type: 'text',
defaultValue: 'default',
migrateDeprecatedValue: (value: string) => value.toUpperCase()
}
store.settingValues['test.setting'] = 'oldvalue'
store.addSetting(setting)
expect(store.settingValues['test.setting']).toBe('OLDVALUE')
})
})
describe('get and set', () => {

View File

@@ -0,0 +1,271 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
import { useSettingStore } from '@/stores/settingStore'
import {
SETTING_MIGRATIONS,
runSettingMigrations
} from '@/utils/migration/settingsMigration'
// Mock the api
vi.mock('@/scripts/api', () => ({
api: {
getSettings: vi.fn(),
storeSetting: vi.fn()
}
}))
// Mock the app
vi.mock('@/scripts/app', () => ({
app: {
ui: {
settings: {
dispatchChange: vi.fn()
}
}
}
}))
describe('settingsMigration', () => {
let store: ReturnType<typeof useSettingStore>
beforeEach(() => {
setActivePinia(createPinia())
store = useSettingStore()
vi.clearAllMocks()
// Mock the store methods to avoid needing registered settings
vi.spyOn(store, 'set').mockImplementation(async (key, value) => {
store.settingValues[key] = value
await api.storeSetting(key, value)
})
vi.spyOn(store, 'get').mockImplementation((key) => {
return store.settingValues[key]
})
vi.spyOn(store, 'exists').mockImplementation((key) => {
return key in store.settingValues
})
})
describe('Comfy.UseNewMenu migration', () => {
it('should migrate "Floating" value to "Top"', async () => {
// Setup initial state with old value
store.settingValues = { 'Comfy.UseNewMenu': 'Floating' }
// Check condition
const migration = SETTING_MIGRATIONS[0]
expect(migration.condition()).toBe(true)
// Run migration
await migration.migrate()
// Verify the value was updated
expect(store.settingValues['Comfy.UseNewMenu']).toBe('Top')
expect(api.storeSetting).toHaveBeenCalledWith('Comfy.UseNewMenu', 'Top')
})
it('should not migrate when value is not "Floating"', () => {
// Setup with different value
store.settingValues = { 'Comfy.UseNewMenu': 'Bottom' }
// Check condition
const migration = SETTING_MIGRATIONS[0]
expect(migration.condition()).toBe(false)
})
it('should not migrate when setting does not exist', () => {
// No settings
store.settingValues = {}
// Check condition
const migration = SETTING_MIGRATIONS[0]
expect(migration.condition()).toBe(false)
})
})
describe('Comfy.Keybinding.UnsetBindings migration', () => {
it('should migrate targetSelector to targetElementId', async () => {
// Setup with old format
store.settingValues = {
'Comfy.Keybinding.UnsetBindings': [
{ targetSelector: '#graph-canvas', key: 'a' },
{ targetSelector: '#other', key: 'b' }
]
}
// Check condition
const migration = SETTING_MIGRATIONS[1]
expect(migration.condition()).toBe(true)
// Run migration
await migration.migrate()
// Verify the migration
const result = store.settingValues['Comfy.Keybinding.UnsetBindings']
expect(result).toEqual([
{ targetElementId: 'graph-canvas', key: 'a' },
{ targetSelector: '#other', key: 'b' } // Only #graph-canvas is migrated
])
expect(api.storeSetting).toHaveBeenCalledWith(
'Comfy.Keybinding.UnsetBindings',
result
)
})
it('should delete targetSelector property after migration', async () => {
// Setup with old format
store.settingValues = {
'Comfy.Keybinding.UnsetBindings': [
{ targetSelector: '#graph-canvas', key: 'a' }
]
}
// Run migration
await SETTING_MIGRATIONS[1].migrate()
// Verify targetSelector is deleted and targetElementId is added
const result = store.settingValues['Comfy.Keybinding.UnsetBindings'][0]
expect(result).not.toHaveProperty('targetSelector')
expect(result).toHaveProperty('targetElementId', 'graph-canvas')
expect(result).toHaveProperty('key', 'a')
})
it('should not migrate when all keybindings use new format', () => {
// Setup with new format
store.settingValues = {
'Comfy.Keybinding.UnsetBindings': [
{ targetElementId: 'graph-canvas', key: 'a' }
]
}
// Check condition
const migration = SETTING_MIGRATIONS[1]
expect(migration.condition()).toBe(false)
})
it('should not migrate when setting does not exist', () => {
// No settings
store.settingValues = {}
// Check condition
const migration = SETTING_MIGRATIONS[1]
expect(migration.condition()).toBe(false)
})
it('should handle empty keybindings array', () => {
// Empty array
store.settingValues = { 'Comfy.Keybinding.UnsetBindings': [] }
// Check condition
const migration = SETTING_MIGRATIONS[1]
expect(migration.condition()).toBe(false)
})
})
describe('Comfy.ColorPalette migration', () => {
it('should remove "custom_" prefix', async () => {
// Setup with old format
store.settingValues = { 'Comfy.ColorPalette': 'custom_mytheme' }
// Check condition
const migration = SETTING_MIGRATIONS[2]
expect(migration.condition()).toBe(true)
// Run migration
await migration.migrate()
// Verify the migration
expect(store.settingValues['Comfy.ColorPalette']).toBe('mytheme')
expect(api.storeSetting).toHaveBeenCalledWith(
'Comfy.ColorPalette',
'mytheme'
)
})
it('should not migrate when value does not start with "custom_"', () => {
// Setup with value that doesn't need migration
store.settingValues = { 'Comfy.ColorPalette': 'dark' }
// Check condition
const migration = SETTING_MIGRATIONS[2]
expect(migration.condition()).toBe(false)
})
it('should not migrate when setting does not exist', () => {
// No settings
store.settingValues = {}
// Check condition
const migration = SETTING_MIGRATIONS[2]
expect(migration.condition()).toBe(false)
})
})
describe('runSettingMigrations', () => {
it('should run all applicable migrations', async () => {
// Setup state that triggers all migrations
store.settingValues = {
'Comfy.UseNewMenu': 'Floating',
'Comfy.Keybinding.UnsetBindings': [
{ targetSelector: '#graph-canvas', key: 'a' }
],
'Comfy.ColorPalette': 'custom_mytheme'
}
// Run all migrations
await runSettingMigrations()
// Verify all migrations ran
expect(store.settingValues['Comfy.UseNewMenu']).toBe('Top')
expect(store.settingValues['Comfy.Keybinding.UnsetBindings']).toEqual([
{ targetElementId: 'graph-canvas', key: 'a' }
])
expect(store.settingValues['Comfy.ColorPalette']).toBe('mytheme')
// Verify all API calls were made
expect(api.storeSetting).toHaveBeenCalledTimes(3)
})
it('should only run migrations that meet their conditions', async () => {
// Setup state that only triggers one migration
store.settingValues = {
'Comfy.UseNewMenu': 'Bottom', // Won't migrate
'Comfy.ColorPalette': 'custom_mytheme' // Will migrate
}
// Run migrations
await runSettingMigrations()
// Verify only one migration ran
expect(store.settingValues['Comfy.UseNewMenu']).toBe('Bottom')
expect(store.settingValues['Comfy.ColorPalette']).toBe('mytheme')
// Only one API call
expect(api.storeSetting).toHaveBeenCalledTimes(1)
expect(api.storeSetting).toHaveBeenCalledWith(
'Comfy.ColorPalette',
'mytheme'
)
})
it('should handle no migrations needed', async () => {
// Setup state that doesn't trigger any migrations
store.settingValues = {
'Comfy.UseNewMenu': 'Top',
'Comfy.Keybinding.UnsetBindings': [
{ targetElementId: 'graph-canvas', key: 'a' }
],
'Comfy.ColorPalette': 'dark'
}
// Run migrations
await runSettingMigrations()
// No API calls should be made
expect(api.storeSetting).not.toHaveBeenCalled()
})
})
})

View File

@@ -3,10 +3,9 @@ import dotenv from 'dotenv'
import IconsResolver from 'unplugin-icons/resolver'
import Icons from 'unplugin-icons/vite'
import Components from 'unplugin-vue-components/vite'
import { defineConfig } from 'vite'
import { type UserConfig, defineConfig } from 'vite'
import { createHtmlPlugin } from 'vite-plugin-html'
import vueDevTools from 'vite-plugin-vue-devtools'
import type { UserConfigExport } from 'vitest/config'
import {
addElementVnodeExportPlugin,
@@ -154,4 +153,4 @@ export default defineConfig({
optimizeDeps: {
exclude: ['@comfyorg/litegraph', '@comfyorg/comfyui-electron-types']
}
}) as UserConfigExport
}) satisfies UserConfig as UserConfig

View File

@@ -1,6 +1,5 @@
import { Plugin, defineConfig } from 'vite'
import { mergeConfig } from 'vite'
import type { UserConfig } from 'vitest/config'
import baseConfig from './vite.config.mts'
@@ -83,7 +82,7 @@ const mockElectronAPI: Plugin = {
}
export default mergeConfig(
baseConfig as unknown as UserConfig,
baseConfig,
defineConfig({
plugins: [mockElectronAPI]
})

View File

@@ -7,7 +7,10 @@ export default defineConfig({
globals: true,
environment: 'happy-dom',
setupFiles: ['./vitest.setup.ts'],
include: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
include: [
'tests-ui/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
'src/components/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'
],
coverage: {
reporter: ['text', 'json', 'html']
}