mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-08 16:17:58 +00:00
Compare commits
2 Commits
v1.23.1
...
js/drafts/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fcde239de | ||
|
|
1eba468f55 |
10
.github/workflows/test-ui.yaml
vendored
10
.github/workflows/test-ui.yaml
vendored
@@ -46,8 +46,8 @@ jobs:
|
||||
id: cache-key
|
||||
run: echo "key=$(date +%s)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Save cache
|
||||
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684
|
||||
- name: Cache setup
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
ComfyUI
|
||||
@@ -62,13 +62,9 @@ jobs:
|
||||
matrix:
|
||||
browser: [chromium, chromium-2x, mobile-chrome]
|
||||
steps:
|
||||
- name: Wait for cache propagation
|
||||
run: sleep 10
|
||||
|
||||
- name: Restore cached setup
|
||||
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
fail-on-cache-miss: true
|
||||
path: |
|
||||
ComfyUI
|
||||
ComfyUI_frontend
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.23.1",
|
||||
"version": "1.22.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.23.1",
|
||||
"version": "1.22.1",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.23.1",
|
||||
"version": "1.22.1",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
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"
|
||||
@@ -32,12 +31,6 @@
|
||||
</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>
|
||||
@@ -48,9 +41,6 @@ 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'
|
||||
@@ -62,10 +52,6 @@ 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
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
<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>
|
||||
@@ -32,8 +32,6 @@
|
||||
v-model:sortField="sortField"
|
||||
:search-results="searchResults"
|
||||
:suggestions="suggestions"
|
||||
:is-missing-tab="isMissingTab"
|
||||
:sort-options="sortOptions"
|
||||
/>
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div
|
||||
@@ -95,14 +93,7 @@
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { merge } from 'lodash'
|
||||
import Button from 'primevue/button'
|
||||
import {
|
||||
computed,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
||||
@@ -115,7 +106,6 @@ 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'
|
||||
@@ -126,15 +116,13 @@ import type { TabItem } from '@/types/comfyManagerTypes'
|
||||
import { ManagerTab } from '@/types/comfyManagerTypes'
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const { initialTab } = defineProps<{
|
||||
initialTab?: ManagerTab
|
||||
const { initialTab = ManagerTab.All } = defineProps<{
|
||||
initialTab: ManagerTab
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const { getPackById } = useComfyRegistryStore()
|
||||
const persistedState = useManagerStatePersistence()
|
||||
const initialState = persistedState.loadStoredState()
|
||||
|
||||
const GRID_STYLE = {
|
||||
display: 'grid',
|
||||
@@ -168,10 +156,8 @@ const tabs = ref<TabItem[]>([
|
||||
icon: 'pi-sync'
|
||||
}
|
||||
])
|
||||
|
||||
const initialTabId = initialTab ?? initialState.selectedTabId
|
||||
const selectedTab = ref<TabItem>(
|
||||
tabs.value.find((tab) => tab.id === initialTabId) || tabs.value[0]
|
||||
tabs.value.find((tab) => tab.id === initialTab) || tabs.value[0]
|
||||
)
|
||||
|
||||
const {
|
||||
@@ -181,13 +167,8 @@ const {
|
||||
searchResults,
|
||||
searchMode,
|
||||
sortField,
|
||||
suggestions,
|
||||
sortOptions
|
||||
} = useRegistrySearch({
|
||||
initialSortField: initialState.sortField,
|
||||
initialSearchMode: initialState.searchMode,
|
||||
initialSearchQuery: initialState.searchQuery
|
||||
})
|
||||
suggestions
|
||||
} = useRegistrySearch()
|
||||
pageNumber.value = 0
|
||||
const onApproachEnd = () => {
|
||||
pageNumber.value++
|
||||
@@ -449,13 +430,7 @@ whenever(selectedNodePack, async () => {
|
||||
if (data?.id === pack.id) {
|
||||
lastFetchedPackId.value = pack.id
|
||||
const mergedPack = merge({}, pack, data)
|
||||
// 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)
|
||||
}
|
||||
selectedNodePacks.value = [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) {
|
||||
@@ -475,15 +450,6 @@ watch(searchQuery, () => {
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
persistedState.persistState({
|
||||
selectedTabId: selectedTab.value?.id,
|
||||
searchQuery: searchQuery.value,
|
||||
searchMode: searchMode.value,
|
||||
sortField: sortField.value
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
getPackById.cancel()
|
||||
})
|
||||
|
||||
@@ -62,7 +62,6 @@ describe('PackVersionBadge', () => {
|
||||
return mount(PackVersionBadge, {
|
||||
props: {
|
||||
nodePack: mockNodePack,
|
||||
isSelected: false,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
@@ -163,58 +162,4 @@ 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import PackVersionSelectorPopover from '@/components/dialog/content/manager/PackVersionSelectorPopover.vue'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
@@ -43,9 +43,8 @@ import { isSemVer } from '@/utils/formatUtil'
|
||||
|
||||
const TRUNCATED_HASH_LENGTH = 7
|
||||
|
||||
const { nodePack, isSelected } = defineProps<{
|
||||
const { nodePack } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
isSelected: boolean
|
||||
}>()
|
||||
|
||||
const popoverRef = ref()
|
||||
@@ -70,14 +69,4 @@ 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>
|
||||
|
||||
@@ -191,100 +191,6 @@ 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
|
||||
|
||||
@@ -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, ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
||||
@@ -161,11 +161,9 @@ const onNodePackChange = async () => {
|
||||
}
|
||||
|
||||
whenever(
|
||||
() => nodePack.id,
|
||||
(nodePackId, oldNodePackId) => {
|
||||
if (nodePackId !== oldNodePackId) {
|
||||
void onNodePackChange()
|
||||
}
|
||||
() => nodePack,
|
||||
() => {
|
||||
void onNodePackChange()
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
@@ -184,4 +182,8 @@ const handleSubmit = async () => {
|
||||
isQueueing.value = false
|
||||
emit('submit')
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
managerStore.installPack.clear()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
<template>
|
||||
<Button
|
||||
outlined
|
||||
class="!m-0 p-0 rounded-lg"
|
||||
:class="[
|
||||
variant === 'black'
|
||||
? 'bg-neutral-900 text-white border-neutral-900'
|
||||
: 'border-neutral-700',
|
||||
fullWidth ? 'w-full' : 'w-min-content'
|
||||
]"
|
||||
class="m-0 p-0 rounded-lg border-neutral-700"
|
||||
:class="{
|
||||
'w-full': fullWidth,
|
||||
'w-min-content': !fullWidth
|
||||
}"
|
||||
:disabled="loading"
|
||||
v-bind="$attrs"
|
||||
@click="onClick"
|
||||
>
|
||||
<span class="py-2.5 px-3 whitespace-nowrap">
|
||||
<span class="py-2.5 px-3">
|
||||
<template v-if="loading">
|
||||
{{ loadingMessage ?? $t('g.loading') }}
|
||||
</template>
|
||||
@@ -29,14 +27,12 @@ import Button from 'primevue/button'
|
||||
const {
|
||||
label,
|
||||
loadingMessage,
|
||||
fullWidth = false,
|
||||
variant = 'default'
|
||||
fullWidth = false
|
||||
} = defineProps<{
|
||||
label: string
|
||||
loading?: boolean
|
||||
loadingMessage?: string
|
||||
fullWidth?: boolean
|
||||
variant?: 'default' | 'black'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
<PackActionButton
|
||||
v-bind="$attrs"
|
||||
:label="
|
||||
label ??
|
||||
(nodePacks.length > 1 ? $t('manager.installSelected') : $t('g.install'))
|
||||
nodePacks.length > 1 ? $t('manager.installSelected') : $t('g.install')
|
||||
"
|
||||
:severity="variant === 'black' ? undefined : 'secondary'"
|
||||
:variant="variant"
|
||||
severity="secondary"
|
||||
:loading="isInstalling"
|
||||
:loading-message="$t('g.installing')"
|
||||
@action="installAllPacks"
|
||||
@@ -29,10 +27,8 @@ import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
type NodePack = components['schemas']['Node']
|
||||
|
||||
const { nodePacks, variant, label } = defineProps<{
|
||||
const { nodePacks } = defineProps<{
|
||||
nodePacks: NodePack[]
|
||||
variant?: 'default' | 'black'
|
||||
label?: string
|
||||
}>()
|
||||
|
||||
const isInstalling = inject(IsInstallingKey, ref(false))
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
/>
|
||||
</MetadataRow>
|
||||
<MetadataRow :label="t('manager.version')">
|
||||
<PackVersionBadge :node-pack="nodePack" :is-selected="true" />
|
||||
<PackVersionBadge :node-pack="nodePack" />
|
||||
</MetadataRow>
|
||||
</div>
|
||||
<div class="mb-6 overflow-hidden">
|
||||
@@ -118,15 +118,7 @@ const onNodePackChange = () => {
|
||||
y.value = 0
|
||||
}
|
||||
|
||||
whenever(
|
||||
() => nodePack.id,
|
||||
(nodePackId, oldNodePackId) => {
|
||||
if (nodePackId !== oldNodePackId) {
|
||||
onNodePackChange()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
whenever(() => nodePack, onNodePackChange, { immediate: true, deep: true })
|
||||
</script>
|
||||
<style scoped>
|
||||
.hidden-scrollbar {
|
||||
|
||||
@@ -51,11 +51,7 @@ 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,
|
||||
// Fetch all nodes.
|
||||
// TODO: Render all nodes previews and handle pagination.
|
||||
// For determining length, use the `totalNumberOfPages` field of response
|
||||
limit: 8192
|
||||
version: pack.latest_version?.version
|
||||
})
|
||||
return nodeDefs?.comfy_nodes ?? []
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<Card
|
||||
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="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="{
|
||||
'selected-card': isSelected,
|
||||
'outline outline-[6px] outline-[var(--p-primary-color)]': isSelected,
|
||||
'opacity-60': isDisabled
|
||||
}"
|
||||
:pt="{
|
||||
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' },
|
||||
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' },
|
||||
title: { class: 'w-full h-full rounded-t-lg cursor-pointer' },
|
||||
footer: { class: 'p-0 m-0' }
|
||||
}"
|
||||
@@ -70,10 +70,7 @@
|
||||
>
|
||||
<i class="pi pi-arrow-circle-up text-blue-600" />
|
||||
</div>
|
||||
<PackVersionBadge
|
||||
:node-pack="nodePack"
|
||||
:is-selected="isSelected"
|
||||
/>
|
||||
<PackVersionBadge :node-pack="nodePack" />
|
||||
</div>
|
||||
<div
|
||||
v-if="formattedLatestVersionDate"
|
||||
@@ -161,22 +158,3 @@ const formattedLatestVersionDate = computed(() => {
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.selected-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.selected-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border: 3px solid var(--p-primary-color);
|
||||
border-radius: 0.5rem;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,37 +1,28 @@
|
||||
<template>
|
||||
<div class="relative w-full p-6">
|
||||
<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'
|
||||
<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'
|
||||
}
|
||||
}"
|
||||
: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')"
|
||||
},
|
||||
loader: {
|
||||
style: 'display: none'
|
||||
}
|
||||
}"
|
||||
:show-empty-message="false"
|
||||
@complete="stubTrue"
|
||||
@option-select="onOptionSelect"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex mt-3 text-sm">
|
||||
@@ -43,7 +34,7 @@
|
||||
/>
|
||||
<SearchFilterDropdown
|
||||
v-model:modelValue="sortField"
|
||||
:options="availableSortOptions"
|
||||
:options="sortOptions"
|
||||
:label="$t('g.sort')"
|
||||
/>
|
||||
</div>
|
||||
@@ -64,55 +55,43 @@ 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 { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
|
||||
import type { NodesIndexSuggestion } from '@/types/algoliaTypes'
|
||||
import {
|
||||
type SearchOption,
|
||||
SortableAlgoliaField
|
||||
} from '@/types/comfyManagerTypes'
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
import type {
|
||||
QuerySuggestion,
|
||||
SearchMode,
|
||||
SortableField
|
||||
} from '@/types/searchServiceTypes'
|
||||
|
||||
const { searchResults, sortOptions } = defineProps<{
|
||||
const { searchResults } = defineProps<{
|
||||
searchResults?: components['schemas']['Node'][]
|
||||
suggestions?: QuerySuggestion[]
|
||||
sortOptions?: SortableField[]
|
||||
isMissingTab?: boolean
|
||||
suggestions?: NodesIndexSuggestion[]
|
||||
}>()
|
||||
|
||||
const searchQuery = defineModel<string>('searchQuery')
|
||||
const searchMode = defineModel<SearchMode>('searchMode', { default: 'packs' })
|
||||
const sortField = defineModel<string>('sortField', {
|
||||
const searchMode = defineModel<string>('searchMode', { default: 'packs' })
|
||||
const sortField = defineModel<SortableAlgoliaField>('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 availableSortOptions = computed<SearchOption<string>[]>(() => {
|
||||
if (!sortOptions) return []
|
||||
return sortOptions.map((field) => ({
|
||||
id: field.id,
|
||||
label: field.label
|
||||
}))
|
||||
})
|
||||
const filterOptions: SearchOption<SearchMode>[] = [
|
||||
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>[] = [
|
||||
{ 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
|
||||
}
|
||||
|
||||
@@ -71,7 +71,6 @@ import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave'
|
||||
import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
|
||||
import { CORE_SETTINGS } from '@/constants/coreSettings'
|
||||
import { i18n, t } from '@/i18n'
|
||||
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
|
||||
import { UnauthorizedError, api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
@@ -189,22 +188,46 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
// Update the progress of the executing node
|
||||
// Update the progress of executing nodes
|
||||
watch(
|
||||
() =>
|
||||
[executionStore.executingNodeId, executionStore.executingNodeProgress] as [
|
||||
NodeId | null,
|
||||
number | null
|
||||
],
|
||||
([executingNodeId, executingNodeProgress]) => {
|
||||
() => executionStore.nodeProgressStates,
|
||||
(nodeProgressStates) => {
|
||||
// Clear progress for all nodes first
|
||||
for (const node of comfyApp.graph.nodes) {
|
||||
if (node.id == executingNodeId) {
|
||||
node.progress = executingNodeProgress ?? undefined
|
||||
} else {
|
||||
node.progress = undefined
|
||||
node.progress = undefined
|
||||
}
|
||||
|
||||
// Then set progress for nodes with progress states
|
||||
for (const nodeId in nodeProgressStates) {
|
||||
const progressState = nodeProgressStates[nodeId]
|
||||
const node = comfyApp.graph.getNodeById(progressState.display_node_id)
|
||||
|
||||
if (node && progressState) {
|
||||
// Only show progress for running nodes
|
||||
if (progressState.state === 'running') {
|
||||
if (node.progress === undefined || node.progress === 0.0) {
|
||||
console.log(
|
||||
`${Date.now()} Setting progress for node ${node.id} to ${progressState.value / progressState.max}=${progressState.value}/${progressState.max} due to ${nodeId}`
|
||||
)
|
||||
node.progress = progressState.value / progressState.max
|
||||
} else {
|
||||
// Update progress if it was already set
|
||||
console.log(
|
||||
`${Date.now()} Setting progress for node ${node.id} to Math.min(${node.progress}, ${progressState.value / progressState.max}=${progressState.value}/${progressState.max}) due to ${nodeId}`
|
||||
)
|
||||
node.progress = Math.min(
|
||||
node.progress,
|
||||
progressState.value / progressState.max
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO - Do we need to force canvas redraw here?
|
||||
// comfyApp.graph.setDirtyCanvas(true, true)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Update node slot errors
|
||||
|
||||
@@ -20,33 +20,44 @@ import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { linkifyHtml, nl2br } from '@/utils/formatUtil'
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
widget?: object
|
||||
nodeId: NodeId
|
||||
}>()
|
||||
|
||||
const executionStore = useExecutionStore()
|
||||
const isParentNodeExecuting = ref(true)
|
||||
const formattedText = computed(() => nl2br(linkifyHtml(modelValue.value)))
|
||||
|
||||
let executingNodeId: NodeId | null = null
|
||||
let parentNodeId: NodeId | null = null
|
||||
onMounted(() => {
|
||||
executingNodeId = executionStore.executingNodeId
|
||||
// Get the parent node ID from props if provided
|
||||
// For backward compatibility, fall back to the first executing node
|
||||
parentNodeId = props.nodeId
|
||||
})
|
||||
|
||||
// Watch for either a new node has starting execution or overall execution ending
|
||||
const stopWatching = watch(
|
||||
[() => executionStore.executingNode, () => executionStore.isIdle],
|
||||
[() => executionStore.executingNodeIds, () => executionStore.isIdle],
|
||||
() => {
|
||||
if (executionStore.isIdle) {
|
||||
isParentNodeExecuting.value = false
|
||||
stopWatching()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if parent node is no longer in the executing nodes list
|
||||
if (
|
||||
executionStore.isIdle ||
|
||||
(executionStore.executingNode &&
|
||||
executionStore.executingNode.id !== executingNodeId)
|
||||
parentNodeId &&
|
||||
!executionStore.executingNodeIds.includes(parentNodeId)
|
||||
) {
|
||||
isParentNodeExecuting.value = false
|
||||
stopWatching()
|
||||
}
|
||||
if (!executingNodeId) {
|
||||
executingNodeId = executionStore.executingNodeId
|
||||
|
||||
// Set parent node ID if not set yet
|
||||
if (!parentNodeId && executionStore.executingNodeIds.length > 0) {
|
||||
parentNodeId = executionStore.executingNodeIds[0]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTitle } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
@@ -36,11 +37,35 @@ export const useBrowserTabTitle = () => {
|
||||
: DEFAULT_TITLE
|
||||
})
|
||||
|
||||
const nodeExecutionTitle = computed(() =>
|
||||
executionStore.executingNode && executionStore.executingNodeProgress
|
||||
? `${executionText.value}[${Math.round(executionStore.executingNodeProgress * 100)}%] ${executionStore.executingNode.type}`
|
||||
: ''
|
||||
)
|
||||
const nodeExecutionTitle = computed(() => {
|
||||
// Check if any nodes are in progress
|
||||
const nodeProgressEntries = Object.entries(
|
||||
executionStore.nodeProgressStates
|
||||
)
|
||||
const runningNodes = nodeProgressEntries.filter(
|
||||
([_, state]) => state.state === 'running'
|
||||
)
|
||||
|
||||
if (runningNodes.length > 0) {
|
||||
// If multiple nodes are running
|
||||
if (runningNodes.length > 1) {
|
||||
return `${executionText.value}[${runningNodes.length} ${t('g.nodesRunning', 'nodes running')}]`
|
||||
}
|
||||
// If only one node is running
|
||||
else {
|
||||
const [nodeId, state] = runningNodes[0]
|
||||
const progress = Math.round((state.value / state.max) * 100)
|
||||
const nodeType =
|
||||
executionStore.activePrompt?.workflow?.changeTracker?.activeState?.nodes.find(
|
||||
(n) => String(n.id) === nodeId
|
||||
)?.type || 'Node'
|
||||
|
||||
return `${executionText.value}[${progress}%] ${nodeType}`
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
const workflowTitle = computed(
|
||||
() =>
|
||||
|
||||
@@ -658,19 +658,19 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
{
|
||||
id: 'Comfy.Manager.CustomNodesManager',
|
||||
icon: 'pi pi-puzzle',
|
||||
label: 'Toggle the Custom Nodes Manager',
|
||||
label: 'Custom Nodes Manager',
|
||||
versionAdded: '1.12.10',
|
||||
function: () => {
|
||||
dialogService.toggleManagerDialog()
|
||||
dialogService.showManagerDialog()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Manager.ToggleManagerProgressDialog',
|
||||
icon: 'pi pi-spinner',
|
||||
label: 'Toggle the Custom Nodes Manager Progress Bar',
|
||||
label: 'Toggle Progress Dialog',
|
||||
versionAdded: '1.13.9',
|
||||
function: () => {
|
||||
dialogService.toggleManagerProgressDialog()
|
||||
dialogService.showManagerProgressDialog()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -15,10 +15,7 @@ export const useProgressFavicon = () => {
|
||||
if (isIdle) {
|
||||
favicon.value = defaultFavicon
|
||||
} else {
|
||||
const frame = Math.min(
|
||||
Math.max(0, Math.floor(progress * totalFrames)),
|
||||
totalFrames - 1
|
||||
)
|
||||
const frame = Math.floor(progress * totalFrames)
|
||||
favicon.value = `/assets/images/favicon_progress_16x16/frame_${frame}.png`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +1,91 @@
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { orderBy } from 'lodash'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { Hit } from 'algoliasearch/dist/lite/browser'
|
||||
import { memoize, orderBy } from 'lodash'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { DEFAULT_PAGE_SIZE } from '@/constants/searchConstants'
|
||||
import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway'
|
||||
import type { SearchAttribute } from '@/types/algoliaTypes'
|
||||
import { useAlgoliaSearchService } from '@/services/algoliaSearchService'
|
||||
import type {
|
||||
AlgoliaNodePack,
|
||||
NodesIndexSuggestion,
|
||||
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: {
|
||||
initialSortField?: string
|
||||
initialSearchMode?: SearchMode
|
||||
initialSearchQuery?: string
|
||||
initialPageNumber?: number
|
||||
maxCacheSize?: number
|
||||
} = {}
|
||||
) {
|
||||
const {
|
||||
initialSortField = DEFAULT_SORT_FIELD,
|
||||
initialSearchMode = 'packs',
|
||||
initialSearchQuery = '',
|
||||
initialPageNumber = 0
|
||||
} = options
|
||||
|
||||
const { maxCacheSize = DEFAULT_MAX_CACHE_SIZE } = options
|
||||
const isLoading = ref(false)
|
||||
const sortField = ref<string>(initialSortField)
|
||||
const searchMode = ref<SearchMode>(initialSearchMode)
|
||||
const sortField = ref<SortableAlgoliaField>(SortableAlgoliaField.Downloads)
|
||||
const searchMode = ref<'nodes' | 'packs'>('packs')
|
||||
const pageSize = ref(DEFAULT_PAGE_SIZE)
|
||||
const pageNumber = ref(initialPageNumber)
|
||||
const searchQuery = ref(initialSearchQuery)
|
||||
const searchResults = ref<RegistryNodePack[]>([])
|
||||
const suggestions = ref<QuerySuggestion[]>([])
|
||||
const pageNumber = ref(0)
|
||||
const searchQuery = ref('')
|
||||
const results = ref<AlgoliaNodePack[]>([])
|
||||
const suggestions = ref<NodesIndexSuggestion[]>([])
|
||||
|
||||
const searchAttributes = computed<SearchAttribute[]>(() =>
|
||||
searchMode.value === 'nodes' ? ['comfy_nodes'] : ['name', 'description']
|
||||
)
|
||||
|
||||
const searchGateway = useRegistrySearchGateway()
|
||||
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 { searchPacks, clearSearchCache, getSortValue, getSortableFields } =
|
||||
searchGateway
|
||||
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 updateSearchResults = async (options: { append?: boolean }) => {
|
||||
isLoading.value = true
|
||||
if (!options.append) {
|
||||
pageNumber.value = 0
|
||||
}
|
||||
const { nodePacks, querySuggestions } = await searchPacks(
|
||||
const { nodePacks, querySuggestions } = await searchPacksCached(
|
||||
searchQuery.value,
|
||||
{
|
||||
pageSize: pageSize.value,
|
||||
@@ -68,22 +98,17 @@ 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,
|
||||
[(pack) => getSortValue(pack, sortField.value)],
|
||||
[direction]
|
||||
[getSortValue],
|
||||
[SORT_DIRECTIONS[sortField.value]]
|
||||
)
|
||||
}
|
||||
|
||||
if (options.append && searchResults.value?.length) {
|
||||
searchResults.value = searchResults.value.concat(sortedPacks)
|
||||
if (options.append && results.value?.length) {
|
||||
results.value = results.value.concat(sortedPacks)
|
||||
} else {
|
||||
searchResults.value = sortedPacks
|
||||
results.value = sortedPacks
|
||||
}
|
||||
suggestions.value = querySuggestions
|
||||
isLoading.value = false
|
||||
@@ -99,9 +124,7 @@ export function useRegistrySearch(
|
||||
immediate: true
|
||||
})
|
||||
|
||||
const sortOptions = computed(() => {
|
||||
return getSortableFields()
|
||||
})
|
||||
onUnmounted(clearSearchPacksCache)
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
@@ -111,8 +134,7 @@ export function useRegistrySearch(
|
||||
searchMode,
|
||||
searchQuery,
|
||||
suggestions,
|
||||
searchResults,
|
||||
sortOptions,
|
||||
clearCache: clearSearchCache
|
||||
searchResults: resultsAsRegistryPacks,
|
||||
nodeSearchResults: resultsAsNodes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { ResultItem } 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'
|
||||
|
||||
@@ -34,13 +33,7 @@ export const useImageUploadWidget = () => {
|
||||
inputName: string,
|
||||
inputData: InputSpec
|
||||
) => {
|
||||
if (!isImageUploadInput(inputData)) {
|
||||
throw new Error(
|
||||
'Image upload widget requires imageInputName augmentation'
|
||||
)
|
||||
}
|
||||
|
||||
const inputOptions = inputData[1]
|
||||
const inputOptions = inputData[1] ?? {}
|
||||
const { imageInputName, allow_batch, image_folder = 'input' } = inputOptions
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
@@ -50,9 +43,11 @@ 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 => {
|
||||
@@ -72,6 +67,7 @@ export const useImageUploadWidget = () => {
|
||||
|
||||
// Setup file upload handling
|
||||
const { openFileSelection } = useNodeImageUpload(node, {
|
||||
// @ts-expect-error InputSpec is not typed correctly
|
||||
allow_batch,
|
||||
fileFilter,
|
||||
accept,
|
||||
|
||||
@@ -23,6 +23,9 @@ export const useTextPreviewWidget = (
|
||||
name: inputSpec.name,
|
||||
component: TextPreviewWidget,
|
||||
inputSpec,
|
||||
componentProps: {
|
||||
nodeId: node.id
|
||||
},
|
||||
options: {
|
||||
getValue: () => widgetValue.value,
|
||||
setValue: (value: string | object) => {
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export const SEARCH_CACHE_MAX_SIZE = 64
|
||||
export const DEFAULT_PAGE_SIZE = 64
|
||||
export const MIN_CHARS_FOR_SUGGESTIONS_ALGOLIA = 2
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@/schemas/comfyWorkflowSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
@@ -1178,9 +1179,10 @@ export class GroupNodeHandler {
|
||||
node.onDrawForeground = function (ctx) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
onDrawForeground?.apply?.(this, arguments)
|
||||
const executionStore = useExecutionStore()
|
||||
if (
|
||||
// @ts-expect-error fixme ts strict error
|
||||
+app.runningNodeId === this.id &&
|
||||
executionStore.nodeProgressStates[this.id] &&
|
||||
executionStore.nodeProgressStates[this.id].state === 'running' &&
|
||||
this.runningInternalNodeId !== null
|
||||
) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
@@ -1275,6 +1277,45 @@ export class GroupNodeHandler {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
(_, id) => id
|
||||
)
|
||||
/*
|
||||
// Handle progress_state events for multiple executing nodes
|
||||
const progress_state = handleEvent.call(
|
||||
this,
|
||||
'progress_state',
|
||||
(d) => {
|
||||
// Check if any of our inner nodes are in this progress state update
|
||||
for (const nodeId in d.nodes) {
|
||||
const innerNodeIndex = this.innerNodes?.findIndex((n) => n.id == nodeId);
|
||||
if (innerNodeIndex > -1) return nodeId;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
(d, id, node) => {
|
||||
// Create a new progress_state event with just our group node
|
||||
const newProgressState = { ...d };
|
||||
newProgressState.nodes = { [id]: {
|
||||
node: id,
|
||||
state: 'running',
|
||||
value: 0,
|
||||
max: 1,
|
||||
prompt_id: d.prompt_id
|
||||
}};
|
||||
|
||||
// If we have a specific running internal node, update its state
|
||||
if (node.runningInternalNodeId !== null) {
|
||||
const innerNodeId = this.innerNodes[node.runningInternalNodeId].id;
|
||||
if (d.nodes[innerNodeId]) {
|
||||
newProgressState.nodes[id] = {
|
||||
...d.nodes[innerNodeId],
|
||||
node: id
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return newProgressState;
|
||||
}
|
||||
);
|
||||
*/
|
||||
|
||||
const executed = handleEvent.call(
|
||||
this,
|
||||
@@ -1294,6 +1335,7 @@ export class GroupNodeHandler {
|
||||
this.node.onRemoved = function () {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
onRemoved?.apply(this, arguments)
|
||||
// api.removeEventListener('progress_state', progress_state)
|
||||
api.removeEventListener('executing', executing)
|
||||
api.removeEventListener('executed', executed)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ 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'
|
||||
@@ -13,6 +12,8 @@ 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) {
|
||||
@@ -27,7 +28,7 @@ function splitFilePath(path: string): [string, string] {
|
||||
function getResourceURL(
|
||||
subfolder: string,
|
||||
filename: string,
|
||||
type: ResultItemType = 'input'
|
||||
type: FolderType = 'input'
|
||||
): string {
|
||||
const params = [
|
||||
'filename=' + encodeURIComponent(filename),
|
||||
|
||||
@@ -147,10 +147,10 @@
|
||||
"label": "Load Default Workflow"
|
||||
},
|
||||
"Comfy_Manager_CustomNodesManager": {
|
||||
"label": "Toggle the Custom Nodes Manager"
|
||||
"label": "Custom Nodes Manager"
|
||||
},
|
||||
"Comfy_Manager_ToggleManagerProgressDialog": {
|
||||
"label": "Toggle the Custom Nodes Manager Progress Bar"
|
||||
"label": "Toggle Progress Dialog"
|
||||
},
|
||||
"Comfy_MaskEditor_OpenMaskEditor": {
|
||||
"label": "Open Mask Editor for Selected Node"
|
||||
|
||||
@@ -123,7 +123,8 @@
|
||||
"copy": "Copy",
|
||||
"imageUrl": "Image URL",
|
||||
"clear": "Clear",
|
||||
"copyURL": "Copy URL"
|
||||
"copyURL": "Copy URL",
|
||||
"nodesRunning": "nodes running"
|
||||
},
|
||||
"manager": {
|
||||
"title": "Custom Nodes Manager",
|
||||
@@ -161,7 +162,6 @@
|
||||
"lastUpdated": "Last Updated",
|
||||
"noDescription": "No description available",
|
||||
"installSelected": "Install Selected",
|
||||
"installAllMissingNodes": "Install All Missing Nodes",
|
||||
"packsSelected": "Packs Selected",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
@@ -829,8 +829,8 @@
|
||||
"ComfyUI Issues": "ComfyUI Issues",
|
||||
"Interrupt": "Interrupt",
|
||||
"Load Default Workflow": "Load Default Workflow",
|
||||
"Toggle the Custom Nodes Manager": "Toggle the Custom Nodes Manager",
|
||||
"Toggle the Custom Nodes Manager Progress Bar": "Toggle the Custom Nodes Manager Progress Bar",
|
||||
"Custom Nodes Manager": "Custom Nodes Manager",
|
||||
"Toggle Progress Dialog": "Toggle Progress Dialog",
|
||||
"Open Mask Editor for Selected Node": "Open Mask Editor for Selected Node",
|
||||
"New": "New",
|
||||
"Clipspace": "Clipspace",
|
||||
@@ -1195,11 +1195,6 @@
|
||||
"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",
|
||||
|
||||
@@ -323,6 +323,7 @@
|
||||
"noTasksFoundMessage": "No hay tareas en la cola.",
|
||||
"noWorkflowsFound": "No se encontraron flujos de trabajo.",
|
||||
"nodes": "Nodos",
|
||||
"nodesRunning": "nodos en ejecución",
|
||||
"ok": "OK",
|
||||
"openNewIssue": "Abrir nuevo problema",
|
||||
"overwrite": "Sobrescribir",
|
||||
@@ -551,11 +552,6 @@
|
||||
"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",
|
||||
@@ -591,7 +587,6 @@
|
||||
},
|
||||
"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",
|
||||
@@ -700,6 +695,7 @@
|
||||
"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",
|
||||
@@ -753,13 +749,12 @@
|
||||
"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",
|
||||
|
||||
@@ -323,6 +323,7 @@
|
||||
"noTasksFoundMessage": "Il n'y a pas de tâches dans la file d'attente.",
|
||||
"noWorkflowsFound": "Aucun flux de travail trouvé.",
|
||||
"nodes": "Nœuds",
|
||||
"nodesRunning": "nœuds en cours d’exécution",
|
||||
"ok": "OK",
|
||||
"openNewIssue": "Ouvrir un nouveau problème",
|
||||
"overwrite": "Écraser",
|
||||
@@ -551,11 +552,6 @@
|
||||
"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",
|
||||
@@ -591,7 +587,6 @@
|
||||
},
|
||||
"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",
|
||||
@@ -700,6 +695,7 @@
|
||||
"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",
|
||||
@@ -753,13 +749,12 @@
|
||||
"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",
|
||||
|
||||
@@ -323,6 +323,7 @@
|
||||
"noTasksFoundMessage": "キューにタスクがありません。",
|
||||
"noWorkflowsFound": "ワークフローが見つかりません。",
|
||||
"nodes": "ノード",
|
||||
"nodesRunning": "ノードが実行中",
|
||||
"ok": "OK",
|
||||
"openNewIssue": "新しい問題を開く",
|
||||
"overwrite": "上書き",
|
||||
@@ -551,11 +552,6 @@
|
||||
"uploadBackgroundImage": "背景画像をアップロード",
|
||||
"uploadTexture": "テクスチャをアップロード"
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "ComfyUI {version} が必要です:",
|
||||
"outdatedVersion": "一部のノードはより新しいバージョンのComfyUIが必要です(現在のバージョン:{version})。すべてのノードを使用するにはアップデートしてください。",
|
||||
"outdatedVersionGeneric": "一部のノードはより新しいバージョンのComfyUIが必要です。すべてのノードを使用するにはアップデートしてください。"
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "なし",
|
||||
"OK": "OK",
|
||||
@@ -591,7 +587,6 @@
|
||||
},
|
||||
"inWorkflow": "ワークフロー内",
|
||||
"infoPanelEmpty": "アイテムをクリックして情報を表示します",
|
||||
"installAllMissingNodes": "すべての不足しているノードをインストール",
|
||||
"installSelected": "選択したものをインストール",
|
||||
"installationQueue": "インストールキュー",
|
||||
"lastUpdated": "最終更新日",
|
||||
@@ -700,6 +695,7 @@
|
||||
"ComfyUI Issues": "ComfyUIの問題",
|
||||
"Contact Support": "サポートに連絡",
|
||||
"Convert selected nodes to group node": "選択したノードをグループノードに変換",
|
||||
"Custom Nodes Manager": "カスタムノードマネージャ",
|
||||
"Delete Selected Items": "選択したアイテムを削除",
|
||||
"Desktop User Guide": "デスクトップユーザーガイド",
|
||||
"Duplicate Current Workflow": "現在のワークフローを複製",
|
||||
@@ -753,13 +749,12 @@
|
||||
"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": "ワークフロー",
|
||||
|
||||
@@ -323,6 +323,7 @@
|
||||
"noTasksFoundMessage": "대기열에 작업이 없습니다.",
|
||||
"noWorkflowsFound": "워크플로를 찾을 수 없습니다.",
|
||||
"nodes": "노드",
|
||||
"nodesRunning": "노드 실행 중",
|
||||
"ok": "확인",
|
||||
"openNewIssue": "새 문제 열기",
|
||||
"overwrite": "덮어쓰기",
|
||||
@@ -551,11 +552,6 @@
|
||||
"uploadBackgroundImage": "배경 이미지 업로드",
|
||||
"uploadTexture": "텍스처 업로드"
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "ComfyUI {version} 이상 필요:",
|
||||
"outdatedVersion": "일부 노드는 더 최신 버전의 ComfyUI가 필요합니다 (현재: {version}). 모든 노드를 사용하려면 업데이트해 주세요.",
|
||||
"outdatedVersionGeneric": "일부 노드는 더 최신 버전의 ComfyUI가 필요합니다. 모든 노드를 사용하려면 업데이트해 주세요."
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "없음",
|
||||
"OK": "확인",
|
||||
@@ -591,7 +587,6 @@
|
||||
},
|
||||
"inWorkflow": "워크플로우 내",
|
||||
"infoPanelEmpty": "정보를 보려면 항목을 클릭하세요",
|
||||
"installAllMissingNodes": "모든 누락된 노드 설치",
|
||||
"installSelected": "선택한 항목 설치",
|
||||
"installationQueue": "설치 대기열",
|
||||
"lastUpdated": "마지막 업데이트",
|
||||
@@ -700,6 +695,7 @@
|
||||
"ComfyUI Issues": "ComfyUI 이슈 페이지",
|
||||
"Contact Support": "고객 지원 문의",
|
||||
"Convert selected nodes to group node": "선택한 노드를 그룹 노드로 변환",
|
||||
"Custom Nodes Manager": "사용자 정의 노드 관리자",
|
||||
"Delete Selected Items": "선택한 항목 삭제",
|
||||
"Desktop User Guide": "데스크톱 사용자 가이드",
|
||||
"Duplicate Current Workflow": "현재 워크플로 복제",
|
||||
@@ -753,13 +749,12 @@
|
||||
"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": "워크플로",
|
||||
|
||||
@@ -323,6 +323,7 @@
|
||||
"noTasksFoundMessage": "В очереди нет задач.",
|
||||
"noWorkflowsFound": "Рабочие процессы не найдены.",
|
||||
"nodes": "Узлы",
|
||||
"nodesRunning": "запущено узлов",
|
||||
"ok": "ОК",
|
||||
"openNewIssue": "Открыть новую проблему",
|
||||
"overwrite": "Перезаписать",
|
||||
@@ -551,11 +552,6 @@
|
||||
"uploadBackgroundImage": "Загрузить фоновое изображение",
|
||||
"uploadTexture": "Загрузить текстуру"
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "Требуется ComfyUI {version}:",
|
||||
"outdatedVersion": "Некоторые узлы требуют более новой версии ComfyUI (текущая: {version}). Пожалуйста, обновите, чтобы использовать все узлы.",
|
||||
"outdatedVersionGeneric": "Некоторые узлы требуют более новой версии ComfyUI. Пожалуйста, обновите, чтобы использовать все узлы."
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "Нет",
|
||||
"OK": "OK",
|
||||
@@ -591,7 +587,6 @@
|
||||
},
|
||||
"inWorkflow": "В рабочем процессе",
|
||||
"infoPanelEmpty": "Нажмите на элемент, чтобы увидеть информацию",
|
||||
"installAllMissingNodes": "Установить все отсутствующие узлы",
|
||||
"installSelected": "Установить выбранное",
|
||||
"installationQueue": "Очередь установки",
|
||||
"lastUpdated": "Последнее обновление",
|
||||
@@ -700,6 +695,7 @@
|
||||
"ComfyUI Issues": "Проблемы ComfyUI",
|
||||
"Contact Support": "Связаться с поддержкой",
|
||||
"Convert selected nodes to group node": "Преобразовать выбранные ноды в групповую ноду",
|
||||
"Custom Nodes Manager": "Менеджер Пользовательских Узлов",
|
||||
"Delete Selected Items": "Удалить выбранные элементы",
|
||||
"Desktop User Guide": "Руководство пользователя для настольных ПК",
|
||||
"Duplicate Current Workflow": "Дублировать текущий рабочий процесс",
|
||||
@@ -753,13 +749,12 @@
|
||||
"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": "Рабочий процесс",
|
||||
|
||||
@@ -323,6 +323,7 @@
|
||||
"noTasksFoundMessage": "队列中没有任务。",
|
||||
"noWorkflowsFound": "未找到工作流。",
|
||||
"nodes": "节点",
|
||||
"nodesRunning": "节点正在运行",
|
||||
"ok": "确定",
|
||||
"openNewIssue": "打开新问题",
|
||||
"overwrite": "覆盖",
|
||||
@@ -551,11 +552,6 @@
|
||||
"uploadBackgroundImage": "上传背景图片",
|
||||
"uploadTexture": "上传纹理"
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "需要 ComfyUI {version}:",
|
||||
"outdatedVersion": "某些节点需要更高版本的 ComfyUI(当前版本:{version})。请更新以使用所有节点。",
|
||||
"outdatedVersionGeneric": "某些节点需要更高版本的 ComfyUI。请更新以使用所有节点。"
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "无",
|
||||
"OK": "确定",
|
||||
@@ -591,7 +587,6 @@
|
||||
},
|
||||
"inWorkflow": "在工作流中",
|
||||
"infoPanelEmpty": "点击一个项目查看信息",
|
||||
"installAllMissingNodes": "安装所有缺失节点",
|
||||
"installSelected": "安装选定",
|
||||
"installationQueue": "安装队列",
|
||||
"lastUpdated": "最后更新",
|
||||
@@ -700,6 +695,7 @@
|
||||
"ComfyUI Issues": "ComfyUI 问题",
|
||||
"Contact Support": "联系支持",
|
||||
"Convert selected nodes to group node": "将选中节点转换为组节点",
|
||||
"Custom Nodes Manager": "自定义节点管理器",
|
||||
"Delete Selected Items": "删除选定的项目",
|
||||
"Desktop User Guide": "桌面端用户指南",
|
||||
"Duplicate Current Workflow": "复制当前工作流",
|
||||
@@ -753,13 +749,12 @@
|
||||
"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": "工作流",
|
||||
@@ -1045,7 +1040,7 @@
|
||||
"Graph": "画面",
|
||||
"Group": "组节点",
|
||||
"Keybinding": "快捷键",
|
||||
"Light": "光照",
|
||||
"Light": "浅色",
|
||||
"Link": "连线",
|
||||
"LinkRelease": "释放链接",
|
||||
"LiteGraph": "画面",
|
||||
|
||||
@@ -11,13 +11,10 @@ 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: resultItemType.optional()
|
||||
type: z.string().optional()
|
||||
})
|
||||
export type ResultItem = z.infer<typeof zResultItem>
|
||||
const zOutputs = z
|
||||
@@ -48,6 +45,22 @@ const zProgressWsMessage = z.object({
|
||||
node: zNodeId
|
||||
})
|
||||
|
||||
const zNodeProgressState = z.object({
|
||||
value: z.number(),
|
||||
max: z.number(),
|
||||
state: z.enum(['pending', 'running', 'finished', 'error']),
|
||||
node_id: zNodeId,
|
||||
prompt_id: zPromptId,
|
||||
display_node_id: zNodeId.optional(),
|
||||
parent_node_id: zNodeId.optional(),
|
||||
real_node_id: zNodeId.optional()
|
||||
})
|
||||
|
||||
const zProgressStateWsMessage = z.object({
|
||||
prompt_id: zPromptId,
|
||||
nodes: z.record(zNodeId, zNodeProgressState)
|
||||
})
|
||||
|
||||
const zExecutingWsMessage = z.object({
|
||||
node: zNodeId,
|
||||
display_node: zNodeId,
|
||||
@@ -132,6 +145,8 @@ export type ProgressTextWsMessage = z.infer<typeof zProgressTextWsMessage>
|
||||
export type DisplayComponentWsMessage = z.infer<
|
||||
typeof zDisplayComponentWsMessage
|
||||
>
|
||||
export type NodeProgressState = z.infer<typeof zNodeProgressState>
|
||||
export type ProgressStateWsMessage = z.infer<typeof zProgressStateWsMessage>
|
||||
// End of ws messages
|
||||
|
||||
const zPromptInputItem = z.object({
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
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('/')),
|
||||
@@ -74,7 +72,7 @@ export const zStringInputOptions = zBaseInputOptions.extend({
|
||||
export const zComboInputOptions = zBaseInputOptions.extend({
|
||||
control_after_generate: z.boolean().optional(),
|
||||
image_upload: z.boolean().optional(),
|
||||
image_folder: resultItemType.optional(),
|
||||
image_folder: z.enum(['input', 'output', 'temp']).optional(),
|
||||
allow_batch: z.boolean().optional(),
|
||||
video_upload: z.boolean().optional(),
|
||||
animated_image_upload: z.boolean().optional(),
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
LogsRawResponse,
|
||||
LogsWsMessage,
|
||||
PendingTaskItem,
|
||||
ProgressStateWsMessage,
|
||||
ProgressTextWsMessage,
|
||||
ProgressWsMessage,
|
||||
PromptResponse,
|
||||
@@ -103,7 +104,17 @@ interface BackendApiCalls {
|
||||
logs: LogsWsMessage
|
||||
/** Binary preview/progress data */
|
||||
b_preview: Blob
|
||||
/** Binary preview with metadata (node_id, prompt_id) */
|
||||
b_preview_with_metadata: {
|
||||
blob: Blob
|
||||
nodeId: string
|
||||
parentNodeId: string
|
||||
displayNodeId: string
|
||||
realNodeId: string
|
||||
promptId: string
|
||||
}
|
||||
progress_text: ProgressTextWsMessage
|
||||
progress_state: ProgressStateWsMessage
|
||||
display_component: DisplayComponentWsMessage
|
||||
}
|
||||
|
||||
@@ -432,6 +443,33 @@ export class ComfyApi extends EventTarget {
|
||||
})
|
||||
this.dispatchCustomEvent('b_preview', imageBlob)
|
||||
break
|
||||
case 4:
|
||||
// PREVIEW_IMAGE_WITH_METADATA
|
||||
const decoder4 = new TextDecoder()
|
||||
const metadataLength = view.getUint32(4)
|
||||
const metadataBytes = event.data.slice(8, 8 + metadataLength)
|
||||
const metadata = JSON.parse(decoder4.decode(metadataBytes))
|
||||
const imageData4 = event.data.slice(8 + metadataLength)
|
||||
|
||||
let imageMime4 = metadata.image_type
|
||||
|
||||
const imageBlob4 = new Blob([imageData4], {
|
||||
type: imageMime4
|
||||
})
|
||||
|
||||
// Dispatch enhanced preview event with metadata
|
||||
this.dispatchCustomEvent('b_preview_with_metadata', {
|
||||
blob: imageBlob4,
|
||||
nodeId: metadata.node_id,
|
||||
displayNodeId: metadata.display_node_id,
|
||||
parentNodeId: metadata.parent_node_id,
|
||||
realNodeId: metadata.real_node_id,
|
||||
promptId: metadata.prompt_id
|
||||
})
|
||||
|
||||
// Also dispatch legacy b_preview for backward compatibility
|
||||
this.dispatchCustomEvent('b_preview', imageBlob4)
|
||||
break
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown binary websocket message of type ${eventType}`
|
||||
@@ -461,6 +499,7 @@ export class ComfyApi extends EventTarget {
|
||||
case 'execution_cached':
|
||||
case 'execution_success':
|
||||
case 'progress':
|
||||
case 'progress_state':
|
||||
case 'executed':
|
||||
case 'graphChanged':
|
||||
case 'promptQueued':
|
||||
|
||||
@@ -193,6 +193,8 @@ export class ComfyApp {
|
||||
|
||||
/**
|
||||
* @deprecated Use useExecutionStore().executingNodeId instead
|
||||
* TODO: Update to support multiple executing nodes. This getter returns only the first executing node.
|
||||
* Consider updating consumers to handle multiple nodes or use executingNodeIds array.
|
||||
*/
|
||||
get runningNodeId(): NodeId | null {
|
||||
return useExecutionStore().executingNodeId
|
||||
@@ -634,10 +636,6 @@ export class ComfyApp {
|
||||
|
||||
api.addEventListener('executing', () => {
|
||||
this.graph.setDirtyCanvas(true, false)
|
||||
// @ts-expect-error fixme ts strict error
|
||||
this.revokePreviews(this.runningNodeId)
|
||||
// @ts-expect-error fixme ts strict error
|
||||
delete this.nodePreviewImages[this.runningNodeId]
|
||||
})
|
||||
|
||||
api.addEventListener('executed', ({ detail }) => {
|
||||
@@ -686,15 +684,13 @@ export class ComfyApp {
|
||||
this.canvas.draw(true, true)
|
||||
})
|
||||
|
||||
api.addEventListener('b_preview', ({ detail }) => {
|
||||
const id = this.runningNodeId
|
||||
if (id == null) return
|
||||
|
||||
const blob = detail
|
||||
api.addEventListener('b_preview_with_metadata', ({ detail }) => {
|
||||
// Enhanced preview with explicit node context
|
||||
const { blob, displayNodeId } = detail
|
||||
this.revokePreviews(displayNodeId)
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
// Ensure clean up if `executing` event is missed.
|
||||
this.revokePreviews(id)
|
||||
this.nodePreviewImages[id] = [blobUrl]
|
||||
// Preview cleanup is now handled in progress_state event to support multiple concurrent previews
|
||||
this.nodePreviewImages[displayNodeId] = [blobUrl]
|
||||
})
|
||||
|
||||
api.init()
|
||||
|
||||
@@ -237,6 +237,7 @@ export class ComponentWidgetImpl<
|
||||
component: Component
|
||||
inputSpec: InputSpec
|
||||
props?: P
|
||||
componentProps?: Record<string, unknown>
|
||||
options: DOMWidgetOptions<V>
|
||||
}) {
|
||||
super({
|
||||
@@ -245,7 +246,9 @@ export class ComponentWidgetImpl<
|
||||
})
|
||||
this.component = obj.component
|
||||
this.inputSpec = obj.inputSpec
|
||||
this.props = obj.props
|
||||
this.props = obj.componentProps
|
||||
? ({ ...obj.props, ...obj.componentProps } as P)
|
||||
: obj.props
|
||||
}
|
||||
|
||||
override computeLayoutSize() {
|
||||
|
||||
183
src/services/algoliaSearchService.ts
Normal file
183
src/services/algoliaSearchService.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
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 { omit } from 'lodash'
|
||||
|
||||
import type {
|
||||
AlgoliaNodePack,
|
||||
NodesIndexSuggestion,
|
||||
SearchAttribute,
|
||||
SearchNodePacksParams,
|
||||
SearchPacksResult
|
||||
} from '@/types/algoliaTypes'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { paramsToCacheKey } from '@/utils/formatUtil'
|
||||
|
||||
type RegistryNodePack = components['schemas']['Node']
|
||||
|
||||
const DEFAULT_MAX_CACHE_SIZE = 64
|
||||
const DEFAULT_MIN_CHARS_FOR_SUGGESTIONS = 2
|
||||
|
||||
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'
|
||||
]
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ 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'
|
||||
@@ -128,14 +129,16 @@ export const useDialogService = () => {
|
||||
}
|
||||
|
||||
function showManagerDialog(
|
||||
props: InstanceType<typeof ManagerDialogContent>['$props'] = {}
|
||||
props: InstanceType<typeof ManagerDialogContent>['$props'] = {
|
||||
initialTab: ManagerTab.All
|
||||
}
|
||||
) {
|
||||
dialogStore.showDialog({
|
||||
key: 'global-manager',
|
||||
component: ManagerDialogContent,
|
||||
headerComponent: ManagerHeader,
|
||||
dialogComponentProps: {
|
||||
closable: true,
|
||||
closable: false,
|
||||
pt: {
|
||||
header: { class: '!p-0 !m-0' },
|
||||
content: { class: '!px-0 h-[83vh] w-[90vw] overflow-y-hidden' }
|
||||
@@ -154,7 +157,6 @@ export const useDialogService = () => {
|
||||
headerComponent: ManagerProgressHeader,
|
||||
footerComponent: ManagerProgressFooter,
|
||||
props: options?.props,
|
||||
priority: 2,
|
||||
dialogComponentProps: {
|
||||
closable: false,
|
||||
modal: false,
|
||||
@@ -395,26 +397,6 @@ 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,
|
||||
@@ -432,8 +414,6 @@ export const useDialogService = () => {
|
||||
showUpdatePasswordDialog,
|
||||
showExtensionDialog,
|
||||
prompt,
|
||||
confirm,
|
||||
toggleManagerDialog,
|
||||
toggleManagerProgressDialog
|
||||
confirm
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
|
||||
import { ComfyApp, app } from '@/scripts/app'
|
||||
import { $el } from '@/scripts/ui'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
@@ -96,7 +97,12 @@ export const useLitegraphService = () => {
|
||||
*/
|
||||
#setupStrokeStyles() {
|
||||
this.strokeStyles['running'] = function (this: LGraphNode) {
|
||||
if (this.id == app.runningNodeId) {
|
||||
const nodeProgressStates = useExecutionStore().nodeProgressStates
|
||||
const nodeId = String(this.id)
|
||||
if (
|
||||
nodeProgressStates[nodeId] &&
|
||||
nodeProgressStates[nodeId].state === 'running'
|
||||
) {
|
||||
return { color: '#0f0' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
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 => {
|
||||
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,
|
||||
// @ts-expect-error comfy_nodes also not in node info
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
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,
|
||||
offset: pageNumber * pageSize
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,6 @@ interface DialogInstance {
|
||||
contentProps: Record<string, any>
|
||||
footerComponent?: Component
|
||||
dialogComponentProps: DialogComponentProps
|
||||
priority: number
|
||||
}
|
||||
|
||||
export interface ShowDialogOptions {
|
||||
@@ -51,12 +50,6 @@ 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', () => {
|
||||
@@ -64,29 +57,13 @@ 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 [dialog] = dialogStack.value.splice(index, 1)
|
||||
insertDialogByPriority(dialog)
|
||||
const dialogs = dialogStack.value.splice(index, 1)
|
||||
dialogStack.value.push(...dialogs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,13 +85,12 @@ 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: DialogInstance = {
|
||||
const dialog = {
|
||||
key: options.key,
|
||||
visible: true,
|
||||
title: options.title,
|
||||
@@ -126,7 +102,6 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
: undefined,
|
||||
component: markRaw(options.component),
|
||||
contentProps: { ...options.props },
|
||||
priority: options.priority ?? 1,
|
||||
dialogComponentProps: {
|
||||
maximizable: false,
|
||||
modal: true,
|
||||
@@ -135,7 +110,6 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
dismissableMask: true,
|
||||
...options.dialogComponentProps,
|
||||
maximized: false,
|
||||
// @ts-expect-error TODO: fix this
|
||||
onMaximize: () => {
|
||||
dialog.dialogComponentProps.maximized = true
|
||||
},
|
||||
@@ -154,8 +128,7 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
insertDialogByPriority(dialog)
|
||||
dialogStack.value.push(dialog)
|
||||
|
||||
return dialog
|
||||
}
|
||||
@@ -196,16 +169,11 @@ 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,
|
||||
isDialogOpen
|
||||
showExtensionDialog
|
||||
}
|
||||
})
|
||||
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
ExecutionErrorWsMessage,
|
||||
ExecutionStartWsMessage,
|
||||
NodeError,
|
||||
NodeProgressState,
|
||||
ProgressStateWsMessage,
|
||||
ProgressTextWsMessage,
|
||||
ProgressWsMessage
|
||||
} from '@/schemas/apiSchema'
|
||||
@@ -42,7 +44,22 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
const queuedPrompts = ref<Record<NodeId, QueuedPrompt>>({})
|
||||
const lastNodeErrors = ref<Record<NodeId, NodeError> | null>(null)
|
||||
const lastExecutionError = ref<ExecutionErrorWsMessage | null>(null)
|
||||
const executingNodeId = ref<NodeId | null>(null)
|
||||
// This is the progress of all nodes in the currently executing workflow
|
||||
const nodeProgressStates = ref<Record<string, NodeProgressState>>({})
|
||||
|
||||
// Easily access all currently executing node IDs
|
||||
const executingNodeIds = computed<NodeId[]>(() => {
|
||||
return Object.entries(nodeProgressStates)
|
||||
.filter(([_, state]) => state.state === 'running')
|
||||
.map(([nodeId, _]) => nodeId)
|
||||
})
|
||||
|
||||
// For backward compatibility - stores the primary executing node ID
|
||||
const executingNodeId = computed<NodeId | null>(() => {
|
||||
return executingNodeIds.value.length > 0 ? executingNodeIds.value[0] : null
|
||||
})
|
||||
|
||||
// For backward compatibility - returns the primary executing node
|
||||
const executingNode = computed<ComfyNode | null>(() => {
|
||||
if (!executingNodeId.value) return null
|
||||
|
||||
@@ -60,7 +77,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// This is the progress of the currently executing node, if any
|
||||
// This is the progress of the currently executing node (for backward compatibility)
|
||||
const _executingNodeProgress = ref<ProgressWsMessage | null>(null)
|
||||
const executingNodeProgress = computed(() =>
|
||||
_executingNodeProgress.value
|
||||
@@ -88,7 +105,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
if (!activePrompt.value) return 0
|
||||
const total = totalNodesToExecute.value
|
||||
const done = nodesExecuted.value
|
||||
return total > 0 ? done / total : 0
|
||||
return done / total
|
||||
})
|
||||
|
||||
function bindExecutionEvents() {
|
||||
@@ -97,6 +114,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
api.addEventListener('executed', handleExecuted)
|
||||
api.addEventListener('executing', handleExecuting)
|
||||
api.addEventListener('progress', handleProgress)
|
||||
api.addEventListener('progress_state', handleProgressState)
|
||||
api.addEventListener('status', handleStatus)
|
||||
api.addEventListener('execution_error', handleExecutionError)
|
||||
}
|
||||
@@ -109,6 +127,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
api.removeEventListener('executed', handleExecuted)
|
||||
api.removeEventListener('executing', handleExecuting)
|
||||
api.removeEventListener('progress', handleProgress)
|
||||
api.removeEventListener('progress_state', handleProgressState)
|
||||
api.removeEventListener('status', handleStatus)
|
||||
api.removeEventListener('execution_error', handleExecutionError)
|
||||
api.removeEventListener('progress_text', handleProgressText)
|
||||
@@ -138,12 +157,8 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
|
||||
if (!activePrompt.value) return
|
||||
|
||||
if (executingNodeId.value && activePrompt.value) {
|
||||
// Seems sometimes nodes that are cached fire executing but not executed
|
||||
activePrompt.value.nodes[executingNodeId.value] = true
|
||||
}
|
||||
executingNodeId.value = e.detail
|
||||
if (executingNodeId.value === null) {
|
||||
// Update the executing nodes list
|
||||
if (e.detail === null) {
|
||||
if (activePromptId.value) {
|
||||
delete queuedPrompts.value[activePromptId.value]
|
||||
}
|
||||
@@ -151,6 +166,37 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function handleProgressState(e: CustomEvent<ProgressStateWsMessage>) {
|
||||
const { nodes } = e.detail
|
||||
|
||||
// Revoke previews for nodes that are starting to execute
|
||||
for (const nodeId in nodes) {
|
||||
const nodeState = nodes[nodeId]
|
||||
if (nodeState.state === 'running' && !nodeProgressStates.value[nodeId]) {
|
||||
// This node just started executing, revoke its previews
|
||||
// Note that we're doing the *actual* node id instead of the display node id
|
||||
// here intentionally. That way, we don't clear the preview every time a new node
|
||||
// within an expanded graph starts executing.
|
||||
app.revokePreviews(nodeId)
|
||||
delete app.nodePreviewImages[nodeId]
|
||||
}
|
||||
}
|
||||
|
||||
// Update the progress states for all nodes
|
||||
nodeProgressStates.value = nodes
|
||||
|
||||
// If we have progress for the currently executing node, update it for backwards compatibility
|
||||
if (executingNodeId.value && nodes[executingNodeId.value]) {
|
||||
const nodeState = nodes[executingNodeId.value]
|
||||
_executingNodeProgress.value = {
|
||||
value: nodeState.value,
|
||||
max: nodeState.max,
|
||||
prompt_id: nodeState.prompt_id,
|
||||
node: nodeState.display_node_id || nodeState.node_id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleProgress(e: CustomEvent<ProgressWsMessage>) {
|
||||
_executingNodeProgress.value = e.detail
|
||||
}
|
||||
@@ -238,9 +284,13 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
*/
|
||||
lastExecutionError,
|
||||
/**
|
||||
* The id of the node that is currently being executed
|
||||
* The id of the node that is currently being executed (backward compatibility)
|
||||
*/
|
||||
executingNodeId,
|
||||
/**
|
||||
* The list of all nodes that are currently executing
|
||||
*/
|
||||
executingNodeIds,
|
||||
/**
|
||||
* The prompt that is currently being executed
|
||||
*/
|
||||
@@ -258,13 +308,17 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
*/
|
||||
executionProgress,
|
||||
/**
|
||||
* The node that is currently being executed
|
||||
* The node that is currently being executed (backward compatibility)
|
||||
*/
|
||||
executingNode,
|
||||
/**
|
||||
* The progress of the executing node (if the node reports progress)
|
||||
* The progress of the executing node (backward compatibility)
|
||||
*/
|
||||
executingNodeProgress,
|
||||
/**
|
||||
* All node progress states from progress_state events
|
||||
*/
|
||||
nodeProgressStates,
|
||||
bindExecutionEvents,
|
||||
unbindExecutionEvents,
|
||||
storePrompt,
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import {
|
||||
ExecutedWsMessage,
|
||||
ResultItem,
|
||||
ResultItemType
|
||||
} from '@/schemas/apiSchema'
|
||||
import { ExecutedWsMessage, ResultItem } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { parseFilePath } from '@/utils/formatUtil'
|
||||
@@ -13,7 +9,7 @@ import { isVideoNode } from '@/utils/litegraphUtil'
|
||||
|
||||
const createOutputs = (
|
||||
filenames: string[],
|
||||
type: ResultItemType,
|
||||
type: string,
|
||||
isAnimated: boolean
|
||||
): ExecutedWsMessage['output'] => {
|
||||
return {
|
||||
@@ -92,7 +88,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
{
|
||||
folder = 'input',
|
||||
isAnimated = false
|
||||
}: { folder?: ResultItemType; isAnimated?: boolean } = {}
|
||||
}: { folder?: string; isAnimated?: boolean } = {}
|
||||
) {
|
||||
if (!filenames || !node) return
|
||||
|
||||
|
||||
@@ -12,20 +12,11 @@ type SafeNestedProperty<
|
||||
> = 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']
|
||||
@@ -59,25 +50,9 @@ export interface AlgoliaNodePack {
|
||||
'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: {
|
||||
@@ -92,11 +67,8 @@ export interface NodesIndexSuggestion {
|
||||
query: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for searching the Algolia index.
|
||||
*/
|
||||
export type SearchNodePacksParams = BaseSearchParamsWithoutQuery & {
|
||||
pageSize: number
|
||||
pageNumber: number
|
||||
restrictSearchableAttributes?: SearchAttribute[]
|
||||
restrictSearchableAttributes: SearchAttribute[]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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 WorkflowNodeProperties = ComfyWorkflowJSON['nodes'][0]['properties']
|
||||
|
||||
@@ -40,7 +39,7 @@ export enum SortableAlgoliaField {
|
||||
}
|
||||
|
||||
export interface TabItem {
|
||||
id: ManagerTab
|
||||
id: string
|
||||
label: string
|
||||
icon: string
|
||||
}
|
||||
@@ -235,10 +234,3 @@ export interface InstallPackParams extends ManagerPackInfo {
|
||||
export interface UpdateAllPacksParams {
|
||||
mode?: ManagerDatabaseSource
|
||||
}
|
||||
|
||||
export interface ManagerState {
|
||||
selectedTabId: ManagerTab
|
||||
searchQuery: string
|
||||
searchMode: SearchMode
|
||||
sortField: string
|
||||
}
|
||||
|
||||
@@ -896,23 +896,6 @@ 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
|
||||
@@ -1095,30 +1078,6 @@ 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
|
||||
@@ -3248,13 +3207,6 @@ 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
|
||||
@@ -3393,8 +3345,6 @@ 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 */
|
||||
@@ -8742,71 +8692,6 @@ export interface components {
|
||||
MoonvalleyUploadResponse: {
|
||||
access_url?: string
|
||||
}
|
||||
GithubReleaseWebhook: {
|
||||
/** @description The action performed on the release */
|
||||
action: string
|
||||
release: {
|
||||
/** @description The tag name of the release */
|
||||
tag_name: string
|
||||
/** @description The name of the release */
|
||||
name: string
|
||||
/** @description The release notes/body */
|
||||
body: string
|
||||
/** @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
|
||||
/** @description URL to the tarball */
|
||||
tarball_url?: string
|
||||
/** @description URL to the zipball */
|
||||
zipball_url?: string
|
||||
/** @description The branch or commit the release was created from */
|
||||
target_commitish: string
|
||||
}
|
||||
repository: {
|
||||
/** @description The name of the repository */
|
||||
name: string
|
||||
/** @description The full name of the repository (owner/repo) */
|
||||
full_name: string
|
||||
/** @description The HTML URL of the repository */
|
||||
html_url: string
|
||||
/** @description The clone URL of the repository */
|
||||
clone_url: 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: {
|
||||
@@ -10910,6 +10795,8 @@ 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
|
||||
@@ -10944,52 +10831,6 @@ 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?: {
|
||||
@@ -11577,89 +11418,6 @@ 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
|
||||
}
|
||||
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?: never
|
||||
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 Internal server error */
|
||||
500: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'application/json': components['schemas']['ErrorResponse']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
securityScan: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* 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'
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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[]
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -3,12 +3,20 @@ import { nextTick, reactive } from 'vue'
|
||||
|
||||
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
|
||||
|
||||
// Mock i18n module
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string, fallback: string) =>
|
||||
key === 'g.nodesRunning' ? 'nodes running' : fallback
|
||||
}))
|
||||
|
||||
// Mock the execution store
|
||||
const executionStore = reactive({
|
||||
isIdle: true,
|
||||
executionProgress: 0,
|
||||
executingNode: null as any,
|
||||
executingNodeProgress: 0
|
||||
executingNodeProgress: 0,
|
||||
nodeProgressStates: {} as any,
|
||||
activePrompt: null as any
|
||||
})
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => executionStore
|
||||
@@ -37,6 +45,8 @@ describe('useBrowserTabTitle', () => {
|
||||
executionStore.executionProgress = 0
|
||||
executionStore.executingNode = null as any
|
||||
executionStore.executingNodeProgress = 0
|
||||
executionStore.nodeProgressStates = {}
|
||||
executionStore.activePrompt = null
|
||||
|
||||
// reset setting and workflow stores
|
||||
;(settingStore.get as any).mockReturnValue('Enabled')
|
||||
@@ -97,13 +107,41 @@ describe('useBrowserTabTitle', () => {
|
||||
expect(document.title).toBe('[30%]ComfyUI')
|
||||
})
|
||||
|
||||
it('shows node execution title when executing a node', async () => {
|
||||
it('shows node execution title when executing a node using nodeProgressStates', async () => {
|
||||
executionStore.isIdle = false
|
||||
executionStore.executionProgress = 0.4
|
||||
executionStore.executingNodeProgress = 0.5
|
||||
executionStore.executingNode = { type: 'Foo' }
|
||||
executionStore.nodeProgressStates = {
|
||||
'1': { state: 'running', value: 5, max: 10, node: '1', prompt_id: 'test' }
|
||||
}
|
||||
executionStore.activePrompt = {
|
||||
workflow: {
|
||||
changeTracker: {
|
||||
activeState: {
|
||||
nodes: [{ id: 1, type: 'Foo' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
useBrowserTabTitle()
|
||||
await nextTick()
|
||||
expect(document.title).toBe('[40%][50%] Foo')
|
||||
})
|
||||
|
||||
it('shows multiple nodes running when multiple nodes are executing', async () => {
|
||||
executionStore.isIdle = false
|
||||
executionStore.executionProgress = 0.4
|
||||
executionStore.nodeProgressStates = {
|
||||
'1': {
|
||||
state: 'running',
|
||||
value: 5,
|
||||
max: 10,
|
||||
node: '1',
|
||||
prompt_id: 'test'
|
||||
},
|
||||
'2': { state: 'running', value: 8, max: 10, node: '2', prompt_id: 'test' }
|
||||
}
|
||||
useBrowserTabTitle()
|
||||
await nextTick()
|
||||
expect(document.title).toBe('[40%][2 nodes running]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,385 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,374 +0,0 @@
|
||||
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])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,445 +0,0 @@
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,186 +0,0 @@
|
||||
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,
|
||||
offset: 0
|
||||
})
|
||||
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,
|
||||
offset: 20
|
||||
})
|
||||
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' }
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,175 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -7,10 +7,7 @@ export default defineConfig({
|
||||
globals: true,
|
||||
environment: 'happy-dom',
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
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}'
|
||||
],
|
||||
include: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
coverage: {
|
||||
reporter: ['text', 'json', 'html']
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user