merge main into rh-test

This commit is contained in:
bymyself
2025-09-28 15:33:29 -07:00
parent 1c0f151d02
commit ff0c15b119
1317 changed files with 85439 additions and 18373 deletions

View File

@@ -0,0 +1,186 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import Panel from 'primevue/panel'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import ManagerProgressDialogContent from './ManagerProgressDialogContent.vue'
type ComponentInstance = InstanceType<typeof ManagerProgressDialogContent> & {
lastPanelRef: HTMLElement | null
onLogsAdded: () => void
handleScroll: (e: { target: HTMLElement }) => void
isUserScrolling: boolean
resetUserScrolling: () => void
collapsedPanels: Record<number, boolean>
togglePanel: (index: number) => void
}
const mockCollapse = vi.fn()
const defaultMockTaskLogs = [
{ taskName: 'Task 1', logs: ['Log 1', 'Log 2'] },
{ taskName: 'Task 2', logs: ['Log 3', 'Log 4'] }
]
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
taskLogs: [...defaultMockTaskLogs],
succeededTasksLogs: [...defaultMockTaskLogs],
failedTasksLogs: [...defaultMockTaskLogs],
managerQueue: { historyCount: 2 },
isLoading: false
})),
useManagerProgressDialogStore: vi.fn(() => ({
isExpanded: true,
activeTabIndex: 0,
getActiveTabIndex: vi.fn(() => 0),
setActiveTabIndex: vi.fn(),
toggle: vi.fn(),
collapse: mockCollapse,
expand: vi.fn()
}))
}))
describe('ManagerProgressDialogContent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCollapse.mockReset()
})
const mountComponent = ({
props = {}
}: Record<string, any> = {}): VueWrapper<ComponentInstance> => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(ManagerProgressDialogContent, {
props: {
...props
},
global: {
plugins: [PrimeVue, createPinia(), i18n],
components: {
Panel,
Button
}
}
}) as VueWrapper<ComponentInstance>
}
it('renders the correct number of panels', async () => {
const wrapper = mountComponent()
await nextTick()
expect(wrapper.findAllComponents(Panel).length).toBe(2)
})
it('expands the last panel by default', async () => {
const wrapper = mountComponent()
await nextTick()
expect(wrapper.vm.collapsedPanels[1]).toBeFalsy()
})
it('toggles panel expansion when toggle method is called', async () => {
const wrapper = mountComponent()
await nextTick()
// Initial state - first panel should be collapsed
expect(wrapper.vm.collapsedPanels[0]).toBeFalsy()
wrapper.vm.togglePanel(0)
await nextTick()
// After toggle - first panel should be expanded
expect(wrapper.vm.collapsedPanels[0]).toBe(true)
wrapper.vm.togglePanel(0)
await nextTick()
expect(wrapper.vm.collapsedPanels[0]).toBeFalsy()
})
it('displays the correct status for each panel', async () => {
const wrapper = mountComponent()
await nextTick()
// Expand all panels to see status text
const panels = wrapper.findAllComponents(Panel)
for (let i = 0; i < panels.length; i++) {
if (!wrapper.vm.collapsedPanels[i]) {
wrapper.vm.togglePanel(i)
await nextTick()
}
}
const panelsText = wrapper
.findAllComponents(Panel)
.map((panel) => panel.text())
expect(panelsText[0]).toContain('Completed ✓')
expect(panelsText[1]).toContain('Completed ✓')
})
it('auto-scrolls to bottom when new logs are added', async () => {
const wrapper = mountComponent()
await nextTick()
const mockScrollElement = document.createElement('div')
Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 200 })
Object.defineProperty(mockScrollElement, 'clientHeight', { value: 100 })
Object.defineProperty(mockScrollElement, 'scrollTop', {
value: 0,
writable: true
})
wrapper.vm.lastPanelRef = mockScrollElement
wrapper.vm.onLogsAdded()
await nextTick()
// Check if scrollTop is set to scrollHeight (scrolled to bottom)
expect(mockScrollElement.scrollTop).toBe(200)
})
it('does not auto-scroll when user is manually scrolling', async () => {
const wrapper = mountComponent()
await nextTick()
const mockScrollElement = document.createElement('div')
Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 200 })
Object.defineProperty(mockScrollElement, 'clientHeight', { value: 100 })
Object.defineProperty(mockScrollElement, 'scrollTop', {
value: 50,
writable: true
})
wrapper.vm.lastPanelRef = mockScrollElement
wrapper.vm.handleScroll({ target: mockScrollElement })
await nextTick()
expect(wrapper.vm.isUserScrolling).toBe(true)
// Now trigger the log update
wrapper.vm.onLogsAdded()
await nextTick()
// Check that scrollTop is not changed (should still be 50)
expect(mockScrollElement.scrollTop).toBe(50)
})
it('calls collapse method when component is unmounted', async () => {
const wrapper = mountComponent()
await nextTick()
wrapper.unmount()
expect(mockCollapse).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,179 @@
<template>
<div
class="overflow-hidden transition-all duration-300"
:class="{
'max-h-[500px]': isExpanded,
'max-h-0 p-0 m-0': !isExpanded
}"
>
<div
ref="sectionsContainerRef"
class="px-6 py-4 overflow-y-auto max-h-[450px] scroll-container"
:style="{
scrollbarWidth: 'thin',
scrollbarColor: 'rgba(156, 163, 175, 0.5) transparent'
}"
:class="{
'max-h-[450px]': isExpanded,
'max-h-0': !isExpanded
}"
>
<div v-for="(log, index) in focusedLogs" :key="index">
<Panel
:expanded="collapsedPanels[index] === true"
toggleable
class="shadow-elevation-1 rounded-lg mt-2 dark-theme:bg-black dark-theme:border-black"
>
<template #header>
<div class="flex items-center justify-between w-full py-2">
<div class="flex flex-col text-sm font-medium leading-normal">
<span>{{ log.taskName }}</span>
<span class="text-muted">
{{
isInProgress(index)
? $t('g.inProgress')
: $t('g.completed') + ' ✓'
}}
</span>
</div>
</div>
</template>
<template #toggleicon>
<Button
:icon="
collapsedPanels[index]
? 'pi pi-chevron-right'
: 'pi pi-chevron-down'
"
text
class="text-neutral-300"
@click="togglePanel(index)"
/>
</template>
<div
:ref="
index === focusedLogs.length - 1
? (el) => (lastPanelRef = el as HTMLElement)
: undefined
"
class="overflow-y-auto h-64 rounded-lg bg-black"
:class="{
'h-64': index !== focusedLogs.length - 1,
grow: index === focusedLogs.length - 1
}"
@scroll="handleScroll"
>
<div class="h-full">
<div
v-for="(logLine, logIndex) in log.logs"
:key="logIndex"
class="text-neutral-400 dark-theme:text-muted"
>
<pre class="whitespace-pre-wrap break-words">{{ logLine }}</pre>
</div>
</div>
</div>
</Panel>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useScroll, whenever } from '@vueuse/core'
import Button from 'primevue/button'
import Panel from 'primevue/panel'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/workbench/extensions/manager/stores/comfyManagerStore'
const comfyManagerStore = useComfyManagerStore()
const progressDialogContent = useManagerProgressDialogStore()
const isInProgress = (index: number) => {
const log = focusedLogs.value[index]
if (!log) return false
// Check if this task is in the running or pending queue
const taskQueue = comfyManagerStore.taskQueue
if (!taskQueue) return false
const allQueueTasks = [
...(taskQueue.running_queue || []),
...(taskQueue.pending_queue || [])
]
return allQueueTasks.some((task) => task.ui_id === log.taskId)
}
const focusedLogs = computed(() => {
if (progressDialogContent.getActiveTabIndex() === 0) {
return comfyManagerStore.succeededTasksLogs
}
return comfyManagerStore.failedTasksLogs
})
const isExpanded = computed(() => progressDialogContent.isExpanded)
const isCollapsed = computed(() => !isExpanded.value)
const collapsedPanels = ref<Record<number, boolean>>({})
const togglePanel = (index: number) => {
collapsedPanels.value[index] = !collapsedPanels.value[index]
}
const sectionsContainerRef = ref<HTMLElement | null>(null)
const { y: scrollY } = useScroll(sectionsContainerRef, {
eventListenerOptions: {
passive: true
}
})
const lastPanelRef = ref<HTMLElement | null>(null)
const isUserScrolling = ref(false)
const lastPanelLogs = computed(() => focusedLogs.value?.at(-1)?.logs)
const isAtBottom = (el: HTMLElement | null) => {
if (!el) return false
const threshold = 20
return Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < threshold
}
const scrollLastPanelToBottom = () => {
if (!lastPanelRef.value || isUserScrolling.value) return
lastPanelRef.value.scrollTop = lastPanelRef.value.scrollHeight
}
const scrollContentToBottom = () => {
scrollY.value = sectionsContainerRef.value?.scrollHeight ?? 0
}
const resetUserScrolling = () => {
isUserScrolling.value = false
}
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement
if (target !== lastPanelRef.value) return
isUserScrolling.value = !isAtBottom(target)
}
const onLogsAdded = () => {
// If user is scrolling manually, don't automatically scroll to bottom
if (isUserScrolling.value) return
scrollLastPanelToBottom()
}
whenever(lastPanelLogs, onLogsAdded, { flush: 'post', deep: true })
whenever(() => isExpanded.value, scrollContentToBottom)
whenever(isCollapsed, resetUserScrolling)
onMounted(() => {
scrollContentToBottom()
})
onBeforeUnmount(() => {
progressDialogContent.collapse()
})
</script>

View File

@@ -0,0 +1,190 @@
<template>
<div
class="w-full px-6 py-2 shadow-lg flex items-center justify-between"
:class="{
'rounded-t-none': progressDialogContent.isExpanded,
'rounded-lg': !progressDialogContent.isExpanded
}"
>
<div class="flex items-center text-base leading-none">
<div class="flex items-center">
<template v-if="isInProgress">
<DotSpinner duration="1s" class="mr-2" />
<span>{{ currentTaskName }}</span>
</template>
<template v-else-if="isRestartCompleted">
<span class="mr-2">🎉</span>
<span>{{ currentTaskName }}</span>
</template>
<template v-else>
<span class="mr-2"></span>
<span>{{ $t('manager.restartToApplyChanges') }}</span>
</template>
</div>
</div>
<div class="flex items-center gap-4">
<span v-if="isInProgress" class="text-sm text-neutral-700">
{{ completedTasksCount }} {{ $t('g.progressCountOf') }}
{{ totalTasksCount }}
</span>
<div class="flex items-center">
<Button
v-if="!isInProgress && !isRestartCompleted"
rounded
outlined
class="mr-4 rounded-md border-2 px-3 text-neutral-600 border-neutral-900 hover:bg-neutral-100 !dark-theme:bg-transparent dark-theme:text-white dark-theme:border-white dark-theme:hover:bg-neutral-800"
@click="handleRestart"
>
{{ $t('manager.applyChanges') }}
</Button>
<Button
v-else-if="!isRestartCompleted"
:icon="
progressDialogContent.isExpanded
? 'pi pi-chevron-up'
: 'pi pi-chevron-down'
"
text
rounded
size="small"
class="font-bold"
severity="secondary"
:aria-label="progressDialogContent.isExpanded ? 'Collapse' : 'Expand'"
@click.stop="progressDialogContent.toggle"
/>
<Button
icon="pi pi-times"
text
rounded
size="small"
class="font-bold"
severity="secondary"
aria-label="Close"
@click.stop="closeDialog"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import Button from 'primevue/button'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { api } from '@/scripts/api'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/workbench/extensions/manager/stores/comfyManagerStore'
const { t } = useI18n()
const dialogStore = useDialogStore()
const progressDialogContent = useManagerProgressDialogStore()
const comfyManagerStore = useComfyManagerStore()
const settingStore = useSettingStore()
const { runFullConflictAnalysis } = useConflictDetection()
// State management for restart process
const isRestarting = ref<boolean>(false)
const isRestartCompleted = ref<boolean>(false)
const isInProgress = computed(
() => comfyManagerStore.isProcessingTasks || isRestarting.value
)
const completedTasksCount = computed(() => {
return (
comfyManagerStore.succeededTasksIds.length +
comfyManagerStore.failedTasksIds.length
)
})
const totalTasksCount = computed(() => {
const completedTasks = Object.keys(comfyManagerStore.taskHistory).length
const taskQueue = comfyManagerStore.taskQueue
const queuedTasks = taskQueue
? (taskQueue.running_queue?.length || 0) +
(taskQueue.pending_queue?.length || 0)
: 0
return completedTasks + queuedTasks
})
const closeDialog = () => {
dialogStore.closeDialog({ key: 'global-manager-progress-dialog' })
}
const fallbackTaskName = t('manager.installingDependencies')
const currentTaskName = computed(() => {
if (isRestarting.value) {
return t('manager.restartingBackend')
}
if (isRestartCompleted.value) {
return t('manager.extensionsSuccessfullyInstalled')
}
if (!comfyManagerStore.taskLogs.length) return fallbackTaskName
const task = comfyManagerStore.taskLogs.at(-1)
return task?.taskName ?? fallbackTaskName
})
const handleRestart = async () => {
// Store original toast setting value
const originalToastSetting = settingStore.get(
'Comfy.Toast.DisableReconnectingToast'
)
try {
await settingStore.set('Comfy.Toast.DisableReconnectingToast', true)
isRestarting.value = true
const onReconnect = async () => {
try {
comfyManagerStore.setStale()
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
await useWorkflowService().reloadCurrentWorkflow()
// Run conflict detection in background after restart completion
void runFullConflictAnalysis()
} finally {
await settingStore.set(
'Comfy.Toast.DisableReconnectingToast',
originalToastSetting
)
isRestarting.value = false
isRestartCompleted.value = true
setTimeout(() => {
closeDialog()
comfyManagerStore.resetTaskState()
}, 3000)
}
}
useEventListener(api, 'reconnected', onReconnect, { once: true })
await useComfyManagerService().rebootComfyUI()
} catch (error) {
// If restart fails, restore settings and reset state
await settingStore.set(
'Comfy.Toast.DisableReconnectingToast',
originalToastSetting
)
isRestarting.value = false
isRestartCompleted.value = false
closeDialog() // Close dialog on error
throw error
}
}
</script>

View File

@@ -0,0 +1,44 @@
<template>
<div
v-if="progressDialogContent.isExpanded"
class="px-4 py-2 flex items-center"
>
<TabMenu
v-model:active-index="activeTabIndex"
:model="tabs"
class="w-full border-none"
:pt="{
menu: { class: 'border-none' },
menuitem: { class: 'font-medium' },
action: { class: 'px-4 py-2' }
}"
/>
</div>
</template>
<script setup lang="ts">
import TabMenu from 'primevue/tabmenu'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/workbench/extensions/manager/stores/comfyManagerStore'
const progressDialogContent = useManagerProgressDialogStore()
const comfyManagerStore = useComfyManagerStore()
const activeTabIndex = computed({
get: () => progressDialogContent.getActiveTabIndex(),
set: (value) => progressDialogContent.setActiveTabIndex(value)
})
const { t } = useI18n()
const tabs = computed(() => [
{ label: t('manager.installationQueue') },
{
label: t('manager.failed', {
count: comfyManagerStore.failedTasksIds.length
})
}
])
</script>

View File

@@ -0,0 +1,542 @@
<template>
<div
class="h-full flex flex-col mx-auto overflow-hidden"
:aria-label="$t('manager.title')"
>
<ContentDivider :width="0.3" />
<Button
v-if="isSmallScreen"
:icon="isSideNavOpen ? 'pi pi-chevron-left' : 'pi pi-chevron-right'"
severity="secondary"
filled
class="absolute top-1/2 -translate-y-1/2 z-10"
:class="isSideNavOpen ? 'left-[12rem]' : 'left-2'"
@click="toggleSideNav"
/>
<div class="flex flex-1 relative overflow-hidden">
<ManagerNavSidebar
v-if="isSideNavOpen"
v-model:selected-tab="selectedTab"
:tabs="tabs"
/>
<div
class="flex-1 overflow-auto bg-gray-50 dark-theme:bg-neutral-900"
:class="{
'transition-all duration-300': isSmallScreen
}"
>
<div class="px-6 flex flex-col h-full">
<!-- Conflict Warning Banner -->
<div
v-if="shouldShowManagerBanner"
class="bg-yellow-500/20 rounded-lg p-4 mt-3 mb-4 flex items-center gap-6 relative"
>
<i class="pi pi-exclamation-triangle text-yellow-600 text-lg"></i>
<div class="flex flex-col gap-2 flex-1">
<p class="text-sm font-bold m-0">
{{ $t('manager.conflicts.warningBanner.title') }}
</p>
<p class="text-xs m-0">
{{ $t('manager.conflicts.warningBanner.message') }}
</p>
<p
class="text-sm font-bold m-0 cursor-pointer"
@click="onClickWarningLink"
>
{{ $t('manager.conflicts.warningBanner.button') }}
</p>
</div>
<IconButton
class="absolute top-0 right-0"
type="transparent"
@click="dismissWarningBanner"
>
<i
class="pi pi-times text-neutral-900 dark-theme:text-white text-xs"
></i>
</IconButton>
</div>
<RegistrySearchBar
v-model:search-query="searchQuery"
v-model:search-mode="searchMode"
v-model:sort-field="sortField"
:search-results="searchResults"
:suggestions="suggestions"
:is-missing-tab="isMissingTab"
:sort-options="sortOptions"
:is-update-available-tab="isUpdateAvailableTab"
/>
<div class="flex-1 overflow-auto">
<div
v-if="isLoading"
class="w-full h-full overflow-auto scrollbar-hide"
>
<GridSkeleton :grid-style="GRID_STYLE" :skeleton-card-count />
</div>
<NoResultsPlaceholder
v-else-if="searchResults.length === 0"
:title="
comfyManagerStore.error
? $t('manager.errorConnecting')
: $t('manager.noResultsFound')
"
:message="
comfyManagerStore.error
? $t('manager.tryAgainLater')
: $t('manager.tryDifferentSearch')
"
/>
<div v-else class="h-full" @click="handleGridContainerClick">
<VirtualGrid
id="results-grid"
:items="resultsWithKeys"
:buffer-rows="4"
:grid-style="GRID_STYLE"
@approach-end="onApproachEnd"
>
<template #item="{ item }">
<PackCard
:node-pack="item"
:is-selected="
selectedNodePacks.some((pack) => pack.id === item.id)
"
@click.stop="
(event: MouseEvent) => selectNodePack(item, event)
"
/>
</template>
</VirtualGrid>
</div>
</div>
</div>
</div>
<div class="w-[clamp(250px,33%,306px)] border-l-0 flex z-20">
<ContentDivider orientation="vertical" :width="0.2" />
<div class="w-full flex flex-col isolate">
<InfoPanel
v-if="!hasMultipleSelections && selectedNodePack"
:node-pack="selectedNodePack"
/>
<InfoPanelMultiItem v-else :node-packs="selectedNodePacks" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { merge } from 'es-toolkit/compat'
import Button from 'primevue/button'
import {
computed,
onBeforeUnmount,
onMounted,
onUnmounted,
ref,
watch,
watchEffect
} from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import ContentDivider from '@/components/common/ContentDivider.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { components } from '@/types/comfyRegistryTypes'
import ManagerNavSidebar from '@/workbench/extensions/manager/components/manager/ManagerNavSidebar.vue'
import InfoPanel from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanel.vue'
import InfoPanelMultiItem from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanelMultiItem.vue'
import PackCard from '@/workbench/extensions/manager/components/manager/packCard/PackCard.vue'
import RegistrySearchBar from '@/workbench/extensions/manager/components/manager/registrySearchBar/RegistrySearchBar.vue'
import GridSkeleton from '@/workbench/extensions/manager/components/manager/skeleton/GridSkeleton.vue'
import { useInstalledPacks } from '@/workbench/extensions/manager/composables/nodePack/useInstalledPacks'
import { usePackUpdateStatus } from '@/workbench/extensions/manager/composables/nodePack/usePackUpdateStatus'
import { useWorkflowPacks } from '@/workbench/extensions/manager/composables/nodePack/useWorkflowPacks'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerStatePersistence } from '@/workbench/extensions/manager/composables/useManagerStatePersistence'
import { useRegistrySearch } from '@/workbench/extensions/manager/composables/useRegistrySearch'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import type { TabItem } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
const { initialTab } = defineProps<{
initialTab?: ManagerTab
}>()
const { t } = useI18n()
const comfyManagerStore = useComfyManagerStore()
const { getPackById } = useComfyRegistryStore()
const conflictAcknowledgment = useConflictAcknowledgment()
const persistedState = useManagerStatePersistence()
const initialState = persistedState.loadStoredState()
const GRID_STYLE = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(17rem, 1fr))',
padding: '0.5rem',
gap: '1.5rem'
} as const
const {
isSmallScreen,
isOpen: isSideNavOpen,
toggle: toggleSideNav
} = useResponsiveCollapse()
// Use conflict acknowledgment state from composable
const {
shouldShowManagerBanner,
dismissWarningBanner,
dismissRedDotNotification
} = conflictAcknowledgment
const tabs = ref<TabItem[]>([
{ id: ManagerTab.All, label: t('g.all'), icon: 'pi-list' },
{ id: ManagerTab.Installed, label: t('g.installed'), icon: 'pi-box' },
{
id: ManagerTab.Workflow,
label: t('manager.inWorkflow'),
icon: 'pi-folder'
},
{
id: ManagerTab.Missing,
label: t('g.missing'),
icon: 'pi-exclamation-circle'
},
{
id: ManagerTab.UpdateAvailable,
label: t('g.updateAvailable'),
icon: 'pi-sync'
}
])
const initialTabId = initialTab ?? initialState.selectedTabId
const selectedTab = ref<TabItem>(
tabs.value.find((tab) => tab.id === initialTabId) || tabs.value[0]
)
const {
searchQuery,
pageNumber,
isLoading: isSearchLoading,
searchResults,
searchMode,
sortField,
suggestions,
sortOptions
} = useRegistrySearch({
initialSortField: initialState.sortField,
initialSearchMode: initialState.searchMode,
initialSearchQuery: initialState.searchQuery
})
pageNumber.value = 0
const onApproachEnd = () => {
pageNumber.value++
}
const isInitialLoad = computed(
() => searchResults.value.length === 0 && searchQuery.value === ''
)
const isEmptySearch = computed(() => searchQuery.value === '')
const displayPacks = ref<components['schemas']['Node'][]>([])
const {
startFetchInstalled,
filterInstalledPack,
installedPacks,
isLoading: isLoadingInstalled,
isReady: installedPacksReady
} = useInstalledPacks()
const {
startFetchWorkflowPacks,
filterWorkflowPack,
workflowPacks,
isLoading: isLoadingWorkflow,
isReady: workflowPacksReady
} = useWorkflowPacks()
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
const isUpdateAvailableTab = computed(
() => selectedTab.value?.id === ManagerTab.UpdateAvailable
)
const isInstalledTab = computed(
() => selectedTab.value?.id === ManagerTab.Installed
)
const isMissingTab = computed(
() => selectedTab.value?.id === ManagerTab.Missing
)
const isWorkflowTab = computed(
() => selectedTab.value?.id === ManagerTab.Workflow
)
const isAllTab = computed(() => selectedTab.value?.id === ManagerTab.All)
const isOutdatedPack = (pack: components['schemas']['Node']) => {
const { isUpdateAvailable } = usePackUpdateStatus(pack)
return isUpdateAvailable.value === true
}
const filterOutdatedPacks = (packs: components['schemas']['Node'][]) =>
packs.filter(isOutdatedPack)
watch(
[isUpdateAvailableTab, installedPacks],
async () => {
if (!isUpdateAvailableTab.value) return
if (!isEmptySearch.value) {
displayPacks.value = filterOutdatedPacks(installedPacks.value)
} else if (
!installedPacks.value.length &&
!installedPacksReady.value &&
!isLoadingInstalled.value
) {
await startFetchInstalled()
} else {
displayPacks.value = filterOutdatedPacks(installedPacks.value)
}
},
{ immediate: true }
)
watch(
[isInstalledTab, installedPacks],
async () => {
if (!isInstalledTab.value) return
if (!isEmptySearch.value) {
displayPacks.value = filterInstalledPack(searchResults.value)
} else if (
!installedPacks.value.length &&
!installedPacksReady.value &&
!isLoadingInstalled.value
) {
await startFetchInstalled()
} else {
displayPacks.value = installedPacks.value
}
},
{ immediate: true }
)
watch(
[isMissingTab, isWorkflowTab, workflowPacks, installedPacks],
async () => {
if (!isWorkflowTab.value && !isMissingTab.value) return
if (!isEmptySearch.value) {
displayPacks.value = isMissingTab.value
? filterMissingPacks(filterWorkflowPack(searchResults.value))
: filterWorkflowPack(searchResults.value)
} else if (
!workflowPacks.value.length &&
!isLoadingWorkflow.value &&
!workflowPacksReady.value
) {
await startFetchWorkflowPacks()
if (isMissingTab.value) {
await startFetchInstalled()
}
} else {
displayPacks.value = isMissingTab.value
? filterMissingPacks(workflowPacks.value)
: workflowPacks.value
}
},
{ immediate: true }
)
watch([isAllTab, searchResults], () => {
if (!isAllTab.value) return
displayPacks.value = searchResults.value
})
const onClickWarningLink = () => {
window.open(
'https://docs.comfy.org/troubleshooting/custom-node-issues',
'_blank'
)
}
const onResultsChange = () => {
switch (selectedTab.value?.id) {
case ManagerTab.Installed:
displayPacks.value = isEmptySearch.value
? installedPacks.value
: filterInstalledPack(searchResults.value)
break
case ManagerTab.Workflow:
displayPacks.value = isEmptySearch.value
? workflowPacks.value
: filterWorkflowPack(searchResults.value)
break
case ManagerTab.Missing:
if (!isEmptySearch.value) {
displayPacks.value = filterMissingPacks(
filterWorkflowPack(searchResults.value)
)
}
break
case ManagerTab.UpdateAvailable:
displayPacks.value = isEmptySearch.value
? filterOutdatedPacks(installedPacks.value)
: filterOutdatedPacks(searchResults.value)
break
default:
displayPacks.value = searchResults.value
}
}
watch(searchResults, onResultsChange, { flush: 'post' })
watch(() => comfyManagerStore.installedPacksIds, onResultsChange)
const isLoading = computed(() => {
if (isSearchLoading.value) return searchResults.value.length === 0
if (selectedTab.value?.id === ManagerTab.Installed) {
return isLoadingInstalled.value
}
if (
selectedTab.value?.id === ManagerTab.Workflow ||
selectedTab.value?.id === ManagerTab.Missing
) {
return isLoadingWorkflow.value
}
return isInitialLoad.value
})
const resultsWithKeys = computed(
() =>
displayPacks.value.map((item) => ({
...item,
key: item.id || item.name
})) as (components['schemas']['Node'] & { key: string })[]
)
const selectedNodePacks = ref<components['schemas']['Node'][]>([])
const selectedNodePack = computed<components['schemas']['Node'] | null>(() =>
selectedNodePacks.value.length === 1 ? selectedNodePacks.value[0] : null
)
const getLoadingCount = () => {
switch (selectedTab.value?.id) {
case ManagerTab.Installed:
return comfyManagerStore.installedPacksIds?.size
case ManagerTab.Workflow:
return workflowPacks.value?.length
case ManagerTab.Missing:
return workflowPacks.value?.filter?.(
(pack) => !comfyManagerStore.isPackInstalled(pack.id)
)?.length
default:
return searchResults.value.length
}
}
const skeletonCardCount = computed(() => {
const loadingCount = getLoadingCount()
if (loadingCount) return loadingCount
return isSmallScreen.value ? 12 : 16
})
const selectNodePack = (
nodePack: components['schemas']['Node'],
event: MouseEvent
) => {
// Handle multi-select with Shift or Ctrl/Cmd key
if (event.shiftKey || event.ctrlKey || event.metaKey) {
const index = selectedNodePacks.value.findIndex(
(pack) => pack.id === nodePack.id
)
if (index === -1) {
// Add to selection if not already selected
selectedNodePacks.value.push(nodePack)
} else {
// Remove from selection if already selected
selectedNodePacks.value.splice(index, 1)
}
} else {
// Single select behavior
selectedNodePacks.value = [nodePack]
}
}
const unSelectItems = () => {
selectedNodePacks.value = []
}
const handleGridContainerClick = (event: MouseEvent) => {
const targetElement = event.target as HTMLElement
if (targetElement && !targetElement.closest('[data-virtual-grid-item]')) {
unSelectItems()
}
}
const hasMultipleSelections = computed(() => selectedNodePacks.value.length > 1)
// Track the last pack ID for which we've fetched full registry data
const lastFetchedPackId = ref<string | null>(null)
// Whenever a single pack is selected, fetch its full info once
whenever(selectedNodePack, async () => {
// Cancel any in-flight requests from previously selected node pack
getPackById.cancel()
// If only a single node pack is selected, fetch full node pack info from registry
const pack = selectedNodePack.value
if (!pack?.id) return
if (hasMultipleSelections.value) return
// Only fetch if we haven't already for this pack
if (lastFetchedPackId.value === pack.id) return
const data = await getPackById.call(pack.id)
// If selected node hasn't changed since request, merge registry & Algolia data
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)
}
// 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) {
displayPacks.value.splice(idx, 1, mergedPack)
}
}
})
let gridContainer: HTMLElement | null = null
onMounted(() => {
gridContainer = document.getElementById('results-grid')
})
watch([searchQuery, selectedTab], () => {
gridContainer ??= document.getElementById('results-grid')
if (gridContainer) {
pageNumber.value = 0
gridContainer.scrollTop = 0
}
})
watchEffect(() => {
dismissRedDotNotification()
})
onBeforeUnmount(() => {
persistedState.persistState({
selectedTabId: selectedTab.value?.id,
searchQuery: searchQuery.value,
searchMode: searchMode.value,
sortField: sortField.value
})
})
onUnmounted(() => {
getPackById.cancel()
})
</script>

View File

@@ -0,0 +1,45 @@
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import ManagerHeader from './ManagerHeader.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: enMessages
}
})
describe('ManagerHeader', () => {
const createWrapper = () => {
return mount(ManagerHeader, {
global: {
plugins: [createPinia(), PrimeVue, i18n]
}
})
}
it('renders the component title', () => {
const wrapper = createWrapper()
expect(wrapper.find('h2').text()).toBe(
enMessages.manager.discoverCommunityContent
)
})
it('has proper structure with flex container', () => {
const wrapper = createWrapper()
const flexContainer = wrapper.find('.flex.items-center')
expect(flexContainer.exists()).toBe(true)
const title = flexContainer.find('h2')
expect(title.exists()).toBe(true)
})
})

View File

@@ -0,0 +1,11 @@
<template>
<div class="w-full">
<div class="flex items-center">
<h2 class="text-lg font-normal text-left">
{{ $t('manager.discoverCommunityContent') }}
</h2>
</div>
</div>
</template>
<script setup lang="ts"></script>

View File

@@ -0,0 +1,42 @@
<template>
<aside
class="flex translate-x-0 max-w-[250px] w-3/12 z-5 transition-transform duration-300 ease-in-out"
>
<ScrollPanel class="flex-1">
<Listbox
v-model="selectedTab"
:options="tabs"
option-label="label"
list-style="max-height:unset"
class="w-full border-0 bg-transparent shadow-none"
:pt="{
list: { class: 'p-3 gap-2' },
option: { class: 'px-4 py-2 text-lg rounded-lg' },
optionGroup: { class: 'p-0 text-left text-inherit' }
}"
>
<template #option="slotProps">
<div class="text-left flex items-center">
<i :class="['pi', slotProps.option.icon, 'text-sm mr-2']" />
<span class="text-sm">{{ slotProps.option.label }}</span>
</div>
</template>
</Listbox>
</ScrollPanel>
<ContentDivider orientation="vertical" :width="0.3" />
</aside>
</template>
<script setup lang="ts">
import Listbox from 'primevue/listbox'
import ScrollPanel from 'primevue/scrollpanel'
import ContentDivider from '@/components/common/ContentDivider.vue'
import type { TabItem } from '@/workbench/extensions/manager/types/comfyManagerTypes'
defineProps<{
tabs: TabItem[]
}>()
const selectedTab = defineModel<TabItem>('selectedTab')
</script>

View File

@@ -0,0 +1,244 @@
<template>
<div class="w-[552px] flex flex-col">
<ContentDivider :width="1" />
<div class="px-4 py-6 w-full h-full flex flex-col gap-2">
<!-- Description -->
<div v-if="showAfterWhatsNew">
<p
class="text-sm leading-4 text-neutral-800 dark-theme:text-white m-0 mb-4"
>
{{ $t('manager.conflicts.description') }}
<br /><br />
{{ $t('manager.conflicts.info') }}
</p>
</div>
<!-- Import Failed List Wrapper -->
<div
v-if="importFailedConflicts.length > 0"
class="w-full flex flex-col bg-neutral-200 dark-theme:bg-black min-h-8 rounded-lg"
>
<div
class="w-full h-8 flex items-center justify-between gap-2 pl-4"
@click="toggleImportFailedPanel"
>
<div class="flex-1 flex">
<span
class="text-xs font-bold text-yellow-600 dark-theme:text-yellow-400 mr-2"
>{{ importFailedConflicts.length }}</span
>
<span
class="text-xs font-bold text-neutral-600 dark-theme:text-white"
>{{ $t('manager.conflicts.importFailedExtensions') }}</span
>
</div>
<div>
<Button
:icon="
importFailedExpanded
? 'pi pi-chevron-down text-xs'
: 'pi pi-chevron-right text-xs'
"
text
class="text-neutral-600 dark-theme:text-neutral-300 !bg-transparent"
/>
</div>
</div>
<!-- Import failed list -->
<div
v-if="importFailedExpanded"
class="py-2 px-4 flex flex-col gap-2.5 max-h-[142px] overflow-y-auto scrollbar-hide"
>
<div
v-for="(packageName, i) in importFailedConflicts"
:key="i"
class="flex items-center justify-between h-6 px-4 flex-shrink-0 conflict-list-item"
>
<span class="text-xs text-neutral-600 dark-theme:text-neutral-300">
{{ packageName }}
</span>
<span class="pi pi-info-circle text-sm"></span>
</div>
</div>
</div>
<!-- Conflict List Wrapper -->
<div
class="w-full flex flex-col bg-neutral-200 dark-theme:bg-black min-h-8 rounded-lg"
>
<div
class="w-full h-8 flex items-center justify-between gap-2 pl-4"
@click="toggleConflictsPanel"
>
<div class="flex-1 flex">
<span
class="text-xs font-bold text-yellow-600 dark-theme:text-yellow-400 mr-2"
>{{ allConflictDetails.length }}</span
>
<span
class="text-xs font-bold text-neutral-600 dark-theme:text-white"
>{{ $t('manager.conflicts.conflicts') }}</span
>
</div>
<div>
<Button
:icon="
conflictsExpanded
? 'pi pi-chevron-down text-xs'
: 'pi pi-chevron-right text-xs'
"
text
class="text-neutral-600 dark-theme:text-neutral-300 !bg-transparent"
/>
</div>
</div>
<!-- Conflicts list -->
<div
v-if="conflictsExpanded"
class="py-2 px-4 flex flex-col gap-2.5 max-h-[142px] overflow-y-auto scrollbar-hide"
>
<div
v-for="(conflict, i) in allConflictDetails"
:key="i"
class="flex items-center justify-between h-6 px-4 flex-shrink-0 conflict-list-item"
>
<span
class="text-xs text-neutral-600 dark-theme:text-neutral-300"
>{{ getConflictMessage(conflict, t) }}</span
>
<span class="pi pi-info-circle text-sm"></span>
</div>
</div>
</div>
<!-- Extension List Wrapper -->
<div
class="w-full flex flex-col bg-neutral-200 dark-theme:bg-black min-h-8 rounded-lg"
>
<div
class="w-full h-8 flex items-center justify-between gap-2 pl-4"
@click="toggleExtensionsPanel"
>
<div class="flex-1 flex">
<span
class="text-xs font-bold text-yellow-600 dark-theme:text-yellow-400 mr-2"
>{{ conflictData.length }}</span
>
<span
class="text-xs font-bold text-neutral-600 dark-theme:text-white"
>{{ $t('manager.conflicts.extensionAtRisk') }}</span
>
</div>
<div>
<Button
:icon="
extensionsExpanded
? 'pi pi-chevron-down text-xs'
: 'pi pi-chevron-right text-xs'
"
text
class="text-neutral-600 dark-theme:text-neutral-300 !bg-transparent"
/>
</div>
</div>
<!-- Extension list -->
<div
v-if="extensionsExpanded"
class="py-2 px-4 flex flex-col gap-2.5 max-h-[142px] overflow-y-auto scrollbar-hide"
>
<div
v-for="conflictResult in conflictData"
:key="conflictResult.package_id"
class="flex items-center justify-between h-6 px-4 flex-shrink-0 conflict-list-item"
>
<span class="text-xs text-neutral-600 dark-theme:text-neutral-300">
{{ conflictResult.package_name }}
</span>
<span class="pi pi-info-circle text-sm"></span>
</div>
</div>
</div>
</div>
<ContentDivider :width="1" />
</div>
</template>
<script setup lang="ts">
import { filter, flatMap, map, some } from 'es-toolkit/compat'
import Button from 'primevue/button'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import type {
ConflictDetail,
ConflictDetectionResult
} from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import { getConflictMessage } from '@/workbench/extensions/manager/utils/conflictMessageUtil'
const { showAfterWhatsNew = false, conflictedPackages } = defineProps<{
showAfterWhatsNew?: boolean
conflictedPackages?: ConflictDetectionResult[]
}>()
const { t } = useI18n()
const { conflictedPackages: globalConflictPackages } = useConflictDetection()
const conflictsExpanded = ref<boolean>(false)
const extensionsExpanded = ref<boolean>(false)
const importFailedExpanded = ref<boolean>(false)
const conflictData = computed(
() => conflictedPackages || globalConflictPackages.value
)
const allConflictDetails = computed(() => {
const allConflicts = flatMap(
conflictData.value,
(result: ConflictDetectionResult) => result.conflicts
)
return filter(
allConflicts,
(conflict: ConflictDetail) => conflict.type !== 'import_failed'
)
})
const packagesWithImportFailed = computed(() => {
return filter(conflictData.value, (result: ConflictDetectionResult) =>
some(
result.conflicts,
(conflict: ConflictDetail) => conflict.type === 'import_failed'
)
)
})
const importFailedConflicts = computed(() => {
return map(
packagesWithImportFailed.value,
(result: ConflictDetectionResult) =>
result.package_name || result.package_id
)
})
const toggleImportFailedPanel = () => {
importFailedExpanded.value = !importFailedExpanded.value
conflictsExpanded.value = false
extensionsExpanded.value = false
}
const toggleConflictsPanel = () => {
conflictsExpanded.value = !conflictsExpanded.value
extensionsExpanded.value = false
importFailedExpanded.value = false
}
const toggleExtensionsPanel = () => {
extensionsExpanded.value = !extensionsExpanded.value
conflictsExpanded.value = false
importFailedExpanded.value = false
}
</script>
<style scoped>
.conflict-list-item:hover {
background-color: rgba(0, 122, 255, 0.2);
}
</style>

View File

@@ -0,0 +1,54 @@
<template>
<div class="flex items-center justify-between w-full px-3 py-4">
<div class="w-full flex items-center justify-between gap-2 pr-1">
<Button
:label="$t('manager.conflicts.conflictInfoTitle')"
text
severity="secondary"
size="small"
icon="pi pi-info-circle"
:pt="{
label: { class: 'text-sm' }
}"
@click="handleConflictInfoClick"
/>
<Button
v-if="props.buttonText"
:label="props.buttonText"
severity="secondary"
size="small"
@click="handleButtonClick"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useDialogStore } from '@/stores/dialogStore'
interface Props {
buttonText?: string
onButtonClick?: () => void
}
const props = withDefaults(defineProps<Props>(), {
buttonText: undefined,
onButtonClick: undefined
})
const dialogStore = useDialogStore()
const handleConflictInfoClick = () => {
window.open(
'https://docs.comfy.org/troubleshooting/custom-node-issues',
'_blank'
)
}
const handleButtonClick = () => {
// Close the conflict dialog
dialogStore.closeDialog({ key: 'global-node-conflict' })
// Execute the custom button action if provided
if (props.onButtonClick) {
props.onButtonClick()
}
}
</script>

View File

@@ -0,0 +1,12 @@
<template>
<div class="h-12 flex items-center justify-between w-full pl-6">
<div class="flex items-center gap-2">
<!-- Warning Icon -->
<i class="pi pi-exclamation-triangle text-lg"></i>
<!-- Title -->
<p class="text-base font-bold">
{{ $t('manager.conflicts.title') }}
</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,89 @@
<template>
<Message
:severity="statusSeverity"
class="p-0 flex items-center rounded-xl break-words w-fit"
:pt="{
text: { class: 'text-xs' },
content: { class: 'px-2 py-0.5' }
}"
>
<i
class="pi pi-circle-fill mr-1.5 text-[0.6rem] p-0"
:style="{ opacity: 0.8 }"
/>
{{ $t(`manager.status.${statusLabel}`) }}
</Message>
</template>
<script setup lang="ts">
import Message from 'primevue/message'
import { computed, inject } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import { ImportFailedKey } from '@/workbench/extensions/manager/types/importFailedTypes'
type PackVersionStatus = components['schemas']['NodeVersionStatus']
type PackStatus = components['schemas']['NodeStatus']
type Status = PackVersionStatus | PackStatus
type MessageProps = InstanceType<typeof Message>['$props']
type MessageSeverity = MessageProps['severity']
type StatusProps = {
label: string
severity: MessageSeverity
}
const { statusType, hasCompatibilityIssues } = defineProps<{
statusType: Status
hasCompatibilityIssues?: boolean
}>()
// Inject import failed context from parent
const importFailedContext = inject(ImportFailedKey)
const importFailed = importFailedContext?.importFailed
const statusPropsMap: Record<Status, StatusProps> = {
NodeStatusActive: {
label: 'active',
severity: 'success'
},
NodeStatusDeleted: {
label: 'deleted',
severity: 'warn'
},
NodeStatusBanned: {
label: 'banned',
severity: 'error'
},
NodeVersionStatusActive: {
label: 'active',
severity: 'success'
},
NodeVersionStatusPending: {
label: 'pending',
severity: 'warn'
},
NodeVersionStatusDeleted: {
label: 'deleted',
severity: 'warn'
},
NodeVersionStatusFlagged: {
label: 'flagged',
severity: 'error'
},
NodeVersionStatusBanned: {
label: 'banned',
severity: 'error'
}
}
const statusLabel = computed(() => {
if (importFailed?.value) return 'importFailed'
if (hasCompatibilityIssues) return 'conflicting'
return statusPropsMap[statusType]?.label || 'unknown'
})
const statusSeverity = computed(() => {
if (hasCompatibilityIssues || importFailed?.value) return 'error'
return statusPropsMap[statusType]?.severity || 'secondary'
})
</script>

View File

@@ -0,0 +1,305 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import PackVersionBadge from './PackVersionBadge.vue'
import PackVersionSelectorPopover from './PackVersionSelectorPopover.vue'
// Mock config to prevent __COMFYUI_FRONTEND_VERSION__ error
vi.mock('@/config', () => ({
default: {
app_title: 'ComfyUI',
app_version: '1.0.0'
}
}))
const mockNodePack = {
id: 'test-pack',
name: 'Test Pack',
latest_version: {
version: '1.0.0'
}
}
const mockInstalledPacks = {
'test-pack': { ver: '1.5.0' },
'installed-pack': { ver: '2.0.0' }
}
const mockIsPackEnabled = vi.fn(() => true)
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
installedPacks: mockInstalledPacks,
isPackInstalled: (id: string) =>
!!mockInstalledPacks[id as keyof typeof mockInstalledPacks],
isPackEnabled: mockIsPackEnabled,
getInstalledPackVersion: (id: string) =>
mockInstalledPacks[id as keyof typeof mockInstalledPacks]?.ver
}))
}))
vi.mock(
'@/workbench/extensions/manager/composables/nodePack/usePackUpdateStatus',
() => ({
usePackUpdateStatus: vi.fn(() => ({
isUpdateAvailable: false
}))
})
)
const mockToggle = vi.fn()
const mockHide = vi.fn()
const PopoverStub = {
name: 'Popover',
template: '<div><slot></slot></div>',
methods: {
toggle: mockToggle,
hide: mockHide
}
}
describe('PackVersionBadge', () => {
beforeEach(() => {
mockToggle.mockReset()
mockHide.mockReset()
mockIsPackEnabled.mockReturnValue(true) // Reset to default enabled state
})
const mountComponent = ({
props = {}
}: Record<string, any> = {}): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(PackVersionBadge, {
props: {
nodePack: mockNodePack,
isSelected: false,
...props
},
global: {
plugins: [PrimeVue, createPinia(), i18n],
directives: {
tooltip: Tooltip
},
stubs: {
Popover: PopoverStub,
PackVersionSelectorPopover: true
}
}
})
}
it('renders with installed version from store', () => {
const wrapper = mountComponent()
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe('1.5.0') // From mockInstalledPacks
})
it('falls back to latest_version when not installed', () => {
// Use a nodePack that's not in the installedPacks
const uninstalledPack = {
id: 'uninstalled-pack',
name: 'Uninstalled Pack',
latest_version: {
version: '3.0.0'
}
}
const wrapper = mountComponent({
props: { nodePack: uninstalledPack }
})
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe('3.0.0') // From latest_version
})
it('falls back to NIGHTLY when no latest_version and not installed', () => {
// Use a nodePack with no latest_version and not in installedPacks
const noVersionPack = {
id: 'no-version-pack',
name: 'No Version Pack'
}
const wrapper = mountComponent({
props: { nodePack: noVersionPack }
})
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe('nightly')
})
it('falls back to NIGHTLY when nodePack.id is missing', () => {
const invalidPack = {
name: 'Invalid Pack'
}
const wrapper = mountComponent({
props: { nodePack: invalidPack }
})
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe('nightly')
})
it('toggles the popover when button is clicked', async () => {
const wrapper = mountComponent()
// Click the badge
await wrapper.find('[role="button"]').trigger('click')
// Verify that the toggle method was called
expect(mockToggle).toHaveBeenCalled()
})
it('closes the popover when cancel is emitted', async () => {
const wrapper = mountComponent()
// Simulate the popover emitting a cancel event
wrapper.findComponent(PackVersionSelectorPopover).vm.$emit('cancel')
await nextTick()
// Verify that the hide method was called
expect(mockHide).toHaveBeenCalled()
})
it('closes the popover when submit is emitted', async () => {
const wrapper = mountComponent()
// Simulate the popover emitting a submit event
wrapper.findComponent(PackVersionSelectorPopover).vm.$emit('submit')
await nextTick()
// 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()
})
})
describe('disabled state', () => {
beforeEach(() => {
mockIsPackEnabled.mockReturnValue(false) // Set all packs as disabled for these tests
})
it('adds disabled styles when pack is disabled', () => {
const wrapper = mountComponent()
const badge = wrapper.find('[role="text"]') // role changes to "text" when disabled
expect(badge.exists()).toBe(true)
expect(badge.classes()).toContain('cursor-not-allowed')
expect(badge.classes()).toContain('opacity-60')
})
it('does not show chevron icon when disabled', () => {
const wrapper = mountComponent()
const chevronIcon = wrapper.find('.pi-chevron-right')
expect(chevronIcon.exists()).toBe(false)
})
it('does not show update arrow when disabled', () => {
const wrapper = mountComponent()
const updateIcon = wrapper.find('.pi-arrow-circle-up')
expect(updateIcon.exists()).toBe(false)
})
it('does not toggle popover when clicked while disabled', async () => {
const wrapper = mountComponent()
const badge = wrapper.find('[role="text"]') // role changes to "text" when disabled
expect(badge.exists()).toBe(true)
await badge.trigger('click')
// Since it's disabled, the popover should not be toggled
expect(mockToggle).not.toHaveBeenCalled()
})
it('has correct tabindex when disabled', () => {
const wrapper = mountComponent()
const badge = wrapper.find('[role="text"]') // role changes to "text" when disabled
expect(badge.exists()).toBe(true)
expect(badge.attributes('tabindex')).toBe('-1')
})
it('does not respond to keyboard events when disabled', async () => {
const wrapper = mountComponent()
const badge = wrapper.find('[role="text"]') // role changes to "text" when disabled
expect(badge.exists()).toBe(true)
await badge.trigger('keydown.enter')
await badge.trigger('keydown.space')
expect(mockToggle).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,106 @@
<template>
<div>
<div
v-tooltip.top="
isDisabled ? $t('manager.enablePackToChangeVersion') : null
"
class="inline-flex items-center gap-1 rounded-2xl text-xs py-1"
:class="{
'bg-gray-100 dark-theme:bg-neutral-700 px-1.5': fill,
'cursor-pointer': !isDisabled,
'cursor-not-allowed opacity-60': isDisabled
}"
:aria-haspopup="!isDisabled"
:role="isDisabled ? 'text' : 'button'"
:tabindex="isDisabled ? -1 : 0"
@click="!isDisabled && toggleVersionSelector($event)"
@keydown.enter="!isDisabled && toggleVersionSelector($event)"
@keydown.space="!isDisabled && toggleVersionSelector($event)"
>
<i
v-if="isUpdateAvailable"
class="pi pi-arrow-circle-up text-blue-600 text-xs"
/>
<span>{{ installedVersion }}</span>
<i v-if="!isDisabled" class="pi pi-chevron-right text-xxs" />
</div>
<Popover
ref="popoverRef"
:pt="{
content: { class: 'p-0 shadow-lg' }
}"
>
<PackVersionSelectorPopover
:installed-version="installedVersion"
:node-pack="nodePack"
@cancel="closeVersionSelector"
@submit="closeVersionSelector"
/>
</Popover>
</div>
</template>
<script setup lang="ts">
import Popover from 'primevue/popover'
import { valid as validSemver } from 'semver'
import { computed, ref, watch } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import PackVersionSelectorPopover from '@/workbench/extensions/manager/components/manager/PackVersionSelectorPopover.vue'
import { usePackUpdateStatus } from '@/workbench/extensions/manager/composables/nodePack/usePackUpdateStatus'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
const TRUNCATED_HASH_LENGTH = 7
const {
nodePack,
isSelected,
fill = true
} = defineProps<{
nodePack: components['schemas']['Node']
isSelected: boolean
fill?: boolean
}>()
const { isUpdateAvailable } = usePackUpdateStatus(nodePack)
const popoverRef = ref()
const managerStore = useComfyManagerStore()
const isInstalled = computed(() => managerStore.isPackInstalled(nodePack?.id))
const isDisabled = computed(
() => isInstalled.value && !managerStore.isPackEnabled(nodePack?.id)
)
const installedVersion = computed(() => {
if (!nodePack.id) return 'nightly'
const version =
managerStore.installedPacks[nodePack.id]?.ver ??
nodePack.latest_version?.version ??
'nightly'
// If Git hash, truncate to 7 characters
return validSemver(version)
? version
: version.slice(0, TRUNCATED_HASH_LENGTH)
})
const toggleVersionSelector = (event: Event) => {
popoverRef.value.toggle(event)
}
const closeVersionSelector = () => {
popoverRef.value.hide()
}
// If the card is unselected, automatically close the version selector popover
watch(
() => isSelected,
(isSelected, wasSelected) => {
if (wasSelected && !isSelected) {
closeVersionSelector()
}
}
)
</script>

View File

@@ -0,0 +1,712 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import Listbox from 'primevue/listbox'
import Select from 'primevue/select'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import VerifiedIcon from '@/components/icons/VerifiedIcon.vue'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
// SelectedVersion is now using direct strings instead of enum
import PackVersionSelectorPopover from './PackVersionSelectorPopover.vue'
// Default mock versions for reference
const defaultMockVersions = [
{
version: '1.0.0',
createdAt: '2023-01-01',
supported_os: ['windows', 'linux'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true
},
{ version: '0.9.0', createdAt: '2022-12-01' },
{ version: '0.8.0', createdAt: '2022-11-01' }
]
const mockNodePack = {
id: 'test-pack',
name: 'Test Pack',
latest_version: {
version: '1.0.0',
supported_os: ['windows', 'linux'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true
},
repository: 'https://github.com/user/repo',
has_registry_data: true
}
// Create mock functions
const mockGetPackVersions = vi.fn()
const mockInstallPack = vi.fn().mockResolvedValue(undefined)
const mockCheckNodeCompatibility = vi.fn()
// Mock the registry service
vi.mock('@/services/comfyRegistryService', () => ({
useComfyRegistryService: vi.fn(() => ({
getPackVersions: mockGetPackVersions
}))
}))
// Mock the manager store
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
installPack: {
call: mockInstallPack,
clear: vi.fn()
},
isPackInstalled: vi.fn(() => false),
getInstalledPackVersion: vi.fn(() => undefined)
}))
}))
// Mock the conflict detection composable
vi.mock(
'@/workbench/extensions/manager/composables/useConflictDetection',
() => ({
useConflictDetection: vi.fn(() => ({
checkNodeCompatibility: mockCheckNodeCompatibility
}))
})
)
const waitForPromises = async () => {
await new Promise((resolve) => setTimeout(resolve, 16))
await nextTick()
}
describe('PackVersionSelectorPopover', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetPackVersions.mockReset()
mockInstallPack.mockReset().mockResolvedValue(undefined)
mockCheckNodeCompatibility
.mockReset()
.mockReturnValue({ hasConflict: false, conflicts: [] })
})
const mountComponent = ({
props = {}
}: Record<string, any> = {}): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(PackVersionSelectorPopover, {
props: {
nodePack: mockNodePack,
...props
},
global: {
plugins: [PrimeVue, createPinia(), i18n],
components: {
Listbox,
VerifiedIcon,
Select
},
directives: {
tooltip: Tooltip
}
}
})
}
it('fetches versions on mount', async () => {
// Set up the mock for this specific test
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
mountComponent()
await waitForPromises()
expect(mockGetPackVersions).toHaveBeenCalledWith(mockNodePack.id)
})
it('shows loading state while fetching versions', async () => {
// Delay the promise resolution
mockGetPackVersions.mockImplementationOnce(
() =>
new Promise((resolve) =>
setTimeout(() => resolve(defaultMockVersions), 1000)
)
)
const wrapper = mountComponent()
expect(wrapper.text()).toContain('Loading versions...')
})
it('displays special options and version options in the listbox', async () => {
// Set up the mock for this specific test
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const wrapper = mountComponent()
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.exists()).toBe(true)
const options = listbox.props('options')!
// Check that we have both special options and version options
// Latest version (1.0.0) should be excluded from the version list to avoid duplication
expect(options.length).toBe(defaultMockVersions.length + 1) // 2 special options + version options minus 1 duplicate
// Check that special options exist
expect(options.some((o) => o.value === 'nightly')).toBe(true)
expect(options.some((o) => o.value === 'latest')).toBe(true)
// Check that version options exist (excluding latest version 1.0.0)
expect(options.some((o) => o.value === '1.0.0')).toBe(false) // Should be excluded as it's the latest
expect(options.some((o) => o.value === '0.9.0')).toBe(true)
expect(options.some((o) => o.value === '0.8.0')).toBe(true)
})
it('emits cancel event when cancel button is clicked', async () => {
// Set up the mock for this specific test
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const wrapper = mountComponent()
await waitForPromises()
const cancelButton = wrapper.findAllComponents(Button)[0]
await cancelButton.trigger('click')
expect(wrapper.emitted('cancel')).toBeTruthy()
})
it('calls installPack and emits submit when install button is clicked', async () => {
// Set up the mock for this specific test
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const wrapper = mountComponent()
await waitForPromises()
// Set the selected version
await wrapper.findComponent(Listbox).setValue('0.9.0')
const installButton = wrapper.findAllComponents(Button)[1]
await installButton.trigger('click')
// Check that installPack was called with the correct parameters
expect(mockInstallPack).toHaveBeenCalledWith(
expect.objectContaining({
id: mockNodePack.id,
repository: mockNodePack.repository,
version: '0.9.0',
selected_version: '0.9.0'
})
)
// Check that submit was emitted
expect(wrapper.emitted('submit')).toBeTruthy()
})
it('is reactive to nodePack prop changes', async () => {
// Set up the mock for the initial fetch
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const wrapper = mountComponent()
await waitForPromises()
// Set up the mock for the second fetch after prop change
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Update the nodePack prop
const newNodePack = { ...mockNodePack, id: 'new-test-pack' }
await wrapper.setProps({ nodePack: newNodePack })
await waitForPromises()
// Should fetch versions for the new nodePack
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
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const packWithRepo = {
...mockNodePack,
latest_version: undefined
}
const wrapper = mountComponent({
props: {
nodePack: packWithRepo
}
})
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.exists()).toBe(true)
expect(listbox.props('modelValue')).toBe('nightly')
})
it('defaults to nightly when publisher name is "Unclaimed"', async () => {
// Set up the mock to return versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const unclaimedNodePack = {
...mockNodePack,
publisher: { name: 'Unclaimed' }
}
const wrapper = mountComponent({
props: {
nodePack: unclaimedNodePack
}
})
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.exists()).toBe(true)
expect(listbox.props('modelValue')).toBe('nightly')
})
})
describe('version compatibility checking', () => {
it('shows warning icon for incompatible versions', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return conflict for specific version
mockCheckNodeCompatibility.mockImplementation((versionData) => {
if (versionData.supported_os?.includes('linux')) {
return {
hasConflict: true,
conflicts: [
{
type: 'os',
current_value: 'windows',
required_value: 'linux'
}
]
}
}
return { hasConflict: false, conflicts: [] }
})
const nodePackWithCompatibility = {
...mockNodePack,
supported_os: ['linux'],
supported_accelerators: ['CUDA']
}
const wrapper = mountComponent({
props: { nodePack: nodePackWithCompatibility }
})
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// The warning icon should be shown for incompatible versions
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
expect(warningIcons.length).toBeGreaterThan(0)
})
it('shows verified icon for compatible versions', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return no conflicts
mockCheckNodeCompatibility.mockReturnValue({
hasConflict: false,
conflicts: []
})
const wrapper = mountComponent()
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// The verified icon should be shown for compatible versions
// Look for the VerifiedIcon component or SVG elements
const verifiedIcons = wrapper.findAll('svg')
expect(verifiedIcons.length).toBeGreaterThan(0)
})
it('calls checkVersionCompatibility with correct version data', async () => {
// Set up the mock for versions with specific supported data
const versionsWithCompatibility = [
{
version: '1.0.0',
supported_os: ['windows', 'linux'],
supported_accelerators: ['CUDA', 'CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0'
}
]
mockGetPackVersions.mockResolvedValueOnce(versionsWithCompatibility)
const nodePackWithCompatibility = {
...mockNodePack,
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
latest_version: {
version: '1.0.0',
supported_os: ['windows', 'linux'],
supported_accelerators: ['CPU'], // latest_version data takes precedence
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true
}
}
const wrapper = mountComponent({
props: { nodePack: nodePackWithCompatibility }
})
await waitForPromises()
// Clear previous calls from component mounting/rendering
mockCheckNodeCompatibility.mockClear()
// Trigger compatibility check by accessing getVersionCompatibility
const vm = wrapper.vm as any
vm.getVersionCompatibility('1.0.0')
// Verify that checkNodeCompatibility was called with correct data
// Since 1.0.0 is the latest version, it should use latest_version data
expect(mockCheckNodeCompatibility).toHaveBeenCalledWith({
supported_os: ['windows', 'linux'],
supported_accelerators: ['CPU'], // latest_version data takes precedence
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true,
version: '1.0.0'
})
})
it('shows version conflict warnings for ComfyUI and frontend versions', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return version conflicts
mockCheckNodeCompatibility.mockImplementation((versionData) => {
const conflicts = []
if (versionData.supported_comfyui_version) {
conflicts.push({
type: 'comfyui_version',
current_value: '0.5.0',
required_value: versionData.supported_comfyui_version
})
}
if (versionData.supported_comfyui_frontend_version) {
conflicts.push({
type: 'frontend_version',
current_value: '1.0.0',
required_value: versionData.supported_comfyui_frontend_version
})
}
return {
hasConflict: conflicts.length > 0,
conflicts
}
})
const nodePackWithVersionRequirements = {
...mockNodePack,
supported_comfyui_version: '>=1.0.0',
supported_comfyui_frontend_version: '>=2.0.0'
}
const wrapper = mountComponent({
props: { nodePack: nodePackWithVersionRequirements }
})
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// The warning icon should be shown for version incompatible packages
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
expect(warningIcons.length).toBeGreaterThan(0)
})
it('handles latest and nightly versions using nodePack data', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const nodePackWithCompatibility = {
...mockNodePack,
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
latest_version: {
...mockNodePack.latest_version,
supported_os: ['windows'], // Match nodePack data for test consistency
supported_accelerators: ['CPU'], // Match nodePack data for test consistency
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true
}
}
const wrapper = mountComponent({
props: { nodePack: nodePackWithCompatibility }
})
await waitForPromises()
const vm = wrapper.vm as any
// Clear previous calls from component mounting/rendering
mockCheckNodeCompatibility.mockClear()
// Test latest version
vm.getVersionCompatibility('latest')
expect(mockCheckNodeCompatibility).toHaveBeenCalledWith({
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true,
version: '1.0.0'
})
// Clear for next test call
mockCheckNodeCompatibility.mockClear()
// Test nightly version
vm.getVersionCompatibility('nightly')
expect(mockCheckNodeCompatibility).toHaveBeenCalledWith({
id: 'test-pack',
name: 'Test Pack',
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
repository: 'https://github.com/user/repo',
has_registry_data: true,
latest_version: {
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true,
version: '1.0.0',
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0'
}
})
})
it('shows banned package warnings', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return banned conflicts
mockCheckNodeCompatibility.mockImplementation((versionData) => {
if (versionData.is_banned === true) {
return {
hasConflict: true,
conflicts: [
{
type: 'banned',
current_value: 'installed',
required_value: 'not_banned'
}
]
}
}
return { hasConflict: false, conflicts: [] }
})
const bannedNodePack = {
...mockNodePack,
is_banned: true,
latest_version: {
...mockNodePack.latest_version,
is_banned: true
}
}
const wrapper = mountComponent({
props: { nodePack: bannedNodePack }
})
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// Open the dropdown to see the options
const select = wrapper.find('.p-select')
if (!select.exists()) {
// Try alternative selector
const selectButton = wrapper.find('[aria-haspopup="listbox"]')
if (selectButton.exists()) {
await selectButton.trigger('click')
}
} else {
await select.trigger('click')
}
await wrapper.vm.$nextTick()
// The warning icon should be shown for banned packages in the dropdown options
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
expect(warningIcons.length).toBeGreaterThan(0)
})
it('shows security pending warnings', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return security pending conflicts
mockCheckNodeCompatibility.mockImplementation((versionData) => {
if (versionData.has_registry_data === false) {
return {
hasConflict: true,
conflicts: [
{
type: 'pending',
current_value: 'no_registry_data',
required_value: 'registry_data_available'
}
]
}
}
return { hasConflict: false, conflicts: [] }
})
const securityPendingNodePack = {
...mockNodePack,
has_registry_data: false,
latest_version: {
...mockNodePack.latest_version,
has_registry_data: false
}
}
const wrapper = mountComponent({
props: { nodePack: securityPendingNodePack }
})
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// The warning icon should be shown for security pending packages
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
expect(warningIcons.length).toBeGreaterThan(0)
})
})
})

View File

@@ -0,0 +1,314 @@
<template>
<div class="w-64 pt-1">
<div class="py-2">
<span class="pl-3 text-md font-semibold text-neutral-500">
{{ $t('manager.selectVersion') }}
</span>
</div>
<div
v-if="isLoadingVersions || isQueueing"
class="text-center text-muted py-4 flex flex-col items-center"
>
<ProgressSpinner class="w-8 h-8 mb-2" />
{{ $t('manager.loadingVersions') }}
</div>
<div v-else-if="versionOptions.length === 0" class="py-2">
<NoResultsPlaceholder
:title="$t('g.noResultsFound')"
:message="$t('manager.tryAgainLater')"
icon="pi pi-exclamation-circle"
class="p-0"
/>
</div>
<Listbox
v-else
v-model="selectedVersion"
option-label="label"
option-value="value"
:options="processedVersionOptions"
:highlight-on-select="false"
class="w-full max-h-[50vh] border-none shadow-none rounded-md"
:pt="{
listContainer: { class: 'scrollbar-hide' }
}"
>
<template #option="slotProps">
<div class="flex justify-between items-center w-full p-1">
<div class="flex items-center gap-2">
<template v-if="slotProps.option.value === 'nightly'">
<div class="w-4"></div>
</template>
<template v-else>
<i
v-if="slotProps.option.hasConflict"
v-tooltip="{
value: slotProps.option.conflictMessage,
showDelay: 300
}"
class="pi pi-exclamation-triangle text-yellow-500"
/>
<VerifiedIcon v-else :size="20" class="relative right-0.5" />
</template>
<span>{{ slotProps.option.label }}</span>
</div>
<i
v-if="slotProps.option.isSelected"
class="pi pi-check text-highlight"
/>
</div>
</template>
</Listbox>
<ContentDivider class="my-2" />
<div class="flex justify-end gap-2 py-1 px-3">
<Button
text
class="text-sm"
severity="secondary"
:label="$t('g.cancel')"
:disabled="isQueueing"
@click="emit('cancel')"
/>
<Button
severity="secondary"
:label="$t('g.install')"
class="py-2.5 px-4 text-sm dark-theme:bg-unset bg-black/80 dark-theme:text-unset text-neutral-100 rounded-lg"
:disabled="isQueueing"
@click="handleSubmit"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import Button from 'primevue/button'
import Listbox from 'primevue/listbox'
import ProgressSpinner from 'primevue/progressspinner'
import { valid as validSemver } from 'semver'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VerifiedIcon from '@/components/icons/VerifiedIcon.vue'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import type { components } from '@/types/comfyRegistryTypes'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import type { components as ManagerComponents } from '@/workbench/extensions/manager/types/generatedManagerTypes'
import { getJoinedConflictMessages } from '@/workbench/extensions/manager/utils/conflictMessageUtil'
type ManagerChannel = ManagerComponents['schemas']['ManagerChannel']
type ManagerDatabaseSource =
ManagerComponents['schemas']['ManagerDatabaseSource']
type SelectedVersion = ManagerComponents['schemas']['SelectedVersion']
// Enum values for runtime use
const SelectedVersionValues = {
LATEST: 'latest' as SelectedVersion,
NIGHTLY: 'nightly' as SelectedVersion
}
const ManagerChannelValues: Record<string, ManagerChannel> = {
DEFAULT: 'default',
DEV: 'dev'
}
const ManagerDatabaseSourceValues: Record<string, ManagerDatabaseSource> = {
CACHE: 'cache',
REMOTE: 'remote',
LOCAL: 'local'
}
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const emit = defineEmits<{
cancel: []
submit: []
}>()
const { t } = useI18n()
const registryService = useComfyRegistryService()
const managerStore = useComfyManagerStore()
const { checkNodeCompatibility } = useConflictDetection()
const isQueueing = ref(false)
const selectedVersion = ref<string>(SelectedVersionValues.LATEST)
onMounted(() => {
const initialVersion =
getInitialSelectedVersion() ?? SelectedVersionValues.LATEST
selectedVersion.value =
// Use NIGHTLY when version is a Git hash
validSemver(initialVersion) ? initialVersion : SelectedVersionValues.NIGHTLY
})
const getInitialSelectedVersion = () => {
if (!nodePack.id) return
// If unclaimed, set selected version to nightly
if (nodePack.publisher?.name === 'Unclaimed')
return SelectedVersionValues.NIGHTLY
// If node pack is installed, set selected version to the installed version
if (managerStore.isPackInstalled(nodePack.id))
return managerStore.getInstalledPackVersion(nodePack.id)
// If node pack is not installed, set selected version to latest
return nodePack.latest_version?.version
}
const fetchVersions = async () => {
if (!nodePack?.id) return []
return (await registryService.getPackVersions(nodePack.id)) || []
}
const versionOptions = ref<
{
value: string
label: string
}[]
>([])
const fetchedVersions = ref<components['schemas']['NodeVersion'][]>([])
const isLoadingVersions = ref(false)
const onNodePackChange = async () => {
isLoadingVersions.value = true
// Fetch versions from the registry
const versions = await fetchVersions()
fetchedVersions.value = versions
const latestVersionNumber = nodePack.latest_version?.version
const availableVersionOptions = versions
.map((version) => ({
value: version.version ?? '',
label: version.version ?? ''
}))
.filter((option) => option.value && option.value !== latestVersionNumber) // Exclude latest version from the list
// Add Latest option with actual version number
const latestLabel = latestVersionNumber
? `${t('manager.latestVersion')} (${latestVersionNumber})`
: t('manager.latestVersion')
// Add Latest option
const defaultVersions = [
{
value: SelectedVersionValues.LATEST,
label: latestLabel
}
]
// Add Nightly option if there is a non-empty `repository` field
if (nodePack.repository?.length) {
defaultVersions.push({
value: SelectedVersionValues.NIGHTLY,
label: t('manager.nightlyVersion')
})
}
versionOptions.value = [...defaultVersions, ...availableVersionOptions]
isLoadingVersions.value = false
}
whenever(
() => nodePack.id,
(nodePackId, oldNodePackId) => {
if (nodePackId !== oldNodePackId) {
void onNodePackChange()
}
},
{ deep: true, immediate: true }
)
const handleSubmit = async () => {
isQueueing.value = true
if (!nodePack.id) {
throw new Error('Node ID is required for installation')
}
// Convert 'latest' to actual version number for installation
const actualVersion =
selectedVersion.value === 'latest'
? nodePack.latest_version?.version ?? 'latest'
: selectedVersion.value
await managerStore.installPack.call({
id: nodePack.id,
repository: nodePack.repository ?? '',
channel: ManagerChannelValues.DEFAULT,
mode: ManagerDatabaseSourceValues.CACHE,
version: actualVersion,
selected_version: selectedVersion.value
})
isQueueing.value = false
emit('submit')
}
const getVersionData = (version: string) => {
const latestVersionNumber = nodePack.latest_version?.version
const useLatestVersionData =
version === 'latest' || version === latestVersionNumber
if (useLatestVersionData) {
const latestVersionData = nodePack.latest_version
return {
...latestVersionData
}
}
const versionData = fetchedVersions.value.find((v) => v.version === version)
if (versionData) {
return {
...versionData
}
}
// Fallback to nodePack data
return {
...nodePack
}
}
// Main function to get version compatibility info
const getVersionCompatibility = (version: string) => {
const versionData = getVersionData(version)
const compatibility = checkNodeCompatibility(versionData)
const conflictMessage = compatibility.hasConflict
? getJoinedConflictMessages(compatibility.conflicts, t)
: ''
return {
hasConflict: compatibility.hasConflict,
conflictMessage
}
}
// Helper to determine if an option is selected.
const isOptionSelected = (optionValue: string) => {
if (selectedVersion.value === optionValue) {
return true
}
if (
optionValue === 'latest' &&
selectedVersion.value === nodePack.latest_version?.version
) {
return true
}
return false
}
// Checks if an option is selected, treating 'latest' as an alias for the actual latest version number.
const processedVersionOptions = computed(() => {
return versionOptions.value.map((option) => {
const compatibility = getVersionCompatibility(option.value)
const isSelected = isOptionSelected(option.value)
return {
...option,
hasConflict: compatibility.hasConflict,
conflictMessage: compatibility.conflictMessage,
isSelected: isSelected
}
})
})
</script>

View File

@@ -0,0 +1,166 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import ToggleSwitch from 'primevue/toggleswitch'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import PackEnableToggle from './PackEnableToggle.vue'
// Mock debounce to execute immediately
vi.mock('es-toolkit/compat', async () => {
const actual = await vi.importActual('es-toolkit/compat')
return {
...actual,
debounce: <T extends (...args: any[]) => any>(fn: T) => fn
}
})
const mockNodePack = {
id: 'test-pack',
name: 'Test Pack',
latest_version: {
version: '1.0.0',
createdAt: '2023-01-01T00:00:00Z'
}
}
const mockIsPackEnabled = vi.fn()
const mockEnablePack = { call: vi.fn().mockResolvedValue(undefined) }
const mockDisablePack = vi.fn().mockResolvedValue(undefined)
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
isPackEnabled: mockIsPackEnabled,
enablePack: mockEnablePack,
disablePack: mockDisablePack,
installedPacks: {}
}))
}))
describe('PackEnableToggle', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsPackEnabled.mockReset()
mockEnablePack.call.mockReset().mockResolvedValue(undefined)
mockDisablePack.mockReset().mockResolvedValue(undefined)
})
const mountComponent = ({
props = {},
installedPacks = {}
}: Record<string, any> = {}): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
vi.mocked(useComfyManagerStore).mockReturnValue({
isPackEnabled: mockIsPackEnabled,
enablePack: mockEnablePack,
disablePack: mockDisablePack,
installedPacks
} as any)
return mount(PackEnableToggle, {
props: {
nodePack: mockNodePack,
...props
},
global: {
plugins: [PrimeVue, createPinia(), i18n]
}
})
}
it('renders a toggle switch', () => {
mockIsPackEnabled.mockReturnValue(true)
const wrapper = mountComponent()
const toggleSwitch = wrapper.findComponent(ToggleSwitch)
expect(toggleSwitch.exists()).toBe(true)
})
it('checks if pack is enabled on mount', () => {
mockIsPackEnabled.mockReturnValue(true)
mountComponent()
expect(mockIsPackEnabled).toHaveBeenCalledWith(mockNodePack.id)
})
it('sets toggle to on when pack is enabled', () => {
mockIsPackEnabled.mockReturnValue(true)
const wrapper = mountComponent()
const toggleSwitch = wrapper.findComponent(ToggleSwitch)
expect(toggleSwitch.props('modelValue')).toBe(true)
})
it('sets toggle to off when pack is disabled', () => {
mockIsPackEnabled.mockReturnValue(false)
const wrapper = mountComponent()
const toggleSwitch = wrapper.findComponent(ToggleSwitch)
expect(toggleSwitch.props('modelValue')).toBe(false)
})
it('calls enablePack when toggle is switched on', async () => {
mockIsPackEnabled.mockReturnValue(false)
const wrapper = mountComponent()
const toggleSwitch = wrapper.findComponent(ToggleSwitch)
await toggleSwitch.vm.$emit('update:modelValue', true)
expect(mockEnablePack.call).toHaveBeenCalledWith(
expect.objectContaining({
id: mockNodePack.id,
version: mockNodePack.latest_version.version
})
)
})
it('calls disablePack when toggle is switched off', async () => {
mockIsPackEnabled.mockReturnValue(true)
const wrapper = mountComponent()
const toggleSwitch = wrapper.findComponent(ToggleSwitch)
await toggleSwitch.vm.$emit('update:modelValue', false)
expect(mockDisablePack).toHaveBeenCalledWith(
expect.objectContaining({
id: mockNodePack.id,
version: mockNodePack.latest_version.version
})
)
})
it('disables toggle while loading', async () => {
const pendingPromise = new Promise<void>((resolve) => {
setTimeout(() => resolve(), 1000)
})
mockEnablePack.call.mockReturnValue(pendingPromise)
mockIsPackEnabled.mockReturnValue(false)
const wrapper = mountComponent()
// Trigger the toggle
const toggleSwitch = wrapper.findComponent(ToggleSwitch)
await toggleSwitch.vm.$emit('update:modelValue', true)
// Check that the toggle is disabled during loading
await nextTick()
expect(wrapper.findComponent(ToggleSwitch).props('disabled')).toBe(true)
// Resolve the promise to simulate the operation completing
await pendingPromise
// Check that the toggle is enabled after the operation completes
await nextTick()
expect(wrapper.findComponent(ToggleSwitch).props('disabled')).toBe(false)
})
})

View File

@@ -0,0 +1,161 @@
<template>
<div class="flex items-center gap-2">
<div
v-if="hasConflict"
v-tooltip="{
value: $t('manager.conflicts.warningTooltip'),
showDelay: 300
}"
class="flex items-center justify-center w-6 h-6 cursor-pointer"
@click="showConflictModal(true)"
>
<i class="pi pi-exclamation-triangle text-yellow-500 text-xl"></i>
</div>
<ToggleSwitch
v-if="!canToggleDirectly"
:model-value="isEnabled"
:disabled="isLoading"
:readonly="!canToggleDirectly"
aria-label="Enable or disable pack"
@focus="handleToggleInteraction"
/>
<ToggleSwitch
v-else
:model-value="isEnabled"
:disabled="isLoading"
aria-label="Enable or disable pack"
@update:model-value="onToggle"
/>
</div>
</template>
<script setup lang="ts">
import { debounce } from 'es-toolkit/compat'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDialogService } from '@/services/dialogService'
import type { components } from '@/types/comfyRegistryTypes'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
import type { components as ManagerComponents } from '@/workbench/extensions/manager/types/generatedManagerTypes'
const TOGGLE_DEBOUNCE_MS = 256
const { nodePack, hasConflict } = defineProps<{
nodePack: components['schemas']['Node']
hasConflict?: boolean
}>()
const { t } = useI18n()
const { isPackEnabled, enablePack, disablePack, installedPacks } =
useComfyManagerStore()
const { getConflictsForPackageByID } = useConflictDetectionStore()
const { showNodeConflictDialog } = useDialogService()
const { acknowledgmentState, markConflictsAsSeen } = useConflictAcknowledgment()
const isLoading = ref(false)
const isEnabled = computed(() => isPackEnabled(nodePack.id))
const version = computed(() => {
const id = nodePack.id
if (!id) return 'nightly' as ManagerComponents['schemas']['SelectedVersion']
return (
installedPacks[id]?.ver ??
nodePack.latest_version?.version ??
('nightly' as ManagerComponents['schemas']['SelectedVersion'])
)
})
const packageConflict = computed(() =>
getConflictsForPackageByID(nodePack.id || '')
)
const canToggleDirectly = computed(() => {
return !(
hasConflict &&
!acknowledgmentState.value.modal_dismissed &&
packageConflict.value
)
})
const showConflictModal = (skipModalDismissed: boolean) => {
let modal_dismissed = acknowledgmentState.value.modal_dismissed
if (skipModalDismissed) modal_dismissed = false
if (packageConflict.value && !modal_dismissed) {
showNodeConflictDialog({
conflictedPackages: [packageConflict.value],
buttonText: !isEnabled.value
? t('manager.conflicts.enableAnyway')
: t('manager.conflicts.understood'),
onButtonClick: async () => {
if (!isEnabled.value) {
await handleEnable()
}
},
dialogComponentProps: {
onClose: () => {
markConflictsAsSeen()
}
}
})
}
}
const handleEnable = () => {
if (!nodePack.id) {
throw new Error('Node ID is required for enabling')
}
return enablePack.call({
id: nodePack.id,
version:
version.value ??
('latest' as ManagerComponents['schemas']['SelectedVersion']),
selected_version:
version.value ??
('latest' as ManagerComponents['schemas']['SelectedVersion']),
repository: nodePack.repository ?? '',
channel: 'default' as ManagerComponents['schemas']['ManagerChannel'],
mode: 'cache' as ManagerComponents['schemas']['ManagerDatabaseSource'],
skip_post_install: false
})
}
const handleDisable = () => {
if (!nodePack.id) {
throw new Error('Node ID is required for disabling')
}
return disablePack({
id: nodePack.id,
version:
version.value ??
('latest' as ManagerComponents['schemas']['SelectedVersion'])
})
}
const handleToggle = async (enable: boolean) => {
if (isLoading.value) return
isLoading.value = true
if (enable) {
await handleEnable()
} else {
await handleDisable()
}
isLoading.value = false
}
const onToggle = debounce(
(enable: boolean) => {
void handleToggle(enable)
},
TOGGLE_DEBOUNCE_MS,
{ trailing: true }
)
const handleToggleInteraction = async (event: Event) => {
if (!canToggleDirectly.value) {
event.preventDefault()
showConflictModal(false)
}
}
</script>

View File

@@ -0,0 +1,144 @@
<template>
<IconTextButton
v-bind="$attrs"
type="transparent"
:label="computedLabel"
:border="true"
:size="size"
:disabled="isLoading || isInstalling"
@click="installAllPacks"
>
<template #icon>
<i
v-if="hasConflict && !isInstalling && !isLoading"
class="pi pi-exclamation-triangle text-yellow-500"
/>
<DotSpinner
v-else-if="isLoading || isInstalling"
duration="1s"
:size="size === 'sm' ? 12 : 16"
/>
</template>
</IconTextButton>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { t } from '@/i18n'
import { useDialogService } from '@/services/dialogService'
import type { ButtonSize } from '@/types/buttonTypes'
import type { components } from '@/types/comfyRegistryTypes'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import type { ConflictDetail } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import type { components as ManagerComponents } from '@/workbench/extensions/manager/types/generatedManagerTypes'
type NodePack = components['schemas']['Node']
const {
nodePacks,
isLoading = false,
label = 'Install',
size = 'sm',
hasConflict,
conflictInfo
} = defineProps<{
nodePacks: NodePack[]
isLoading?: boolean
label?: string
size?: ButtonSize
hasConflict?: boolean
conflictInfo?: ConflictDetail[]
}>()
const managerStore = useComfyManagerStore()
const { showNodeConflictDialog } = useDialogService()
// Check if any of the packs are currently being installed
const isInstalling = computed(() => {
if (!nodePacks?.length) return false
return nodePacks.some((pack) => managerStore.isPackInstalling(pack.id))
})
const createPayload = (installItem: NodePack) => {
if (!installItem.id) {
throw new Error('Node ID is required for installation')
}
const isUnclaimedPack = installItem.publisher?.name === 'Unclaimed'
const versionToInstall = isUnclaimedPack
? ('nightly' as ManagerComponents['schemas']['SelectedVersion'])
: installItem.latest_version?.version ??
('latest' as ManagerComponents['schemas']['SelectedVersion'])
return {
id: installItem.id,
repository: installItem.repository ?? '',
channel: 'dev' as ManagerComponents['schemas']['ManagerChannel'],
mode: 'cache' as ManagerComponents['schemas']['ManagerDatabaseSource'],
selected_version: versionToInstall,
version: versionToInstall
}
}
const installPack = (item: NodePack) =>
managerStore.installPack.call(createPayload(item))
const installAllPacks = async () => {
if (!nodePacks?.length) return
if (hasConflict && conflictInfo) {
// Check each package individually for conflicts
const { checkNodeCompatibility } = useConflictDetection()
const conflictedPackages: ConflictDetectionResult[] = nodePacks
.map((pack) => {
const compatibilityCheck = checkNodeCompatibility(pack)
return {
package_id: pack.id || '',
package_name: pack.name || '',
has_conflict: compatibilityCheck.hasConflict,
conflicts: compatibilityCheck.conflicts,
is_compatible: !compatibilityCheck.hasConflict
}
})
.filter((result) => result.has_conflict) // Only show packages with conflicts
showNodeConflictDialog({
conflictedPackages,
buttonText: t('manager.conflicts.installAnyway'),
onButtonClick: async () => {
// Proceed with installation of uninstalled packages
const uninstalledPacks = nodePacks.filter(
(pack) => !managerStore.isPackInstalled(pack.id)
)
if (!uninstalledPacks.length) return
await performInstallation(uninstalledPacks)
}
})
return
}
// No conflicts or conflicts acknowledged - proceed with installation
const uninstalledPacks = nodePacks.filter(
(pack) => !managerStore.isPackInstalled(pack.id)
)
if (!uninstalledPacks.length) return
await performInstallation(uninstalledPacks)
}
const performInstallation = async (packs: NodePack[]) => {
await Promise.all(packs.map(installPack))
managerStore.installPack.clear()
}
const computedLabel = computed(() =>
isInstalling.value
? t('g.installing')
: label ??
(nodePacks.length > 1 ? t('manager.installSelected') : t('g.install'))
)
</script>

View File

@@ -0,0 +1,53 @@
<template>
<IconTextButton
v-bind="$attrs"
type="transparent"
:label="
nodePacks.length > 1
? $t('manager.uninstallSelected')
: $t('manager.uninstall')
"
:border="true"
:size="size"
class="border-red-500"
@click="uninstallItems"
/>
</template>
<script setup lang="ts">
import IconTextButton from '@/components/button/IconTextButton.vue'
import type { ButtonSize } from '@/types/buttonTypes'
import type { components } from '@/types/comfyRegistryTypes'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import type { components as ManagerComponents } from '@/workbench/extensions/manager/types/generatedManagerTypes'
type NodePack = components['schemas']['Node']
const { nodePacks, size } = defineProps<{
nodePacks: NodePack[]
size?: ButtonSize
}>()
const managerStore = useComfyManagerStore()
const createPayload = (
uninstallItem: NodePack
): ManagerComponents['schemas']['ManagerPackInfo'] => {
if (!uninstallItem.id) {
throw new Error('Node ID is required for uninstallation')
}
return {
id: uninstallItem.id,
version: uninstallItem.latest_version?.version || 'unknown'
}
}
const uninstallPack = (item: NodePack) =>
managerStore.uninstallPack(createPayload(item))
const uninstallItems = async () => {
if (!nodePacks?.length) return
await Promise.all(nodePacks.map(uninstallPack))
}
</script>

View File

@@ -0,0 +1,83 @@
<template>
<IconTextButton
v-tooltip.top="
hasDisabledUpdatePacks ? $t('manager.disabledNodesWontUpdate') : null
"
v-bind="$attrs"
type="transparent"
:label="$t('manager.updateAll')"
:border="true"
size="sm"
:disabled="isUpdating"
@click="updateAllPacks"
>
<template v-if="isUpdating" #icon>
<DotSpinner duration="1s" :size="12" />
</template>
</IconTextButton>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import type { components } from '@/types/comfyRegistryTypes'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
type NodePack = components['schemas']['Node']
const { nodePacks, hasDisabledUpdatePacks } = defineProps<{
nodePacks: NodePack[]
hasDisabledUpdatePacks?: boolean
}>()
const isUpdating = ref<boolean>(false)
const managerStore = useComfyManagerStore()
const createPayload = (updateItem: NodePack) => {
return {
id: updateItem.id!,
version: updateItem.latest_version!.version!
}
}
const updatePack = async (item: NodePack) => {
if (!item.id || !item.latest_version?.version) {
console.warn('Pack missing required id or version:', item)
return
}
await managerStore.updatePack.call(createPayload(item))
}
const updateAllPacks = async () => {
if (!nodePacks?.length) {
console.warn('No packs provided for update')
return
}
isUpdating.value = true
const updatablePacks = nodePacks.filter((pack) =>
managerStore.isPackInstalled(pack.id)
)
if (!updatablePacks.length) {
console.info('No installed packs available for update')
isUpdating.value = false
return
}
console.info(`Starting update of ${updatablePacks.length} packs`)
try {
await Promise.all(updatablePacks.map(updatePack))
managerStore.updatePack.clear()
console.info('All packs updated successfully')
} catch (error) {
console.error('Pack update failed:', error)
console.error(
'Failed packs info:',
updatablePacks.map((p) => p.id)
)
} finally {
isUpdating.value = false
}
}
</script>

View File

@@ -0,0 +1,183 @@
<template>
<template v-if="nodePack">
<div class="flex flex-col h-full z-40 overflow-hidden relative">
<div class="top-0 z-10 px-6 pt-6 w-full">
<InfoPanelHeader
:node-packs="[nodePack]"
:has-conflict="hasCompatibilityIssues"
/>
</div>
<div
ref="scrollContainer"
class="p-6 pt-2 overflow-y-auto flex-1 text-sm scrollbar-hide"
>
<div class="mb-6">
<MetadataRow
v-if="!importFailed && isPackInstalled(nodePack.id)"
:label="t('manager.filter.enabled')"
class="flex"
style="align-items: center"
>
<PackEnableToggle
:node-pack="nodePack"
:has-conflict="hasCompatibilityIssues"
/>
</MetadataRow>
<MetadataRow
v-for="item in infoItems"
v-show="item.value !== undefined && item.value !== null"
:key="item.key"
:label="item.label"
:value="item.value"
/>
<MetadataRow :label="t('g.status')">
<PackStatusMessage
:status-type="
nodePack.status as components['schemas']['NodeVersionStatus']
"
:has-compatibility-issues="hasCompatibilityIssues"
/>
</MetadataRow>
<MetadataRow :label="t('manager.version')">
<PackVersionBadge :node-pack="nodePack" :is-selected="true" />
</MetadataRow>
</div>
<div class="mb-6 overflow-hidden">
<InfoTabs
:node-pack="nodePack"
:has-compatibility-issues="hasCompatibilityIssues"
:conflict-result="conflictResult"
/>
</div>
</div>
</div>
</template>
<template v-else>
<div class="pt-4 px-8 flex-1 overflow-hidden text-sm">
{{ $t('manager.infoPanelEmpty') }}
</div>
</template>
</template>
<script setup lang="ts">
import { useScroll, whenever } from '@vueuse/core'
import { computed, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { components } from '@/types/comfyRegistryTypes'
import PackStatusMessage from '@/workbench/extensions/manager/components/manager/PackStatusMessage.vue'
import PackVersionBadge from '@/workbench/extensions/manager/components/manager/PackVersionBadge.vue'
import PackEnableToggle from '@/workbench/extensions/manager/components/manager/button/PackEnableToggle.vue'
import InfoPanelHeader from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanelHeader.vue'
import InfoTabs from '@/workbench/extensions/manager/components/manager/infoPanel/InfoTabs.vue'
import MetadataRow from '@/workbench/extensions/manager/components/manager/infoPanel/MetadataRow.vue'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useImportFailedDetection } from '@/workbench/extensions/manager/composables/useImportFailedDetection'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
import { IsInstallingKey } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import { ImportFailedKey } from '@/workbench/extensions/manager/types/importFailedTypes'
interface InfoItem {
key: string
label: string
value: string | number | undefined
}
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const scrollContainer = ref<HTMLElement | null>(null)
const { isPackInstalled } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack.id))
const isInstalling = ref(false)
provide(IsInstallingKey, isInstalling)
whenever(isInstalled, () => {
isInstalling.value = false
})
const { checkNodeCompatibility } = useConflictDetection()
const { getConflictsForPackageByID } = useConflictDetectionStore()
const { t, d, n } = useI18n()
// Check compatibility once and pass to children
const conflictResult = computed((): ConflictDetectionResult | null => {
// For installed packages, use stored conflict data
if (isInstalled.value && nodePack.id) {
return getConflictsForPackageByID(nodePack.id) || null
}
// For non-installed packages, perform compatibility check
const compatibility = checkNodeCompatibility(nodePack)
if (compatibility.hasConflict) {
return {
package_id: nodePack.id || '',
package_name: nodePack.name || '',
has_conflict: true,
conflicts: compatibility.conflicts,
is_compatible: false
}
}
return null
})
const hasCompatibilityIssues = computed(() => {
return conflictResult.value?.has_conflict
})
const packageId = computed(() => nodePack.id || '')
const { importFailed, showImportFailedDialog } =
useImportFailedDetection(packageId)
provide(ImportFailedKey, {
importFailed,
showImportFailedDialog
})
const infoItems = computed<InfoItem[]>(() => [
{
key: 'publisher',
label: t('manager.createdBy'),
value: nodePack.publisher?.name ?? nodePack.publisher?.id
},
{
key: 'downloads',
label: t('manager.downloads'),
value: nodePack.downloads ? n(nodePack.downloads) : undefined
},
{
key: 'lastUpdated',
label: t('manager.lastUpdated'),
value: nodePack.latest_version?.createdAt
? d(nodePack.latest_version.createdAt, {
dateStyle: 'medium'
})
: undefined
}
])
const { y } = useScroll(scrollContainer, {
eventListenerOptions: {
passive: true
}
})
const onNodePackChange = () => {
y.value = 0
}
whenever(
() => nodePack.id,
(nodePackId, oldNodePackId) => {
if (nodePackId !== oldNodePackId) {
onNodePackChange()
}
},
{ immediate: true }
)
</script>

View File

@@ -0,0 +1,97 @@
<template>
<div v-if="nodePacks?.length" class="flex flex-col items-center">
<slot name="thumbnail">
<PackIcon :node-pack="nodePacks[0]" width="204" height="106" />
</slot>
<h2
class="text-2xl font-bold text-center mt-4 mb-2"
style="word-break: break-all"
>
<slot name="title">
<span class="inline-block text-base">{{ nodePacks[0].name }}</span>
</slot>
</h2>
<div
v-if="!importFailed"
class="mt-2 mb-4 w-full max-w-xs flex justify-center"
>
<slot name="install-button">
<PackUninstallButton
v-if="isAllInstalled"
v-bind="$attrs"
size="md"
:node-packs="nodePacks"
/>
<PackInstallButton
v-else
v-bind="$attrs"
size="md"
:node-packs="nodePacks"
:has-conflict="hasConflict || computedHasConflict"
:conflict-info="conflictInfo"
/>
</slot>
</div>
</div>
<div v-else class="flex flex-col items-center">
<NoResultsPlaceholder
:message="$t('manager.status.unknown')"
:title="$t('manager.tryAgainLater')"
/>
</div>
</template>
<script setup lang="ts">
import { computed, inject, ref, watch } from 'vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import type { components } from '@/types/comfyRegistryTypes'
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
import PackUninstallButton from '@/workbench/extensions/manager/components/manager/button/PackUninstallButton.vue'
import PackIcon from '@/workbench/extensions/manager/components/manager/packIcon/PackIcon.vue'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import type { ConflictDetail } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import { ImportFailedKey } from '@/workbench/extensions/manager/types/importFailedTypes'
const { nodePacks, hasConflict } = defineProps<{
nodePacks: components['schemas']['Node'][]
hasConflict?: boolean
}>()
const managerStore = useComfyManagerStore()
// Inject import failed context from parent
const importFailedContext = inject(ImportFailedKey)
const importFailed = importFailedContext?.importFailed
const isAllInstalled = ref(false)
watch(
[() => nodePacks, () => managerStore.installedPacks],
() => {
isAllInstalled.value = nodePacks.every((nodePack) =>
managerStore.isPackInstalled(nodePack.id)
)
},
{ immediate: true }
)
// Add conflict detection for install button dialog
const { checkNodeCompatibility } = useConflictDetection()
// Compute conflict info for all node packs
const conflictInfo = computed<ConflictDetail[]>(() => {
if (!nodePacks?.length) return []
const allConflicts: ConflictDetail[] = []
for (const nodePack of nodePacks) {
const compatibilityCheck = checkNodeCompatibility(nodePack)
if (compatibilityCheck.conflicts) {
allConflicts.push(...compatibilityCheck.conflicts)
}
}
return allConflicts
})
const computedHasConflict = computed(() => conflictInfo.value.length > 0)
</script>

View File

@@ -0,0 +1,163 @@
<template>
<div v-if="nodePacks?.length" class="flex flex-col h-full">
<div class="p-6 flex-1 overflow-auto">
<InfoPanelHeader :node-packs>
<template #thumbnail>
<PackIconStacked :node-packs="nodePacks" />
</template>
<template #title>
<div class="mt-5">
<span class="inline-block mr-2 text-blue-500 text-base">{{
nodePacks.length
}}</span>
<span class="text-base">{{ $t('manager.packsSelected') }}</span>
</div>
</template>
<template #install-button>
<!-- Mixed: Don't show any button -->
<div v-if="isMixed" class="text-sm text-neutral-500">
{{ $t('manager.mixedSelectionMessage') }}
</div>
<!-- All installed: Show uninstall button -->
<PackUninstallButton
v-else-if="isAllInstalled"
size="md"
:node-packs="installedPacks"
/>
<!-- None installed: Show install button -->
<PackInstallButton
v-else-if="isNoneInstalled"
size="md"
:node-packs="notInstalledPacks"
:has-conflict="hasConflicts"
:conflict-info="conflictInfo"
/>
</template>
</InfoPanelHeader>
<div class="mb-6">
<MetadataRow :label="$t('g.status')">
<PackStatusMessage
:status-type="overallStatus"
:has-compatibility-issues="hasConflicts"
/>
</MetadataRow>
<MetadataRow
:label="$t('manager.totalNodes')"
:value="totalNodesCount"
/>
</div>
</div>
</div>
<div v-else class="mt-4 mx-8 flex-1 overflow-hidden text-sm">
{{ $t('manager.infoPanelEmpty') }}
</div>
</template>
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import { computed, onUnmounted, provide, toRef } from 'vue'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { components } from '@/types/comfyRegistryTypes'
import PackStatusMessage from '@/workbench/extensions/manager/components/manager/PackStatusMessage.vue'
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
import PackUninstallButton from '@/workbench/extensions/manager/components/manager/button/PackUninstallButton.vue'
import InfoPanelHeader from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanelHeader.vue'
import MetadataRow from '@/workbench/extensions/manager/components/manager/infoPanel/MetadataRow.vue'
import PackIconStacked from '@/workbench/extensions/manager/components/manager/packIcon/PackIconStacked.vue'
import { usePacksSelection } from '@/workbench/extensions/manager/composables/nodePack/usePacksSelection'
import { usePacksStatus } from '@/workbench/extensions/manager/composables/nodePack/usePacksStatus'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import type { ConflictDetail } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import { ImportFailedKey } from '@/workbench/extensions/manager/types/importFailedTypes'
const { nodePacks } = defineProps<{
nodePacks: components['schemas']['Node'][]
}>()
const nodePacksRef = toRef(() => nodePacks)
// Use new composables for cleaner code
const {
installedPacks,
notInstalledPacks,
isAllInstalled,
isNoneInstalled,
isMixed
} = usePacksSelection(nodePacksRef)
const { hasImportFailed, overallStatus } = usePacksStatus(nodePacksRef)
const { checkNodeCompatibility } = useConflictDetection()
const { getNodeDefs } = useComfyRegistryStore()
// Provide import failed context for PackStatusMessage
provide(ImportFailedKey, {
importFailed: hasImportFailed,
showImportFailedDialog: () => {} // No-op for multi-selection
})
// Check for conflicts in not-installed packages - keep original logic but simplified
const packageConflicts = computed(() => {
const conflictsByPackage = new Map<string, ConflictDetail[]>()
for (const pack of notInstalledPacks.value) {
const compatibilityCheck = checkNodeCompatibility(pack)
if (compatibilityCheck.hasConflict && pack.id) {
conflictsByPackage.set(pack.id, compatibilityCheck.conflicts)
}
}
return conflictsByPackage
})
// Aggregate all unique conflicts for display
const conflictInfo = computed<ConflictDetail[]>(() => {
const conflictMap = new Map<string, ConflictDetail>()
packageConflicts.value.forEach((conflicts) => {
conflicts.forEach((conflict) => {
const key = `${conflict.type}-${conflict.current_value}-${conflict.required_value}`
if (!conflictMap.has(key)) {
conflictMap.set(key, conflict)
}
})
})
return Array.from(conflictMap.values())
})
const hasConflicts = computed(() => conflictInfo.value.length > 0)
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
})
return nodeDefs?.comfy_nodes ?? []
}
const { state: allNodeDefs } = useAsyncState(
() => Promise.all(nodePacks.map(getPackNodes)),
[],
{
immediate: true
}
)
const totalNodesCount = computed(() =>
allNodeDefs.value.reduce(
(total, nodeDefs) => total + (nodeDefs?.length || 0),
0
)
)
onUnmounted(() => {
getNodeDefs.cancel()
})
</script>

View File

@@ -0,0 +1,85 @@
<template>
<div class="overflow-hidden">
<Tabs :value="activeTab">
<TabList class="overflow-x-auto scrollbar-hide">
<Tab v-if="hasCompatibilityIssues" value="warning" class="p-2 mr-6">
<div class="flex items-center gap-1">
<span></span>
{{ importFailed ? $t('g.error') : $t('g.warning') }}
</div>
</Tab>
<Tab value="description" class="p-2 mr-6">
{{ $t('g.description') }}
</Tab>
<Tab value="nodes" class="p-2">
{{ $t('g.nodes') }}
</Tab>
</TabList>
<TabPanels class="overflow-auto py-4 px-2">
<TabPanel
v-if="hasCompatibilityIssues"
value="warning"
class="bg-transparent"
>
<WarningTabPanel
:node-pack="nodePack"
:conflict-result="conflictResult"
/>
</TabPanel>
<TabPanel value="description">
<DescriptionTabPanel :node-pack="nodePack" />
</TabPanel>
<TabPanel value="nodes">
<NodesTabPanel :node-pack="nodePack" :node-names="nodeNames" />
</TabPanel>
</TabPanels>
</Tabs>
</div>
</template>
<script setup lang="ts">
import Tab from 'primevue/tab'
import TabList from 'primevue/tablist'
import TabPanel from 'primevue/tabpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { computed, inject, ref, watchEffect } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import DescriptionTabPanel from '@/workbench/extensions/manager/components/manager/infoPanel/tabs/DescriptionTabPanel.vue'
import NodesTabPanel from '@/workbench/extensions/manager/components/manager/infoPanel/tabs/NodesTabPanel.vue'
import WarningTabPanel from '@/workbench/extensions/manager/components/manager/infoPanel/tabs/WarningTabPanel.vue'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import { ImportFailedKey } from '@/workbench/extensions/manager/types/importFailedTypes'
const { nodePack, hasCompatibilityIssues, conflictResult } = defineProps<{
nodePack: components['schemas']['Node']
hasCompatibilityIssues?: boolean
conflictResult?: ConflictDetectionResult | null
}>()
// Inject import failed context from parent
const importFailedContext = inject(ImportFailedKey)
const importFailed = importFailedContext?.importFailed
const nodeNames = computed(() => {
// @ts-expect-error comfy_nodes is an Algolia-specific field
const { comfy_nodes } = nodePack
return comfy_nodes ?? []
})
const activeTab = ref('description')
// Watch for compatibility issues and automatically switch to warning tab
watchEffect(
() => {
if (hasCompatibilityIssues) {
activeTab.value = 'warning'
} else if (activeTab.value === 'warning') {
// If currently on warning tab but no issues, switch to description
activeTab.value = 'description'
}
},
{ flush: 'post' }
)
</script>

View File

@@ -0,0 +1,38 @@
<template>
<div class="flex flex-col gap-4 text-sm">
<div v-for="(section, index) in sections" :key="index" class="mb-4">
<div class="mb-3">
{{ section.title }}
</div>
<div class="text-muted break-words">
<a
v-if="section.isUrl"
:href="section.text"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2"
>
<i v-if="isGitHubLink(section.text)" class="pi pi-github text-base" />
<span class="break-all">{{ section.text }}</span>
</a>
<MarkdownText v-else :text="section.text" class="text-muted" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import MarkdownText from '@/workbench/extensions/manager/components/manager/infoPanel/MarkdownText.vue'
export interface TextSection {
title: string
text: string
isUrl?: boolean
}
defineProps<{
sections: TextSection[]
}>()
const isGitHubLink = (url: string): boolean => url.includes('github.com')
</script>

View File

@@ -0,0 +1,108 @@
<template>
<div>
<div v-if="!hasMarkdown" class="break-words" v-text="text" />
<div v-else class="break-words">
<template v-for="(segment, index) in parsedSegments" :key="index">
<a
v-if="segment.type === 'link' && 'url' in segment"
:href="segment.url"
target="_blank"
rel="noopener noreferrer"
class="hover:underline"
>
<span class="text-blue-600">{{ segment.text }}</span>
</a>
<strong v-else-if="segment.type === 'bold'">{{ segment.text }}</strong>
<em v-else-if="segment.type === 'italic'">{{ segment.text }}</em>
<code
v-else-if="segment.type === 'code'"
class="px-1 py-0.5 rounded text-xs"
>{{ segment.text }}</code
>
<span v-else>{{ segment.text }}</span>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const { text } = defineProps<{
text: string
}>()
type MarkdownSegment = {
type: 'text' | 'link' | 'bold' | 'italic' | 'code'
text: string
url?: string
}
const hasMarkdown = computed(() => {
const hasMarkdown =
/(\[.*?\]\(.*?\)|(\*\*|__)(.*?)(\*\*|__)|(\*|_)(.*?)(\*|_)|`(.*?)`)/.test(
text
)
return hasMarkdown
})
const parsedSegments = computed(() => {
if (!hasMarkdown.value) return [{ type: 'text', text }]
const segments: MarkdownSegment[] = []
const remainingText = text
let lastIndex: number = 0
const linkRegex = /\[(.*?)\]\((.*?)\)/g
let linkMatch: RegExpExecArray | null
while ((linkMatch = linkRegex.exec(remainingText)) !== null) {
// Add text before the match
if (linkMatch.index > lastIndex) {
segments.push({
type: 'text',
text: remainingText.substring(lastIndex, linkMatch.index)
})
}
// Add the link
segments.push({
type: 'link',
text: linkMatch[1],
url: linkMatch[2]
})
lastIndex = linkMatch.index + linkMatch[0].length
}
// Add remaining text after all links
if (lastIndex < remainingText.length) {
let rest = remainingText.substring(lastIndex)
// Process bold text
rest = rest.replace(/(\*\*|__)(.*?)(\*\*|__)/g, (_, __, p2) => {
segments.push({ type: 'bold', text: p2 })
return ''
})
// Process italic text
rest = rest.replace(/(\*|_)(.*?)(\*|_)/g, (_, __, p2) => {
segments.push({ type: 'italic', text: p2 })
return ''
})
// Process code
rest = rest.replace(/`(.*?)`/g, (_, p1) => {
segments.push({ type: 'code', text: p1 })
return ''
})
// Add any remaining text
if (rest) {
segments.push({ type: 'text', text: rest })
}
}
return segments
})
</script>

View File

@@ -0,0 +1,15 @@
<template>
<div class="flex py-1.5 text-xs">
<div class="w-1/3 truncate pr-2 text-muted">{{ label }}</div>
<div class="w-2/3">
<slot>{{ value }}</slot>
</div>
</div>
</template>
<script setup lang="ts">
const { value = 'N/A', label } = defineProps<{
label: string
value?: string | number
}>()
</script>

View File

@@ -0,0 +1,179 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { components } from '@/types/comfyRegistryTypes'
import DescriptionTabPanel from './DescriptionTabPanel.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: enMessages
}
})
const TRANSLATIONS = {
description: 'Description',
repository: 'Repository',
license: 'License',
noDescription: 'No description available'
}
describe('DescriptionTabPanel', () => {
const mountComponent = (props: {
nodePack: Partial<components['schemas']['Node']>
}) => {
return mount(DescriptionTabPanel, {
props,
global: {
plugins: [i18n]
}
})
}
const getSectionByTitle = (
wrapper: ReturnType<typeof mountComponent>,
title: string
) => {
const sections = wrapper
.findComponent({ name: 'InfoTextSection' })
.props('sections')
return sections.find((s: any) => s.title === title)
}
const createNodePack = (
overrides: Partial<components['schemas']['Node']> = {}
) => ({
description: 'Test description',
...overrides
})
const licenseTests = [
{
name: 'handles plain text license',
nodePack: createNodePack({
license: 'MIT License',
repository: 'https://github.com/user/repo'
}),
expected: {
text: 'MIT License',
isUrl: false
}
},
{
name: 'handles license file names',
nodePack: createNodePack({
license: 'LICENSE',
repository: 'https://github.com/user/repo'
}),
expected: {
text: 'https://github.com/user/repo/blob/main/LICENSE',
isUrl: true
}
},
{
name: 'handles license.md file names',
nodePack: createNodePack({
license: 'license.md',
repository: 'https://github.com/user/repo'
}),
expected: {
text: 'https://github.com/user/repo/blob/main/license.md',
isUrl: true
}
},
{
name: 'handles JSON license objects with text property',
nodePack: createNodePack({
license: JSON.stringify({ text: 'GPL-3.0' }),
repository: 'https://github.com/user/repo'
}),
expected: {
text: 'GPL-3.0',
isUrl: false
}
},
{
name: 'handles JSON license objects with file property',
nodePack: createNodePack({
license: JSON.stringify({ file: 'LICENSE.md' }),
repository: 'https://github.com/user/repo'
}),
expected: {
text: 'https://github.com/user/repo/blob/main/LICENSE.md',
isUrl: true
}
},
{
name: 'handles missing repository URL',
nodePack: createNodePack({
license: 'LICENSE'
}),
expected: {
text: 'LICENSE',
isUrl: false
}
},
{
name: 'handles non-GitHub repository URLs',
nodePack: createNodePack({
license: 'LICENSE',
repository: 'https://gitlab.com/user/repo'
}),
expected: {
text: 'https://gitlab.com/user/repo/blob/main/LICENSE',
isUrl: true
}
}
]
describe('license formatting', () => {
licenseTests.forEach((test) => {
it(test.name, () => {
const wrapper = mountComponent({ nodePack: test.nodePack })
const licenseSection = getSectionByTitle(wrapper, TRANSLATIONS.license)
expect(licenseSection).toBeDefined()
expect(licenseSection.text).toBe(test.expected.text)
expect(licenseSection.isUrl).toBe(test.expected.isUrl)
})
})
})
describe('description sections', () => {
it('shows description section', () => {
const wrapper = mountComponent({
nodePack: createNodePack()
})
const descriptionSection = getSectionByTitle(
wrapper,
TRANSLATIONS.description
)
expect(descriptionSection).toBeDefined()
expect(descriptionSection.text).toBe('Test description')
})
it('shows repository section when available', () => {
const wrapper = mountComponent({
nodePack: createNodePack({
repository: 'https://github.com/user/repo'
})
})
const repoSection = getSectionByTitle(wrapper, TRANSLATIONS.repository)
expect(repoSection).toBeDefined()
expect(repoSection.text).toBe('https://github.com/user/repo')
expect(repoSection.isUrl).toBe(true)
})
it('shows fallback text when description is missing', () => {
const wrapper = mountComponent({
nodePack: {
description: undefined
}
})
expect(wrapper.find('p').text()).toBe(TRANSLATIONS.noDescription)
})
})
})

View File

@@ -0,0 +1,151 @@
<template>
<div class="overflow-hidden">
<InfoTextSection
v-if="nodePack?.description"
:sections="descriptionSections"
/>
<p v-else class="text-muted italic text-sm">
{{ $t('manager.noDescription') }}
</p>
<div v-if="nodePack?.latest_version?.dependencies?.length">
<p class="mb-1">
{{ $t('manager.dependencies') }}
</p>
<div
v-for="(dep, index) in nodePack.latest_version.dependencies"
:key="index"
class="text-muted break-words"
>
{{ dep }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { components } from '@/types/comfyRegistryTypes'
import { isValidUrl } from '@/utils/formatUtil'
import InfoTextSection, {
type TextSection
} from '@/workbench/extensions/manager/components/manager/infoPanel/InfoTextSection.vue'
const { t } = useI18n()
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const isLicenseFile = (filename: string): boolean => {
// Match LICENSE, LICENSE.md, LICENSE.txt (case insensitive)
const licensePattern = /^license(\.md|\.txt)?$/i
return licensePattern.test(filename)
}
const extractBaseRepoUrl = (repoUrl: string): string => {
const githubRepoPattern = /^(https?:\/\/github\.com\/[^/]+\/[^/]+)/i
const match = repoUrl.match(githubRepoPattern)
return match ? match[1] : repoUrl
}
const createLicenseUrl = (filename: string, repoUrl: string): string => {
if (!repoUrl || !filename) return ''
const licenseFile = isLicenseFile(filename) ? filename : 'LICENSE'
const baseRepoUrl = extractBaseRepoUrl(repoUrl)
return `${baseRepoUrl}/blob/main/${licenseFile}`
}
const parseLicenseObject = (
licenseObj: any
): { text: string; isUrl: boolean } => {
const licenseFile = licenseObj.file || licenseObj.text
if (
typeof licenseFile === 'string' &&
isLicenseFile(licenseFile) &&
nodePack.repository
) {
const url = createLicenseUrl(licenseFile, nodePack.repository)
return {
text: url,
isUrl: !!url && isValidUrl(url)
}
} else if (licenseObj.text) {
return {
text: licenseObj.text,
isUrl: false
}
} else if (typeof licenseFile === 'string') {
// Return the license file name if repository is missing
return {
text: licenseFile,
isUrl: false
}
}
return {
text: JSON.stringify(licenseObj),
isUrl: false
}
}
const formatLicense = (
license: string
): { text: string; isUrl: boolean } | null => {
// Treat "{}" JSON string as undefined
if (license === '{}') return null
try {
const licenseObj = JSON.parse(license)
// Handle empty object case
if (Object.keys(licenseObj).length === 0) {
return null
}
return parseLicenseObject(licenseObj)
} catch (e) {
if (isLicenseFile(license) && nodePack.repository) {
const url = createLicenseUrl(license, nodePack.repository)
return {
text: url,
isUrl: !!url && isValidUrl(url)
}
}
return {
text: license,
isUrl: false
}
}
}
const descriptionSections = computed<TextSection[]>(() => {
const sections: TextSection[] = [
{
title: t('g.description'),
text: nodePack.description || t('manager.noDescription')
}
]
if (nodePack.repository) {
sections.push({
title: t('manager.repository'),
text: nodePack.repository,
isUrl: isValidUrl(nodePack.repository)
})
}
if (nodePack.license) {
const licenseInfo = formatLicense(nodePack.license)
if (licenseInfo && licenseInfo.text) {
sections.push({
title: t('manager.license'),
text: licenseInfo.text,
isUrl: licenseInfo.isUrl
})
}
}
return sections
})
</script>

View File

@@ -0,0 +1,93 @@
<template>
<div class="flex flex-col gap-4 text-sm">
<template v-if="mappedNodeDefs?.length">
<div
v-for="nodeDef in mappedNodeDefs"
:key="createNodeDefKey(nodeDef)"
class="border rounded-lg p-4"
>
<NodePreview :node-def="nodeDef" class="text-[.625rem]! min-w-full!" />
</div>
</template>
<template v-else-if="isLoading">
<ProgressSpinner />
</template>
<template v-else-if="nodeNames.length">
<div v-for="node in nodeNames" :key="node" class="text-muted truncate">
{{ node }}
</div>
</template>
<template v-else>
<NoResultsPlaceholder
:title="$t('manager.noNodesFound')"
:message="$t('manager.noNodesFoundDescription')"
/>
</template>
</div>
</template>
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref, shallowRef, useId } from 'vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import NodePreview from '@/components/node/NodePreview.vue'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { registryToFrontendV2NodeDef } from '@/utils/mapperUtil'
type ListComfyNodesResponse =
operations['ListComfyNodes']['responses'][200]['content']['application/json']['comfy_nodes']
const { nodePack, nodeNames } = defineProps<{
nodePack: components['schemas']['Node']
nodeNames: string[]
}>()
const { getNodeDefs } = useComfyRegistryStore()
const isLoading = ref(false)
const registryNodeDefs = shallowRef<ListComfyNodesResponse | null>(null)
const fetchNodeDefs = async () => {
getNodeDefs.cancel()
isLoading.value = true
const { id: packId } = nodePack
const version = nodePack.latest_version?.version
if (!packId || !version) {
registryNodeDefs.value = null
} else {
const response = await getNodeDefs.call({
packId,
version,
page: 1,
limit: 256
})
registryNodeDefs.value = response?.comfy_nodes ?? null
}
isLoading.value = false
}
whenever(() => nodePack, fetchNodeDefs, { immediate: true, deep: true })
const toFrontendNodeDef = (nodeDef: components['schemas']['ComfyNode']) => {
try {
return registryToFrontendV2NodeDef(nodeDef, nodePack)
} catch (error) {
return null
}
}
const mappedNodeDefs = computed(() => {
if (!registryNodeDefs.value) return null
return registryNodeDefs.value
.map(toFrontendNodeDef)
.filter((nodeDef) => nodeDef !== null)
})
const createNodeDefKey = (nodeDef: components['schemas']['ComfyNode']) =>
`${nodeDef.category}${nodeDef.comfy_node_name ?? useId()}`
</script>

View File

@@ -0,0 +1,43 @@
<template>
<div class="flex flex-col gap-3">
<button
v-if="importFailedInfo"
class="cursor-pointer outline-none border-none inline-flex items-center justify-end bg-transparent gap-1"
@click="showImportFailedDialog"
>
<i class="pi pi-code text-base"></i>
<span class="dark-theme:text-white text-sm">{{
t('serverStart.openLogs')
}}</span>
</button>
<div
v-for="(conflict, index) in conflictResult?.conflicts || []"
:key="index"
class="p-3 bg-yellow-800/20 rounded-md"
>
<div class="flex justify-between items-center">
<div class="text-sm break-words flex-1">
{{ getConflictMessage(conflict, $t) }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { t } from '@/i18n'
import type { components } from '@/types/comfyRegistryTypes'
import { useImportFailedDetection } from '@/workbench/extensions/manager/composables/useImportFailedDetection'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import { getConflictMessage } from '@/workbench/extensions/manager/utils/conflictMessageUtil'
const { nodePack, conflictResult } = defineProps<{
nodePack: components['schemas']['Node']
conflictResult: ConflictDetectionResult | null | undefined
}>()
const packageId = computed(() => nodePack?.id || '')
const { importFailedInfo, showImportFailedDialog } =
useImportFailedDetection(packageId)
</script>

View File

@@ -0,0 +1,52 @@
<template>
<div class="w-full aspect-7/3 overflow-hidden">
<!-- default banner show -->
<div v-if="showDefaultBanner" class="w-full h-full">
<img
:src="DEFAULT_BANNER"
alt="default banner"
class="w-full h-full object-cover"
/>
</div>
<!-- banner_url or icon show -->
<div v-else class="relative w-full h-full">
<!-- blur background -->
<div
v-if="imgSrc"
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
:style="{
backgroundImage: `url(${imgSrc})`,
filter: 'blur(10px)'
}"
></div>
<!-- image -->
<img
:src="isImageError ? DEFAULT_BANNER : imgSrc"
:alt="nodePack.name + ' banner'"
:class="
isImageError
? 'relative w-full h-full object-cover z-10'
: 'relative w-full h-full object-contain z-10'
"
@error="isImageError = true"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const isImageError = ref(false)
const showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon)
</script>

View File

@@ -0,0 +1,149 @@
<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="{
'selected-card': 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' },
title: { class: 'w-full h-full rounded-t-lg cursor-pointer' },
footer: {
class: 'p-0 m-0 flex flex-col gap-0',
style: {
borderTop: isLightTheme ? '1px solid #f4f4f4' : '1px solid #2C2C2C'
}
}
}"
>
<template #title>
<PackBanner :node-pack="nodePack" />
</template>
<template #content>
<div class="pt-4 px-4 pb-3 w-full h-full">
<div class="flex flex-col gap-y-1 w-full h-full">
<span
class="text-sm font-bold truncate overflow-hidden text-ellipsis"
>
{{ nodePack.name }}
</span>
<p
v-if="nodePack.description"
class="flex-1 text-muted text-xs font-medium break-words overflow-hidden min-h-12 line-clamp-3 my-0 leading-4 mb-1 overflow-hidden"
>
{{ nodePack.description }}
</p>
<div class="flex flex-col gap-y-2">
<div class="flex-1 flex items-center gap-2">
<div v-if="nodesCount" class="p-2 pl-0 text-xs">
{{ nodesCount }} {{ $t('g.nodes') }}
</div>
<PackVersionBadge
:node-pack="nodePack"
:is-selected="isSelected"
:fill="false"
:class="isInstalling ? 'pointer-events-none' : ''"
/>
<div
v-if="formattedLatestVersionDate"
class="px-2 py-1 flex justify-center items-center gap-1 text-xs text-muted font-medium"
>
{{ formattedLatestVersionDate }}
</div>
</div>
<div class="flex">
<span
v-if="publisherName"
class="text-xs text-muted font-medium leading-3 max-w-40 truncate"
>
{{ publisherName }}
</span>
</div>
</div>
</div>
</div>
</template>
<template #footer>
<PackCardFooter :node-pack="nodePack" :is-installing="isInstalling" />
</template>
</Card>
</template>
<script setup lang="ts">
import Card from 'primevue/card'
import { computed, provide } from 'vue'
import { useI18n } from 'vue-i18n'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import PackVersionBadge from '@/workbench/extensions/manager/components/manager/PackVersionBadge.vue'
import PackBanner from '@/workbench/extensions/manager/components/manager/packBanner/PackBanner.vue'
import PackCardFooter from '@/workbench/extensions/manager/components/manager/packCard/PackCardFooter.vue'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import {
IsInstallingKey,
type MergedNodePack,
type RegistryPack,
isMergedNodePack
} from '@/workbench/extensions/manager/types/comfyManagerTypes'
const { nodePack, isSelected = false } = defineProps<{
nodePack: MergedNodePack | RegistryPack
isSelected?: boolean
}>()
const { d } = useI18n()
const colorPaletteStore = useColorPaletteStore()
const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
)
const { isPackInstalled, isPackEnabled, isPackInstalling } =
useComfyManagerStore()
const isInstalling = computed(() => isPackInstalling(nodePack?.id))
provide(IsInstallingKey, isInstalling)
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const isDisabled = computed(
() => isInstalled.value && !isPackEnabled(nodePack?.id)
)
const nodesCount = computed(() =>
isMergedNodePack(nodePack) ? nodePack.comfy_nodes?.length : undefined
)
const publisherName = computed(() => {
if (!nodePack) return null
const { publisher, author } = nodePack
return publisher?.name ?? publisher?.id ?? author
})
const formattedLatestVersionDate = computed(() => {
if (!nodePack.latest_version?.createdAt) return null
return d(new Date(nodePack.latest_version.createdAt), {
dateStyle: 'medium'
})
})
</script>
<style scoped>
.selected-card {
position: relative;
}
.selected-card::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border: 4px solid var(--p-primary-color);
border-radius: 0.5rem;
pointer-events: none;
z-index: 100;
}
</style>

View File

@@ -0,0 +1,61 @@
<template>
<div
class="min-h-12 flex justify-between items-center px-4 py-2 text-xs text-muted font-medium leading-3"
>
<div v-if="nodePack.downloads" class="flex items-center gap-1.5">
<i class="pi pi-download text-muted"></i>
<span>{{ formattedDownloads }}</span>
</div>
<PackInstallButton
v-if="!isInstalled"
:node-packs="[nodePack]"
:is-installing="isInstalling"
:has-conflict="hasConflicts"
:conflict-info="conflictInfo"
/>
<PackEnableToggle
v-else
:has-conflict="hasConflicts"
:node-pack="nodePack"
/>
</div>
</template>
<script setup lang="ts">
import { computed, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import type { components } from '@/types/comfyRegistryTypes'
import PackEnableToggle from '@/workbench/extensions/manager/components/manager/button/PackEnableToggle.vue'
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { IsInstallingKey } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import type { ConflictDetail } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const { isPackInstalled } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const isInstalling = inject(IsInstallingKey)
const { n } = useI18n()
const formattedDownloads = computed(() =>
nodePack.downloads ? n(nodePack.downloads) : ''
)
// Add conflict detection for the card button
const { checkNodeCompatibility } = useConflictDetection()
// Check for conflicts with this specific node pack
const conflictInfo = computed<ConflictDetail[]>(() => {
if (!nodePack) return []
const compatibilityCheck = checkNodeCompatibility(nodePack)
return compatibilityCheck.conflicts || []
})
const hasConflicts = computed(() => conflictInfo.value.length > 0)
</script>

View File

@@ -0,0 +1,52 @@
<template>
<div class="w-full max-w-[204] aspect-[2/1] rounded-lg overflow-hidden">
<!-- default banner show -->
<div v-if="showDefaultBanner" class="w-full h-full">
<img
:src="DEFAULT_BANNER"
alt="default banner"
class="w-full h-full object-cover"
/>
</div>
<!-- banner_url or icon show -->
<div v-else class="relative w-full h-full">
<!-- blur background -->
<div
v-if="imgSrc"
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{
backgroundImage: `url(${imgSrc})`,
filter: 'blur(10px)'
}"
></div>
<!-- image -->
<img
:src="isImageError ? DEFAULT_BANNER : imgSrc"
:alt="nodePack.name + ' banner'"
:class="
isImageError
? 'relative w-full h-full object-cover z-10'
: 'relative w-full h-full object-contain z-10'
"
@error="isImageError = true"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const isImageError = ref(false)
const showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon)
</script>

View File

@@ -0,0 +1,33 @@
<template>
<div class="relative w-[224px] h-[104px] shadow-xl">
<div
v-for="(pack, index) in nodePacks.slice(0, maxVisible)"
:key="pack.id"
class="absolute w-[210px] h-[90px]"
:style="{
bottom: `${index * offset}px`,
right: `${index * offset}px`,
zIndex: maxVisible - index
}"
>
<div class="border rounded-lg shadow-lg p-0.5">
<PackIcon :node-pack="pack" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { components } from '@/types/comfyRegistryTypes'
import PackIcon from '@/workbench/extensions/manager/components/manager/packIcon/PackIcon.vue'
const {
nodePacks,
maxVisible = 3,
offset = 8
} = defineProps<{
nodePacks: components['schemas']['Node'][]
maxVisible?: number
offset?: number
}>()
</script>

View File

@@ -0,0 +1,132 @@
<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'
}
}"
:show-empty-message="false"
@complete="stubTrue"
@option-select="onOptionSelect"
/>
</div>
<PackInstallButton
v-if="isMissingTab && missingNodePacks.length > 0"
:disabled="isLoading || !!error"
:node-packs="missingNodePacks"
:label="$t('manager.installAllMissingNodes')"
/>
<PackUpdateButton
v-if="isUpdateAvailableTab && hasUpdateAvailable"
:node-packs="enabledUpdateAvailableNodePacks"
:has-disabled-update-packs="hasDisabledUpdatePacks"
/>
</div>
<div class="flex mt-3 text-sm">
<div class="flex gap-6 ml-1">
<SearchFilterDropdown
v-model:model-value="searchMode"
:options="filterOptions"
:label="$t('g.filter')"
/>
<SearchFilterDropdown
v-model:model-value="sortField"
:options="availableSortOptions"
:label="$t('g.sort')"
/>
</div>
<div class="flex items-center gap-4 ml-6">
<small v-if="hasResults" class="text-color-secondary">
{{ $t('g.resultsCount', { count: searchResults?.length || 0 }) }}
</small>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { stubTrue } from 'es-toolkit/compat'
import type { AutoCompleteOptionSelectEvent } from 'primevue/autocomplete'
import AutoComplete from 'primevue/autocomplete'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { components } from '@/types/comfyRegistryTypes'
import type {
QuerySuggestion,
SearchMode,
SortableField
} from '@/types/searchServiceTypes'
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
import PackUpdateButton from '@/workbench/extensions/manager/components/manager/button/PackUpdateButton.vue'
import SearchFilterDropdown from '@/workbench/extensions/manager/components/manager/registrySearchBar/SearchFilterDropdown.vue'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useUpdateAvailableNodes } from '@/workbench/extensions/manager/composables/nodePack/useUpdateAvailableNodes'
import {
type SearchOption,
SortableAlgoliaField
} from '@/workbench/extensions/manager/types/comfyManagerTypes'
const { searchResults, sortOptions } = defineProps<{
searchResults?: components['schemas']['Node'][]
suggestions?: QuerySuggestion[]
sortOptions?: SortableField[]
isMissingTab?: boolean
isUpdateAvailableTab?: boolean
}>()
const searchQuery = defineModel<string>('searchQuery')
const searchMode = defineModel<SearchMode>('searchMode', { default: 'packs' })
const sortField = defineModel<string>('sortField', {
default: SortableAlgoliaField.Downloads
})
const { t } = useI18n()
// Get missing node packs from workflow with loading and error states
const { missingNodePacks, isLoading, error } = useMissingNodes()
// Use the composable to get update available nodes
const {
hasUpdateAvailable,
enabledUpdateAvailableNodePacks,
hasDisabledUpdatePacks
} = useUpdateAvailableNodes()
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>[] = [
{ 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
}
</script>

View File

@@ -0,0 +1,36 @@
<template>
<div class="flex items-center gap-1">
<span class="text-muted">{{ label }}:</span>
<Dropdown
:model-value="modelValue"
:options="options"
option-label="label"
option-value="id"
class="min-w-[6rem] border-none bg-transparent shadow-none"
:pt="{
input: { class: 'py-0 px-1 border-none' },
trigger: { class: 'hidden' },
panel: { class: 'shadow-md' },
item: { class: 'py-2 px-3 text-sm' }
}"
@update:model-value="$emit('update:modelValue', $event)"
/>
</div>
</template>
<script setup lang="ts" generic="T">
// eslint-disable-next-line no-restricted-imports -- TODO: Migrate to Select component
import Dropdown from 'primevue/dropdown'
import type { SearchOption } from '@/workbench/extensions/manager/types/comfyManagerTypes'
defineProps<{
options: SearchOption<T>[]
label: string
modelValue: T
}>()
defineEmits<{
'update:modelValue': [value: T]
}>()
</script>

View File

@@ -0,0 +1,19 @@
<template>
<div :style="gridStyle">
<PackCardSkeleton v-for="n in skeletonCardCount" :key="n" />
</div>
</template>
<script setup lang="ts">
import PackCardSkeleton from '@/workbench/extensions/manager/components/manager/skeleton/PackCardSkeleton.vue'
const { skeletonCardCount = 12, gridStyle } = defineProps<{
skeletonCardCount?: number
gridStyle: {
display: string
gridTemplateColumns: string
padding: string
gap: string
}
}>()
</script>

View File

@@ -0,0 +1,80 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import GridSkeleton from './GridSkeleton.vue'
import PackCardSkeleton from './PackCardSkeleton.vue'
describe('GridSkeleton', () => {
const mountComponent = ({
props = {}
}: Record<string, any> = {}): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(GridSkeleton, {
props: {
gridStyle: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(19rem, 1fr))',
padding: '0.5rem',
gap: '1.5rem'
},
...props
},
global: {
plugins: [PrimeVue, createPinia(), i18n],
stubs: {
PackCardSkeleton: true
}
}
})
}
it('renders with default props', () => {
const wrapper = mountComponent()
expect(wrapper.exists()).toBe(true)
})
it('applies the provided grid style', () => {
const customGridStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(15rem, 1fr))',
padding: '1rem',
gap: '1rem'
}
const wrapper = mountComponent({
props: { gridStyle: customGridStyle }
})
const gridElement = wrapper.element
expect(gridElement.style.display).toBe('grid')
expect(gridElement.style.gridTemplateColumns).toBe(
'repeat(auto-fill, minmax(15rem, 1fr))'
)
expect(gridElement.style.padding).toBe('1rem')
expect(gridElement.style.gap).toBe('1rem')
})
it('renders the specified number of skeleton cards', async () => {
const cardCount = 5
const wrapper = mountComponent({
props: { skeletonCardCount: cardCount }
})
await nextTick()
const skeletonCards = wrapper.findAllComponents(PackCardSkeleton)
expect(skeletonCards.length).toBe(5)
})
})

View File

@@ -0,0 +1,54 @@
<template>
<div
class="rounded-lg shadow-sm h-full overflow-hidden flex flex-col"
data-virtual-grid-item
>
<!-- Card header - flush with top, approximately 15% of height -->
<div class="w-full px-4 py-3 flex justify-between items-center">
<div class="flex items-center">
<div class="w-6 h-6 flex items-center justify-center">
<Skeleton shape="circle" width="1.5rem" height="1.5rem" />
</div>
<Skeleton width="5rem" height="1rem" class="ml-2" />
</div>
<Skeleton width="4rem" height="1.75rem" border-radius="0.75rem" />
</div>
<!-- Card content with icon on left and text on right -->
<div class="flex-1 p-4 flex">
<!-- Left icon - 64x64 -->
<div class="shrink-0 mr-4">
<Skeleton width="4rem" height="4rem" border-radius="0.5rem" />
</div>
<!-- Right content -->
<div class="flex-1 flex flex-col overflow-hidden">
<!-- Title -->
<Skeleton width="80%" height="1rem" class="mb-2" />
<!-- Description -->
<div class="mb-3">
<Skeleton width="100%" height="0.75rem" class="mb-1" />
<Skeleton width="95%" height="0.75rem" class="mb-1" />
<Skeleton width="90%" height="0.75rem" />
</div>
<!-- Tags/Badges -->
<div class="flex gap-2">
<Skeleton width="4rem" height="1.5rem" border-radius="0.75rem" />
<Skeleton width="5rem" height="1.5rem" border-radius="0.75rem" />
</div>
</div>
</div>
<!-- Card footer - similar to header -->
<div class="w-full px-5 py-4 flex justify-between items-center">
<Skeleton width="4rem" height="0.8rem" />
<Skeleton width="6rem" height="0.8rem" />
</div>
</div>
</template>
<script setup lang="ts">
import Skeleton from 'primevue/skeleton'
</script>

View File

@@ -0,0 +1,83 @@
import { whenever } from '@vueuse/core'
import { computed, onUnmounted, ref } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import { useNodePacks } from '@/workbench/extensions/manager/composables/nodePack/useNodePacks'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import type { UseNodePacksOptions } from '@/workbench/extensions/manager/types/comfyManagerTypes'
export const useInstalledPacks = (options: UseNodePacksOptions = {}) => {
const comfyManagerStore = useComfyManagerStore()
// Flag to prevent duplicate fetches during initialization
const isInitializing = ref(false)
const lastFetchedIds = ref<string>('')
const installedPackIds = computed(() =>
Array.from(comfyManagerStore.installedPacksIds)
)
const { startFetch, cleanup, error, isLoading, nodePacks, isReady } =
useNodePacks(installedPackIds, options)
const filterInstalledPack = (packs: components['schemas']['Node'][]) =>
packs.filter((pack) => comfyManagerStore.isPackInstalled(pack.id))
const startFetchInstalled = async () => {
// Prevent duplicate calls during initialization
if (isInitializing.value) {
return
}
isInitializing.value = true
try {
if (comfyManagerStore.installedPacksIds.size === 0) {
await comfyManagerStore.refreshInstalledList()
}
await startFetch()
} finally {
isInitializing.value = false
}
}
// When installedPackIds changes, we need to update the nodePacks
// But only if the IDs actually changed (not just array reference)
whenever(installedPackIds, async (newIds) => {
const newIdsStr = newIds.sort().join(',')
if (newIdsStr !== lastFetchedIds.value && !isInitializing.value) {
lastFetchedIds.value = newIdsStr
await startFetch()
}
})
onUnmounted(() => {
cleanup()
})
// Create a computed property that provides installed pack info with versions
const installedPacksWithVersions = computed(() => {
const result: Array<{ id: string; version: string }> = []
for (const pack of Object.values(comfyManagerStore.installedPacks)) {
const id = pack.cnr_id || pack.aux_id
if (id) {
result.push({
id,
version: pack.ver ?? ''
})
}
}
return result
})
return {
error,
isLoading,
isReady,
installedPacks: nodePacks,
installedPacksWithVersions,
startFetchInstalled,
filterInstalledPack
}
}

View File

@@ -0,0 +1,77 @@
import { groupBy } from 'es-toolkit/compat'
import { computed, onMounted } from 'vue'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { components } from '@/types/comfyRegistryTypes'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
import { useWorkflowPacks } from '@/workbench/extensions/manager/composables/nodePack/useWorkflowPacks'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
/**
* 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 = collectAllNodes(app.graph, isMissingCoreNode)
return groupBy(missingNodes, (node) => String(node.properties?.ver || ''))
})
// Automatically fetch workflow pack data when composable is used
onMounted(async () => {
if (!workflowPacks.value.length && !isLoading.value) {
await startFetchWorkflowPacks()
}
})
return {
missingNodePacks,
missingCoreNodes,
isLoading,
error
}
}

View File

@@ -0,0 +1,43 @@
import { get, useAsyncState } from '@vueuse/core'
import type { Ref } from 'vue'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { UseNodePacksOptions } from '@/workbench/extensions/manager/types/comfyManagerTypes'
/**
* Handles fetching node packs from the registry given a list of node pack IDs
*/
export const useNodePacks = (
packsIds: string[] | Ref<string[]>,
options: UseNodePacksOptions = {}
) => {
const { immediate = false } = options
const { getPacksByIds } = useComfyRegistryStore()
const fetchPacks = () => getPacksByIds.call(get(packsIds).filter(Boolean))
const {
isReady,
isLoading,
error,
execute,
state: nodePacks
} = useAsyncState(fetchPacks, [], {
immediate
})
const cleanup = () => {
getPacksByIds.cancel()
isReady.value = false
isLoading.value = false
}
return {
error,
isLoading,
isReady,
nodePacks,
startFetch: execute,
cleanup
}
}

View File

@@ -0,0 +1,40 @@
import { compare, valid } from 'semver'
import { computed } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
export const usePackUpdateStatus = (
nodePack: components['schemas']['Node']
) => {
const { isPackInstalled, getInstalledPackVersion } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const installedVersion = computed(() =>
getInstalledPackVersion(nodePack.id ?? '')
)
const latestVersion = computed(() => nodePack.latest_version?.version)
const isNightlyPack = computed(
() => !!installedVersion.value && !valid(installedVersion.value)
)
const isUpdateAvailable = computed(() => {
if (
!isInstalled.value ||
isNightlyPack.value ||
!latestVersion.value ||
!installedVersion.value
) {
return false
}
return compare(latestVersion.value, installedVersion.value) > 0
})
return {
isUpdateAvailable,
isNightlyPack,
installedVersion,
latestVersion
}
}

View File

@@ -0,0 +1,51 @@
import { type Ref, computed } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
type NodePack = components['schemas']['Node']
type SelectionState = 'all-installed' | 'none-installed' | 'mixed'
/**
* Composable for managing multi-package selection states
* Handles installation status tracking and selection state determination
*/
export function usePacksSelection(nodePacks: Ref<NodePack[]>) {
const managerStore = useComfyManagerStore()
const installedPacks = computed(() =>
nodePacks.value.filter((pack) => managerStore.isPackInstalled(pack.id))
)
const notInstalledPacks = computed(() =>
nodePacks.value.filter((pack) => !managerStore.isPackInstalled(pack.id))
)
const isAllInstalled = computed(
() => installedPacks.value.length === nodePacks.value.length
)
const isNoneInstalled = computed(
() => notInstalledPacks.value.length === nodePacks.value.length
)
const isMixed = computed(
() => installedPacks.value.length > 0 && notInstalledPacks.value.length > 0
)
const selectionState = computed<SelectionState>(() => {
if (isAllInstalled.value) return 'all-installed'
if (isNoneInstalled.value) return 'none-installed'
return 'mixed'
})
return {
installedPacks,
notInstalledPacks,
isAllInstalled,
isNoneInstalled,
isMixed,
selectionState
}
}

View File

@@ -0,0 +1,63 @@
import { type Ref, computed } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
type NodePack = components['schemas']['Node']
type NodeStatus = components['schemas']['NodeStatus']
type NodeVersionStatus = components['schemas']['NodeVersionStatus']
const STATUS_PRIORITY = [
'NodeStatusBanned',
'NodeVersionStatusBanned',
'NodeStatusDeleted',
'NodeVersionStatusDeleted',
'NodeVersionStatusFlagged',
'NodeVersionStatusPending',
'NodeStatusActive',
'NodeVersionStatusActive'
] as const
/**
* Composable for managing package status with priority
* Handles import failures and determines the most important status
*/
export function usePacksStatus(nodePacks: Ref<NodePack[]>) {
const conflictDetectionStore = useConflictDetectionStore()
const hasImportFailed = computed(() => {
return nodePacks.value.some((pack) => {
if (!pack.id) return false
const conflicts = conflictDetectionStore.getConflictsForPackageByID(
pack.id
)
return (
conflicts?.conflicts?.some((c) => c.type === 'import_failed') || false
)
})
})
const overallStatus = computed<NodeStatus | NodeVersionStatus>(() => {
// Check for import failed first (highest priority for installed packages)
if (hasImportFailed.value) {
// Import failed doesn't have a specific status enum, so we return active
// but the PackStatusMessage will handle it via hasImportFailed prop
return 'NodeVersionStatusActive' as NodeVersionStatus
}
// Find the highest priority status from all packages
for (const priorityStatus of STATUS_PRIORITY) {
if (nodePacks.value.some((pack) => pack.status === priorityStatus)) {
return priorityStatus as NodeStatus | NodeVersionStatus
}
}
// Default to active if no specific status found
return 'NodeVersionStatusActive' as NodeVersionStatus
})
return {
hasImportFailed,
overallStatus
}
}

View File

@@ -0,0 +1,82 @@
import { compare, valid } from 'semver'
import { computed, onMounted } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import { useInstalledPacks } from '@/workbench/extensions/manager/composables/nodePack/useInstalledPacks'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
/**
* Composable to find NodePacks that have updates available
* Uses the same filtering approach as ManagerDialogContent.vue
* Automatically fetches installed pack data when initialized
*/
export const useUpdateAvailableNodes = () => {
const comfyManagerStore = useComfyManagerStore()
const { installedPacks, isLoading, error, startFetchInstalled } =
useInstalledPacks()
// Check if a pack has updates available (same logic as usePackUpdateStatus)
const isOutdatedPack = (pack: components['schemas']['Node']) => {
const isInstalled = comfyManagerStore.isPackInstalled(pack?.id)
if (!isInstalled) return false
const installedVersion = comfyManagerStore.getInstalledPackVersion(
pack.id ?? ''
)
const latestVersion = pack.latest_version?.version
const isNightlyPack = !!installedVersion && !valid(installedVersion)
if (isNightlyPack || !latestVersion || !installedVersion) {
return false
}
return compare(latestVersion, installedVersion) > 0
}
// Same filtering logic as ManagerDialogContent.vue
const filterOutdatedPacks = (packs: components['schemas']['Node'][]) =>
packs.filter(isOutdatedPack)
// Filter only outdated packs from installed packs
const updateAvailableNodePacks = computed(() => {
if (!installedPacks.value.length) return []
return filterOutdatedPacks(installedPacks.value)
})
// Filter only enabled outdated packs
const enabledUpdateAvailableNodePacks = computed(() => {
return updateAvailableNodePacks.value.filter((pack) =>
comfyManagerStore.isPackEnabled(pack.id)
)
})
// Check if there are any enabled outdated packs
const hasUpdateAvailable = computed(() => {
return enabledUpdateAvailableNodePacks.value.length > 0
})
// Check if there are disabled packs with updates
const hasDisabledUpdatePacks = computed(() => {
return (
updateAvailableNodePacks.value.length >
enabledUpdateAvailableNodePacks.value.length
)
})
// Automatically fetch installed pack data when composable is used
onMounted(async () => {
if (!installedPacks.value.length && !isLoading.value) {
await startFetchInstalled()
}
})
return {
updateAvailableNodePacks,
enabledUpdateAvailableNodePacks,
hasUpdateAvailable,
hasDisabledUpdatePacks,
isLoading,
error
}
}

View File

@@ -0,0 +1,156 @@
import { computed, onUnmounted, ref } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { app } from '@/scripts/app'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { components } from '@/types/comfyRegistryTypes'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
import { useNodePacks } from '@/workbench/extensions/manager/composables/nodePack/useNodePacks'
import type { UseNodePacksOptions } from '@/workbench/extensions/manager/types/comfyManagerTypes'
type WorkflowPack = {
id:
| ComfyWorkflowJSON['nodes'][number]['properties']['cnr_id']
| ComfyWorkflowJSON['nodes'][number]['properties']['aux_id']
version: ComfyWorkflowJSON['nodes'][number]['properties']['ver']
}
const CORE_NODES_PACK_NAME = 'comfy-core'
/**
* Handles parsing node pack metadata from nodes on the graph and fetching the
* associated node packs from the registry
*/
export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
const nodeDefStore = useNodeDefStore()
const systemStatsStore = useSystemStatsStore()
const { inferPackFromNodeName } = useComfyRegistryStore()
const workflowPacks = ref<WorkflowPack[]>([])
const getWorkflowNodePackId = (node: LGraphNode): string | undefined => {
if (typeof node.properties?.cnr_id === 'string') {
return node.properties.cnr_id
}
if (typeof node.properties?.aux_id === 'string') {
return node.properties.aux_id
}
return undefined
}
/**
* Clean the version string to be used in the registry search.
* Removes the leading 'v' and trims whitespace and line terminators.
*/
const cleanVersionString = (version: string) =>
version.replace(/^v/, '').trim()
/**
* Infer the pack for a node by searching the registry for packs that have nodes
* with the same name.
*/
const inferPack = async (
node: LGraphNode
): Promise<WorkflowPack | undefined> => {
const nodeName = node.type
// Check if node is a core node
const nodeDef = nodeDefStore.nodeDefsByName[nodeName]
if (nodeDef?.nodeSource.type === 'core') {
if (!systemStatsStore.systemStats) {
await systemStatsStore.refetchSystemStats()
}
return {
id: CORE_NODES_PACK_NAME,
version:
systemStatsStore.systemStats?.system?.comfyui_version ?? 'nightly'
}
}
// Query the registry to find which pack provides this node
const pack = await inferPackFromNodeName.call(nodeName)
if (pack) {
return {
id: pack.id,
version: pack.latest_version?.version ?? 'nightly'
}
}
// No pack found - this node doesn't exist in the registry or couldn't be
// extracted from the parent node pack successfully
return undefined
}
/**
* Map a workflow node to its pack using the node pack metadata.
* If the node pack metadata is not available, fallback to searching the
* registry for packs that have nodes with the same name.
*/
const workflowNodeToPack = async (
node: LGraphNode
): Promise<WorkflowPack | undefined> => {
const packId = getWorkflowNodePackId(node)
if (!packId) return inferPack(node) // Fallback
if (packId === CORE_NODES_PACK_NAME) return undefined
const version =
typeof node.properties.ver === 'string'
? cleanVersionString(node.properties.ver)
: undefined
return {
id: packId,
version
}
}
/**
* Get the node packs for all nodes in the workflow (including subgraphs).
*/
const getWorkflowPacks = async () => {
if (!app.graph) return []
const allNodes = collectAllNodes(app.graph)
if (!allNodes.length) return []
const packs = await Promise.all(allNodes.map(workflowNodeToPack))
workflowPacks.value = packs.filter((pack) => pack !== undefined)
}
const packsToUniqueIds = (packs: WorkflowPack[]) =>
packs.reduce((acc, pack) => {
if (pack?.id) acc.add(pack.id)
return acc
}, new Set<string>())
const workflowPacksIds = computed(() =>
Array.from(packsToUniqueIds(workflowPacks.value))
)
const { startFetch, cleanup, error, isLoading, nodePacks, isReady } =
useNodePacks(workflowPacksIds, options)
const isIdInWorkflow = (packId: string) =>
workflowPacksIds.value.includes(packId)
const filterWorkflowPack = (packs: components['schemas']['Node'][]) =>
packs.filter((pack) => !!pack.id && isIdInWorkflow(pack.id))
onUnmounted(() => {
cleanup()
})
return {
error,
isLoading,
isReady,
workflowPacks: nodePacks,
startFetchWorkflowPacks: async () => {
await getWorkflowPacks() // Parse the packs from the workflow nodes
await startFetch() // Fetch the packs infos from the registry
},
filterWorkflowPack
}
}

View File

@@ -0,0 +1,101 @@
import { useStorage } from '@vueuse/core'
import { computed } from 'vue'
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
/**
* LocalStorage keys for conflict acknowledgment tracking
*/
const STORAGE_KEYS = {
CONFLICT_MODAL_DISMISSED: 'Comfy.ConflictModalDismissed',
CONFLICT_RED_DOT_DISMISSED: 'Comfy.ConflictRedDotDismissed',
CONFLICT_WARNING_BANNER_DISMISSED: 'Comfy.ConflictWarningBannerDismissed'
} as const
/**
* Interface for conflict acknowledgment state
*/
interface ConflictAcknowledgmentState {
modal_dismissed: boolean
red_dot_dismissed: boolean
warning_banner_dismissed: boolean
}
// Shared state - initialized once and reused across all composable calls
const modalDismissed = useStorage(STORAGE_KEYS.CONFLICT_MODAL_DISMISSED, false)
const redDotDismissed = useStorage(
STORAGE_KEYS.CONFLICT_RED_DOT_DISMISSED,
false
)
const warningBannerDismissed = useStorage(
STORAGE_KEYS.CONFLICT_WARNING_BANNER_DISMISSED,
false
)
/**
* Composable for managing conflict acknowledgment state in localStorage
*
* This handles:
* - Tracking whether conflict modal has been dismissed
* - Tracking whether red dot notification has been cleared
* - Managing per-package conflict acknowledgments
* - Detecting ComfyUI version changes to reset acknowledgment state
*/
export function useConflictAcknowledgment() {
const conflictDetectionStore = useConflictDetectionStore()
// Create computed state object for backward compatibility
const state = computed<ConflictAcknowledgmentState>(() => ({
modal_dismissed: modalDismissed.value,
red_dot_dismissed: redDotDismissed.value,
warning_banner_dismissed: warningBannerDismissed.value
}))
/**
* Mark red dot notification as dismissed
*/
function dismissRedDotNotification(): void {
redDotDismissed.value = true
}
/**
* Mark manager warning banner as dismissed
*/
function dismissWarningBanner(): void {
warningBannerDismissed.value = true
redDotDismissed.value = true
}
/**
* Mark conflicts as seen (unified function for help center and manager)
*/
function markConflictsAsSeen(): void {
redDotDismissed.value = true
modalDismissed.value = true
warningBannerDismissed.value = true
}
const hasConflicts = computed(() => conflictDetectionStore.hasConflicts)
const shouldShowConflictModal = computed(() => !modalDismissed.value)
const shouldShowRedDot = computed(() => {
if (!hasConflicts.value) return false
if (redDotDismissed.value) return false
return true
})
const shouldShowManagerBanner = computed(() => {
return hasConflicts.value && !warningBannerDismissed.value
})
return {
// State
acknowledgmentState: state,
shouldShowConflictModal,
shouldShowRedDot,
shouldShowManagerBanner,
// Methods
dismissRedDotNotification,
dismissWarningBanner,
markConflictsAsSeen
}
}

View File

@@ -0,0 +1,714 @@
import { until } from '@vueuse/core'
import { find } from 'es-toolkit/compat'
import { computed, getCurrentInstance, onUnmounted, readonly, ref } from 'vue'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { components } from '@/types/comfyRegistryTypes'
import { useInstalledPacks } from '@/workbench/extensions/manager/composables/nodePack/useInstalledPacks'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
import type {
RegistryAccelerator,
RegistryOS
} from '@/workbench/extensions/manager/types/compatibility.types'
import type {
ConflictDetail,
ConflictDetectionResponse,
ConflictDetectionResult,
Node,
NodeRequirements,
SystemEnvironment
} from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import {
consolidateConflictsByPackage,
createBannedConflict,
createPendingConflict
} from '@/workbench/extensions/manager/utils/conflictUtils'
import {
checkAcceleratorCompatibility,
checkOSCompatibility,
normalizeOSList
} from '@/workbench/extensions/manager/utils/systemCompatibility'
import {
checkVersionCompatibility,
getFrontendVersion
} from '@/workbench/extensions/manager/utils/versionUtil'
/**
* Composable for conflict detection system.
* Error-resilient and asynchronous to avoid affecting other components.
*/
export function useConflictDetection() {
const managerStore = useComfyManagerStore()
const {
startFetchInstalled,
installedPacks,
installedPacksWithVersions,
isReady: installedPacksReady
} = useInstalledPacks()
const isDetecting = ref(false)
const lastDetectionTime = ref<string | null>(null)
const detectionError = ref<string | null>(null)
const systemEnvironment = ref<SystemEnvironment | null>(null)
const detectionResults = ref<ConflictDetectionResult[]>([])
// Store merged conflicts separately for testing
const storedMergedConflicts = ref<ConflictDetectionResult[]>([])
// Registry API request cancellation
const abortController = ref<AbortController | null>(null)
const acknowledgment = useConflictAcknowledgment()
const conflictStore = useConflictDetectionStore()
const hasConflicts = computed(() => conflictStore.hasConflicts)
const conflictedPackages = computed(() => {
return conflictStore.conflictedPackages
})
const bannedPackages = computed(() => conflictStore.bannedPackages)
const securityPendingPackages = computed(
() => conflictStore.securityPendingPackages
)
/**
* Collects current system environment information.
* Continues with default values even if errors occur.
* @returns Promise that resolves to system environment information
*/
async function collectSystemEnvironment(): Promise<SystemEnvironment> {
try {
// Get system stats from store (primary source of system information)
// Wait for systemStats to be initialized if not already
const { systemStats, isInitialized: systemStatsInitialized } =
useSystemStatsStore()
await until(systemStatsInitialized)
const frontendVersion = getFrontendVersion()
const environment: SystemEnvironment = {
comfyui_version: systemStats?.system.comfyui_version ?? '',
frontend_version: frontendVersion,
os: systemStats?.system.os ?? '',
accelerator: systemStats?.devices?.[0]?.type ?? ''
}
systemEnvironment.value = environment
return environment
} catch (error) {
const fallbackEnvironment: SystemEnvironment = {
comfyui_version: undefined,
frontend_version: undefined,
os: undefined,
accelerator: undefined
}
systemEnvironment.value = fallbackEnvironment
return fallbackEnvironment
}
}
/**
* Fetches requirement information for installed packages using Registry Store.
*
* This function combines local installation data with Registry API compatibility metadata
* using the established store layer pattern with caching and batch requests.
*
* Process
* 1. Get locally installed packages
* 2. Batch fetch Registry data using store layer
* 3. Combine local + Registry data
* 4. Extract compatibility requirements
*
* @returns Promise that resolves to array of node pack requirements
*/
async function buildNodeRequirements(): Promise<NodeRequirements[]> {
try {
// Step 1: Use installed packs composable instead of direct API calls
await startFetchInstalled() // Ensure data is loaded
if (
!installedPacksReady.value ||
!installedPacks.value ||
installedPacks.value.length === 0
) {
console.warn(
'[ConflictDetection] No installed packages available from useInstalledPacks'
)
return []
}
// Step 2: Get Registry service for bulk API calls
const registryService = useComfyRegistryService()
// Step 3: Setup abort controller for request cancellation
abortController.value = new AbortController()
// Step 4: Use bulk API to fetch all version data in a single request
const versionDataMap = new Map<
string,
components['schemas']['NodeVersion']
>()
// Prepare bulk request with actual installed versions from Manager API
const nodeVersions = installedPacksWithVersions.value.map((pack) => ({
node_id: pack.id,
version: pack.version
}))
if (nodeVersions.length > 0) {
try {
const bulkResponse = await registryService.getBulkNodeVersions(
nodeVersions,
abortController.value?.signal
)
if (bulkResponse && bulkResponse.node_versions?.length > 0) {
// Process bulk response
bulkResponse.node_versions.forEach((result) => {
if (result.status === 'success' && result.node_version) {
versionDataMap.set(
result.identifier.node_id,
result.node_version
)
} else if (result.status === 'error') {
console.warn(
`[ConflictDetection] Failed to fetch version data for ${result.identifier.node_id}@${result.identifier.version}:`,
result.error_message
)
}
})
}
} catch (error) {
console.warn(
'[ConflictDetection] Failed to fetch bulk version data:',
error
)
}
}
// Step 5: Combine local installation data with Registry version data
const requirements: NodeRequirements[] = []
// IMPORTANT: Use installedPacksWithVersions to check ALL installed packages
// not just the ones that exist in Registry (installedPacks)
for (const installedPackVersion of installedPacksWithVersions.value) {
const versionData = versionDataMap.get(installedPackVersion.id)
const isEnabled = managerStore.isPackEnabled(installedPackVersion.id)
// Find the pack info from Registry if available
const packInfo = find(installedPacks.value, {
id: installedPackVersion.id
})
if (versionData) {
// Combine local installation data with version-specific Registry data
const requirement: NodeRequirements = {
// Basic package info
id: installedPackVersion.id,
name: packInfo?.name || installedPackVersion.id,
installed_version: installedPackVersion.version,
is_enabled: isEnabled,
// Version-specific compatibility data
supported_comfyui_version: versionData.supported_comfyui_version,
supported_comfyui_frontend_version:
versionData.supported_comfyui_frontend_version,
supported_os: normalizeOSList(
versionData.supported_os
) as Node['supported_os'],
supported_accelerators: versionData.supported_accelerators,
// Status information
version_status: versionData.status,
is_banned: versionData.status === 'NodeVersionStatusBanned',
is_pending: versionData.status === 'NodeVersionStatusPending'
}
requirements.push(requirement)
} else {
console.warn(
`[ConflictDetection] No Registry data found for ${installedPackVersion.id}, using fallback`
)
// Create fallback requirement without Registry data
const fallbackRequirement: NodeRequirements = {
id: installedPackVersion.id,
name: packInfo?.name || installedPackVersion.id,
installed_version: installedPackVersion.version,
is_enabled: isEnabled,
is_banned: false,
is_pending: false
}
requirements.push(fallbackRequirement)
}
}
return requirements
} catch (error) {
console.warn(
'[ConflictDetection] Failed to fetch package requirements:',
error
)
return []
}
}
/**
* Detects conflicts for an individual package using Registry API data.
*
* @param packageReq Package requirements from Registry
* @param sysEnv Current system environment
* @returns Conflict detection result for the package
*/
function analyzePackageConflicts(
packageReq: NodeRequirements,
systemEnvInfo: SystemEnvironment
): ConflictDetectionResult {
const conflicts: ConflictDetail[] = []
// 1. ComfyUI version conflict check
const versionConflict = checkVersionCompatibility(
'comfyui_version',
systemEnvInfo.comfyui_version,
packageReq.supported_comfyui_version
)
if (versionConflict) conflicts.push(versionConflict)
// 2. Frontend version conflict check
const frontendConflict = checkVersionCompatibility(
'frontend_version',
systemEnvInfo.frontend_version,
packageReq.supported_comfyui_frontend_version
)
if (frontendConflict) conflicts.push(frontendConflict)
// 3. OS compatibility check
const osConflict = checkOSCompatibility(
packageReq.supported_os as RegistryOS[] | undefined,
systemEnvInfo.os
)
if (osConflict) conflicts.push(osConflict)
// 4. Accelerator compatibility check
const acceleratorConflict = checkAcceleratorCompatibility(
packageReq.supported_accelerators as RegistryAccelerator[] | undefined,
systemEnvInfo.accelerator
)
if (acceleratorConflict) conflicts.push(acceleratorConflict)
// 5. Banned package check using shared logic
const bannedConflict = createBannedConflict(packageReq.is_banned)
if (bannedConflict) {
conflicts.push(bannedConflict)
}
// 6. Registry data availability check using shared logic
const pendingConflict = createPendingConflict(packageReq.is_pending)
if (pendingConflict) {
conflicts.push(pendingConflict)
}
// Generate result
const hasConflict = conflicts.length > 0
return {
package_id: packageReq.id ?? '',
package_name: packageReq.name ?? '',
has_conflict: hasConflict,
conflicts,
is_compatible: !hasConflict
}
}
/**
* Fetches Python import failure information from ComfyUI Manager.
* Gets installed packages and checks each one for import failures using bulk API.
* @returns Promise that resolves to import failure data
*/
async function fetchImportFailInfo(): Promise<Record<string, any>> {
try {
const comfyManagerService = useComfyManagerService()
// Use installedPacksWithVersions to match what versions bulk API uses
// This ensures both APIs check the same set of packages
if (
!installedPacksWithVersions.value ||
installedPacksWithVersions.value.length === 0
) {
console.warn(
'[ConflictDetection] No installed packages available for import failure check'
)
return {}
}
const packageIds = installedPacksWithVersions.value.map((pack) => pack.id)
// Use bulk API to get import failure info for all packages at once
const bulkResult = await comfyManagerService.getImportFailInfoBulk(
{ cnr_ids: packageIds },
abortController.value?.signal
)
if (bulkResult) {
// Filter out null values (packages without import failures)
const importFailures: Record<string, any> = {}
Object.entries(bulkResult).forEach(([packageId, failInfo]) => {
if (failInfo !== null) {
importFailures[packageId] = failInfo
console.debug(
`[ConflictDetection] Import failure found for ${packageId}:`,
failInfo
)
}
})
return importFailures
}
return {}
} catch (error) {
console.warn(
'[ConflictDetection] Failed to fetch import failure information:',
error
)
return {}
}
}
/**
* Detects runtime conflicts from Python import failures.
* @param importFailInfo Import failure data from Manager API
* @returns Array of conflict detection results for failed imports
*/
function detectImportFailConflicts(
importFailInfo: Record<string, { msg: string; name: string; path: string }>
): ConflictDetectionResult[] {
const results: ConflictDetectionResult[] = []
if (!importFailInfo || typeof importFailInfo !== 'object') {
return results
}
// Process import failures
for (const [packageId, failureInfo] of Object.entries(importFailInfo)) {
if (failureInfo && typeof failureInfo === 'object') {
// Extract error information from Manager API response
const errorMsg = failureInfo.msg || 'Unknown import error'
const modulePath = failureInfo.path || ''
results.push({
package_id: packageId,
package_name: packageId,
has_conflict: true,
conflicts: [
{
type: 'import_failed',
current_value: 'installed',
required_value: failureInfo.msg
}
],
is_compatible: false
})
console.warn(
`[ConflictDetection] Python import failure detected for ${packageId}:`,
{
path: modulePath,
error: errorMsg
}
)
}
}
return results
}
/**
* Performs complete conflict detection.
* @returns Promise that resolves to conflict detection response
*/
async function runFullConflictAnalysis(): Promise<ConflictDetectionResponse> {
if (isDetecting.value) {
return {
success: false,
error_message: 'Already detecting conflicts',
results: detectionResults.value
}
}
isDetecting.value = true
detectionError.value = null
try {
// 1. Collect system environment information
const systemEnvInfo = await collectSystemEnvironment()
// 2. Collect installed node requirement information
const installedNodeRequirements = await buildNodeRequirements()
// 3. Detect conflicts for each package (parallel processing)
const conflictDetectionTasks = installedNodeRequirements.map(
async (packageReq) => {
try {
return analyzePackageConflicts(packageReq, systemEnvInfo)
} catch (error) {
console.warn(
`[ConflictDetection] Failed to detect conflicts for package ${packageReq.name}:`,
error
)
// Return null for failed packages, will be filtered out
return null
}
}
)
const conflictResults = await Promise.allSettled(conflictDetectionTasks)
const packageResults: ConflictDetectionResult[] = conflictResults
.map((result) => (result.status === 'fulfilled' ? result.value : null))
.filter((result): result is ConflictDetectionResult => result !== null)
// 4. Detect Python import failures
const importFailInfo = await fetchImportFailInfo()
const importFailResults = detectImportFailConflicts(importFailInfo)
// 5. Combine all results
const allResults = [...packageResults, ...importFailResults]
// 6. Update state
detectionResults.value = allResults
lastDetectionTime.value = new Date().toISOString()
// Store conflict results for later UI display
// Dialog will be shown based on specific events, not on app mount
if (allResults.some((result) => result.has_conflict)) {
const conflictedResults = allResults.filter(
(result) => result.has_conflict
)
// Merge conflicts for packages with the same name
const mergedConflicts = consolidateConflictsByPackage(conflictedResults)
console.debug(
'[ConflictDetection] Conflicts detected (stored for UI):',
mergedConflicts
)
// Store merged conflicts in Pinia store for UI usage
conflictStore.setConflictedPackages(mergedConflicts)
// Also update local state for backward compatibility
detectionResults.value = [...mergedConflicts]
storedMergedConflicts.value = [...mergedConflicts]
// Use merged conflicts in response as well
const response: ConflictDetectionResponse = {
success: true,
results: mergedConflicts,
detected_system_environment: systemEnvInfo
}
return response
} else {
// No conflicts detected, clear the results
conflictStore.clearConflicts()
detectionResults.value = []
}
const response: ConflictDetectionResponse = {
success: true,
results: allResults,
detected_system_environment: systemEnvInfo
}
return response
} catch (error) {
console.error(
'[ConflictDetection] Error during conflict detection:',
error
)
detectionError.value =
error instanceof Error ? error.message : String(error)
return {
success: false,
error_message: detectionError.value,
results: []
}
} finally {
isDetecting.value = false
// Clear abort controller to prevent memory leaks
if (abortController.value) {
abortController.value = null
}
}
}
/**
* Error-resilient initialization (called on app mount).
* Async function that doesn't block UI setup.
* Ensures proper order: system_stats -> manager state -> installed -> versions bulk -> import_fail_info_bulk
*/
async function initializeConflictDetection(): Promise<void> {
try {
// Check if manager is new Manager before proceeding
const { useManagerState } = await import(
'@/workbench/extensions/manager/composables/useManagerState'
)
const managerState = useManagerState()
if (!managerState.isNewManagerUI.value) {
return
}
// Manager is new Manager, perform conflict detection
// The useInstalledPacks will handle fetching installed list if needed
await runFullConflictAnalysis()
} catch (error) {
console.warn(
'[ConflictDetection] Error during initialization (ignored):',
error
)
// Errors do not affect other parts of the app
}
}
// Cleanup function for request cancellation
function cancelRequests(): void {
if (abortController.value) {
abortController.value.abort()
abortController.value = null
}
}
// Auto-cleanup on component unmount
// Only register lifecycle hooks if we're in a Vue component context
const instance = getCurrentInstance()
if (instance) {
onUnmounted(() => {
cancelRequests()
})
}
// Helper functions (implementations at the bottom of the file)
/**
* Check if conflicts should trigger modal display after "What's New" dismissal
*/
async function shouldShowConflictModalAfterUpdate(): Promise<boolean> {
// Ensure conflict detection has run
if (detectionResults.value.length === 0) {
await runFullConflictAnalysis()
}
// Check if this is a version update scenario
// In a real scenario, this would check actual version change
// For now, we'll assume it's an update if we have conflicts and modal hasn't been dismissed
const hasActualConflicts = hasConflicts.value
const canShowModal = acknowledgment.shouldShowConflictModal.value
return hasActualConflicts && canShowModal
}
/**
* Check compatibility for a node.
* Used by components like PackVersionSelectorPopover.
*/
function checkNodeCompatibility(
node: Node | components['schemas']['NodeVersion']
) {
const conflicts: ConflictDetail[] = []
// Check OS compatibility
const osConflict = checkOSCompatibility(
normalizeOSList(node.supported_os),
systemEnvironment.value?.os
)
if (osConflict) {
conflicts.push(osConflict)
}
// Check Accelerator compatibility
const acceleratorConflict = checkAcceleratorCompatibility(
node.supported_accelerators as RegistryAccelerator[],
systemEnvironment.value?.accelerator
)
if (acceleratorConflict) {
conflicts.push(acceleratorConflict)
}
// Check ComfyUI version compatibility
const comfyUIVersionConflict = checkVersionCompatibility(
'comfyui_version',
systemEnvironment.value?.comfyui_version,
node.supported_comfyui_version
)
if (comfyUIVersionConflict) {
conflicts.push(comfyUIVersionConflict)
}
// Check ComfyUI Frontend version compatibility
const currentFrontendVersion = getFrontendVersion()
const frontendVersionConflict = checkVersionCompatibility(
'frontend_version',
currentFrontendVersion,
node.supported_comfyui_frontend_version
)
if (frontendVersionConflict) {
conflicts.push(frontendVersionConflict)
}
// Check banned package status using shared logic
const bannedConflict = createBannedConflict(
node.status === 'NodeStatusBanned' ||
node.status === 'NodeVersionStatusBanned'
)
if (bannedConflict) {
conflicts.push(bannedConflict)
}
// Check pending status using shared logic
const pendingConflict = createPendingConflict(
node.status === 'NodeVersionStatusPending'
)
if (pendingConflict) {
conflicts.push(pendingConflict)
}
return {
hasConflict: conflicts.length > 0,
conflicts
}
}
return {
// State
isDetecting: readonly(isDetecting),
lastDetectionTime: readonly(lastDetectionTime),
detectionError: readonly(detectionError),
systemEnvironment: readonly(systemEnvironment),
detectionResults: readonly(detectionResults),
// Computed
hasConflicts,
conflictedPackages,
bannedPackages,
securityPendingPackages,
// Methods
runFullConflictAnalysis,
collectSystemEnvironment,
initializeConflictDetection,
cancelRequests,
shouldShowConflictModalAfterUpdate,
// Helper functions for other components
checkNodeCompatibility
}
}

View File

@@ -0,0 +1,85 @@
import { type ComputedRef, computed, unref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDialogService } from '@/services/dialogService'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
import type { ConflictDetail } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
/**
* Extracting import failed conflicts from conflict list
*/
function extractImportFailedConflicts(conflicts?: ConflictDetail[] | null) {
if (!conflicts) return null
const importFailedConflicts = conflicts.filter(
(item): item is ConflictDetail => item.type === 'import_failed'
)
return importFailedConflicts.length > 0 ? importFailedConflicts : null
}
/**
* Creating import failed dialog
*/
function createImportFailedDialog() {
const { t } = useI18n()
const { showErrorDialog } = useDialogService()
return (importFailedInfo: ConflictDetail[] | null) => {
if (importFailedInfo) {
const errorMessage =
importFailedInfo
.map((conflict) => conflict.required_value)
.filter(Boolean)
.join('\n') || t('manager.importFailedGenericError')
const error = new Error(errorMessage)
showErrorDialog(error, {
title: t('manager.failedToInstall'),
reportType: 'importFailedError'
})
}
}
}
/**
* Composable for detecting and handling import failed conflicts
* @param packageId - Package ID string or computed ref
* @returns Object with import failed detection and dialog handler
*/
export function useImportFailedDetection(
packageId?: string | ComputedRef<string> | null
) {
const { isPackInstalled } = useComfyManagerStore()
const { getConflictsForPackageByID } = useConflictDetectionStore()
const isInstalled = computed(() =>
packageId ? isPackInstalled(unref(packageId)) : false
)
const conflicts = computed(() => {
const currentPackageId = unref(packageId)
if (!currentPackageId || !isInstalled.value) return null
return getConflictsForPackageByID(currentPackageId) || null
})
const importFailedInfo = computed(() => {
return extractImportFailedConflicts(conflicts.value?.conflicts)
})
const importFailed = computed(() => {
return importFailedInfo.value !== null
})
const showImportFailedDialog = createImportFailedDialog()
return {
importFailedInfo,
importFailed,
showImportFailedDialog: () =>
showImportFailedDialog(importFailedInfo.value),
isInstalled
}
}

View File

@@ -0,0 +1,170 @@
import { useEventListener } from '@vueuse/core'
import { pickBy } from 'es-toolkit/compat'
import type { Ref } from 'vue'
import { computed, ref } from 'vue'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { normalizePackKeys } from '@/utils/packUtils'
import type { components } from '@/workbench/extensions/manager/types/generatedManagerTypes'
type ManagerTaskHistory = Record<
string,
components['schemas']['TaskHistoryItem']
>
type ManagerTaskQueue = components['schemas']['TaskStateMessage']
type ManagerWsTaskDoneMsg = components['schemas']['MessageTaskDone']
type ManagerWsTaskStartedMsg = components['schemas']['MessageTaskStarted']
type QueueTaskItem = components['schemas']['QueueTaskItem']
type HistoryTaskItem = components['schemas']['TaskHistoryItem']
type Task = QueueTaskItem | HistoryTaskItem
const MANAGER_WS_TASK_DONE_NAME = 'cm-task-completed'
const MANAGER_WS_TASK_STARTED_NAME = 'cm-task-started'
export const useManagerQueue = (
taskHistory: Ref<ManagerTaskHistory>,
taskQueue: Ref<ManagerTaskQueue>,
installedPacks: Ref<Record<string, any>>
) => {
const { showManagerProgressDialog } = useDialogService()
// Task queue state (read-only from server)
const maxHistoryItems = ref(64)
const isLoading = ref(false)
const isProcessing = ref(false)
// Computed values
const currentQueueLength = computed(
() =>
taskQueue.value.running_queue.length +
taskQueue.value.pending_queue.length
)
/**
* Update the processing state based on the current queue length.
* If the queue is empty, or all tasks in the queue are associated
* with different clients, then this client is not processing any tasks.
*/
const updateProcessingState = (): void => {
isProcessing.value = currentQueueLength.value > 0
}
const allTasksDone = computed(() => currentQueueLength.value === 0)
const historyCount = computed(() => Object.keys(taskHistory.value).length)
/**
* Check if a task is associated with this client.
* Task can be from running queue, pending queue, or history.
* @param task - The task to check
* @returns True if the task belongs to this client
*/
const isTaskFromThisClient = (task: Task): boolean =>
task.client_id === app.api.clientId
/**
* Check if a history task is associated with this client.
* @param task - The history task to check
* @returns True if the task belongs to this client
*/
const isHistoryTaskFromThisClient = (task: HistoryTaskItem): boolean =>
task.client_id === app.api.clientId
/**
* Filter queue tasks by client id.
* Ensures that only tasks associated with this client are processed and
* added to client state.
* @param tasks - Array of queue tasks to filter
* @returns Filtered array containing only tasks from this client
*/
const filterQueueByClientId = (tasks: QueueTaskItem[]): QueueTaskItem[] =>
tasks.filter(isTaskFromThisClient)
/**
* Filter history tasks by client id using pickBy for optimal performance.
* Returns a new object containing only tasks associated with this client.
* @param history - The history object to filter
* @returns Filtered history object containing only tasks from this client
*/
const filterHistoryByClientId = (history: ManagerTaskHistory) =>
pickBy(history, isHistoryTaskFromThisClient)
/**
* Update task queue and history state with filtered data from server.
* Ensures only tasks from this client are stored in local state.
* @param state - The task state message from the server
*/
const updateTaskState = (state: ManagerTaskQueue) => {
taskQueue.value.running_queue = filterQueueByClientId(state.running_queue)
taskQueue.value.pending_queue = filterQueueByClientId(state.pending_queue)
taskHistory.value = filterHistoryByClientId(state.history)
if (state.installed_packs) {
// Normalize pack keys to ensure consistent access
installedPacks.value = normalizePackKeys(state.installed_packs)
}
updateProcessingState()
}
// WebSocket event listener for task done
const cleanupTaskDoneListener = useEventListener(
app.api,
MANAGER_WS_TASK_DONE_NAME,
(event: CustomEvent<ManagerWsTaskDoneMsg>) => {
if (event?.type === MANAGER_WS_TASK_DONE_NAME && event.detail?.state) {
updateTaskState(event.detail.state)
// If no more tasks are running/pending, hide the progress dialog
if (allTasksDone.value) {
setTimeout(() => {
if (allTasksDone.value) {
showManagerProgressDialog()
}
}, 1000) // Small delay to let users see completion
}
}
}
)
// WebSocket event listener for task started
const cleanupTaskStartedListener = useEventListener(
app.api,
MANAGER_WS_TASK_STARTED_NAME,
(event: CustomEvent<ManagerWsTaskStartedMsg>) => {
if (event?.type === MANAGER_WS_TASK_STARTED_NAME && event.detail?.state) {
updateTaskState(event.detail.state)
// Show progress dialog when a task starts
showManagerProgressDialog()
}
}
)
/**
* Cleanup function to remove event listeners and reset state
*/
const cleanup = () => {
cleanupTaskDoneListener()
cleanupTaskStartedListener()
// Reset state
isProcessing.value = false
isLoading.value = false
}
return {
// State
isLoading,
isProcessing,
maxHistoryItems,
// Computed
allTasksDone,
historyCount,
currentQueueLength,
// Actions
updateTaskState,
cleanup
}
}

View File

@@ -0,0 +1,208 @@
import { storeToRefs } from 'pinia'
import { computed, readonly } from 'vue'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
export enum ManagerUIState {
DISABLED = 'disabled',
LEGACY_UI = 'legacy',
NEW_UI = 'new'
}
export function useManagerState() {
const systemStatsStore = useSystemStatsStore()
const { systemStats, isInitialized: systemInitialized } =
storeToRefs(systemStatsStore)
/**
* The current manager UI state.
* Computed once and cached until dependencies change (which they don't during runtime).
* This follows Vue's conventions and provides better performance through caching.
*/
const managerUIState = readonly(
computed((): ManagerUIState => {
// Wait for systemStats to be initialized
if (!systemInitialized.value) {
// Default to DISABLED while loading
return ManagerUIState.DISABLED
}
// Get current values
const clientSupportsV4 =
api.getClientFeatureFlags().supports_manager_v4_ui ?? false
const serverSupportsV4 = api.getServerFeature(
'extension.manager.supports_v4'
)
// Check command line args first (highest priority)
// --enable-manager flag enables the manager (opposite of old --disable-manager)
const hasEnableManager =
systemStats.value?.system?.argv?.includes('--enable-manager')
// If --enable-manager is NOT present, manager is disabled
if (!hasEnableManager) {
return ManagerUIState.DISABLED
}
if (
systemStats.value?.system?.argv?.includes('--enable-manager-legacy-ui')
) {
return ManagerUIState.LEGACY_UI
}
// Both client and server support v4 = NEW_UI
if (clientSupportsV4 && serverSupportsV4 === true) {
return ManagerUIState.NEW_UI
}
// Server supports v4 but client doesn't = LEGACY_UI
if (serverSupportsV4 === true && !clientSupportsV4) {
return ManagerUIState.LEGACY_UI
}
// Server explicitly doesn't support v4 = LEGACY_UI
if (serverSupportsV4 === false) {
return ManagerUIState.LEGACY_UI
}
// If server feature flags haven't loaded yet, default to NEW_UI
// This is a temporary state - feature flags are exchanged immediately on WebSocket connection
// NEW_UI is the safest default since v2 API is the current standard
// If the server doesn't support v2, API calls will fail with 404 and be handled gracefully
if (serverSupportsV4 === undefined) {
return ManagerUIState.NEW_UI
}
// Should never reach here, but if we do, disable manager
return ManagerUIState.DISABLED
})
)
/**
* Check if manager is enabled (not DISABLED)
*/
const isManagerEnabled = readonly(
computed((): boolean => {
return managerUIState.value !== ManagerUIState.DISABLED
})
)
/**
* Check if manager UI is in NEW_UI mode
*/
const isNewManagerUI = readonly(
computed((): boolean => {
return managerUIState.value === ManagerUIState.NEW_UI
})
)
/**
* Check if manager UI is in LEGACY_UI mode
*/
const isLegacyManagerUI = readonly(
computed((): boolean => {
return managerUIState.value === ManagerUIState.LEGACY_UI
})
)
/**
* Check if install button should be shown (only in NEW_UI mode)
*/
const shouldShowInstallButton = readonly(
computed((): boolean => {
return isNewManagerUI.value
})
)
/**
* Check if manager buttons should be shown (when manager is not disabled)
*/
const shouldShowManagerButtons = readonly(
computed((): boolean => {
return isManagerEnabled.value
})
)
/**
* Opens the manager UI based on current state
* Centralizes the logic for opening manager across the app
* @param options - Optional configuration for opening the manager
* @param options.initialTab - Initial tab to show (for NEW_UI mode)
* @param options.legacyCommand - Legacy command to execute (for LEGACY_UI mode)
* @param options.showToastOnLegacyError - Whether to show toast on legacy command failure
* @param options.isLegacyOnly - If true, shows error in NEW_UI mode instead of opening manager
*/
const openManager = async (options?: {
initialTab?: ManagerTab
legacyCommand?: string
showToastOnLegacyError?: boolean
isLegacyOnly?: boolean
}): Promise<void> => {
const state = managerUIState.value
const dialogService = useDialogService()
const commandStore = useCommandStore()
switch (state) {
case ManagerUIState.DISABLED:
dialogService.showSettingsDialog('extension')
break
case ManagerUIState.LEGACY_UI: {
const command =
options?.legacyCommand || 'Comfy.Manager.Menu.ToggleVisibility'
try {
await commandStore.execute(command)
} catch {
// If legacy command doesn't exist
if (options?.showToastOnLegacyError !== false) {
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('manager.legacyMenuNotAvailable'),
life: 3000
})
}
// Fallback to extensions panel if not showing toast
if (options?.showToastOnLegacyError === false) {
dialogService.showSettingsDialog('extension')
}
}
break
}
case ManagerUIState.NEW_UI:
if (options?.isLegacyOnly) {
// Legacy command is not available in NEW_UI mode
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('manager.legacyMenuNotAvailable'),
life: 3000
})
dialogService.showManagerDialog({ initialTab: ManagerTab.All })
} else {
dialogService.showManagerDialog(
options?.initialTab ? { initialTab: options.initialTab } : undefined
)
}
break
}
}
return {
managerUIState,
isManagerEnabled,
isNewManagerUI,
isLegacyManagerUI,
shouldShowInstallButton,
shouldShowManagerButtons,
openManager
}
}

View File

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

View File

@@ -0,0 +1,118 @@
import { watchDebounced } from '@vueuse/core'
import { orderBy } from 'es-toolkit/compat'
import { computed, ref, watch } from 'vue'
import { DEFAULT_PAGE_SIZE } from '@/constants/searchConstants'
import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway'
import type { SearchAttribute } from '@/types/algoliaTypes'
import type { components } from '@/types/comfyRegistryTypes'
import type { QuerySuggestion, SearchMode } from '@/types/searchServiceTypes'
import { SortableAlgoliaField } from '@/workbench/extensions/manager/types/comfyManagerTypes'
type RegistryNodePack = components['schemas']['Node']
const SEARCH_DEBOUNCE_TIME = 320
const DEFAULT_SORT_FIELD = SortableAlgoliaField.Downloads // Set in the index configuration
/**
* Composable for managing UI state of Comfy Node Registry search.
*/
export function useRegistrySearch(
options: {
initialSortField?: string
initialSearchMode?: SearchMode
initialSearchQuery?: string
initialPageNumber?: number
} = {}
) {
const {
initialSortField = DEFAULT_SORT_FIELD,
initialSearchMode = 'packs',
initialSearchQuery = '',
initialPageNumber = 0
} = options
const isLoading = ref(false)
const sortField = ref<string>(initialSortField)
const searchMode = ref<SearchMode>(initialSearchMode)
const pageSize = ref(DEFAULT_PAGE_SIZE)
const pageNumber = ref(initialPageNumber)
const searchQuery = ref(initialSearchQuery)
const searchResults = ref<RegistryNodePack[]>([])
const suggestions = ref<QuerySuggestion[]>([])
const searchAttributes = computed<SearchAttribute[]>(() =>
searchMode.value === 'nodes' ? ['comfy_nodes'] : ['name', 'description']
)
const searchGateway = useRegistrySearchGateway()
const { searchPacks, clearSearchCache, getSortValue, getSortableFields } =
searchGateway
const updateSearchResults = async (options: { append?: boolean }) => {
isLoading.value = true
if (!options.append) {
pageNumber.value = 0
}
const { nodePacks, querySuggestions } = await searchPacks(
searchQuery.value,
{
pageSize: pageSize.value,
pageNumber: pageNumber.value,
restrictSearchableAttributes: searchAttributes.value
}
)
let sortedPacks = nodePacks
// 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]
)
}
if (options.append && searchResults.value?.length) {
searchResults.value = searchResults.value.concat(sortedPacks)
} else {
searchResults.value = sortedPacks
}
suggestions.value = querySuggestions
isLoading.value = false
}
const onQueryChange = () => updateSearchResults({ append: false })
const onPageChange = () => updateSearchResults({ append: true })
watch([sortField, searchMode], onQueryChange)
watch(pageNumber, onPageChange)
watchDebounced(searchQuery, onQueryChange, {
debounce: SEARCH_DEBOUNCE_TIME,
immediate: true
})
const sortOptions = computed(() => {
return getSortableFields()
})
return {
isLoading,
pageNumber,
pageSize,
sortField,
searchMode,
searchQuery,
suggestions,
searchResults,
sortOptions,
clearCache: clearSearchCache
}
}

View File

@@ -0,0 +1,333 @@
import type { AxiosError, AxiosResponse } from 'axios'
import axios from 'axios'
import { v4 as uuidv4 } from 'uuid'
import { ref } from 'vue'
import { api } from '@/scripts/api'
import { isAbortError } from '@/utils/typeGuardUtil'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import type { components } from '@/workbench/extensions/manager/types/generatedManagerTypes'
type ManagerQueueStatus = components['schemas']['QueueStatus']
type InstallPackParams = components['schemas']['InstallPackParams']
type InstalledPacksResponse = components['schemas']['InstalledPacksResponse']
type UpdateAllPacksParams = components['schemas']['UpdateAllPacksParams']
type ManagerTaskHistory = components['schemas']['HistoryResponse']
type QueueTaskItem = components['schemas']['QueueTaskItem']
const GENERIC_SECURITY_ERR_MSG =
'Forbidden: A security error has occurred. Please check the terminal logs'
/**
* API routes for ComfyUI Manager
*/
enum ManagerRoute {
START_QUEUE = 'manager/queue/start',
RESET_QUEUE = 'manager/queue/reset',
QUEUE_STATUS = 'manager/queue/status',
UPDATE_ALL = 'manager/queue/update_all',
LIST_INSTALLED = 'customnode/installed',
GET_NODES = 'customnode/getmappings',
IMPORT_FAIL_INFO = 'customnode/import_fail_info',
IMPORT_FAIL_INFO_BULK = 'customnode/import_fail_info_bulk',
REBOOT = 'manager/reboot',
IS_LEGACY_MANAGER_UI = 'manager/is_legacy_manager_ui',
TASK_HISTORY = 'manager/queue/history',
QUEUE_TASK = 'manager/queue/task'
}
const managerApiClient = axios.create({
baseURL: api.apiURL('/v2/'),
headers: {
'Content-Type': 'application/json'
}
})
/**
* Service for interacting with the ComfyUI Manager API
* Provides methods for managing packs, ComfyUI-Manager queue operations, and system functions
* Note: This service should only be used when Manager state is NEW_UI
*/
export const useComfyManagerService = () => {
const isLoading = ref(false)
const error = ref<string | null>(null)
// Check if manager service should be available
const isManagerServiceAvailable = () => {
const managerState = useManagerState()
return managerState.isNewManagerUI.value
}
const handleRequestError = (
err: unknown,
context: string,
routeSpecificErrors?: Record<number, string>
) => {
// Don't treat cancellation as an error
if (isAbortError(err)) return
let message: string
if (!axios.isAxiosError(err)) {
message = `${context} failed: ${err instanceof Error ? err.message : String(err)}`
} else {
const axiosError = err as AxiosError<{ message: string }>
const status = axiosError.response?.status
if (status && routeSpecificErrors?.[status]) {
message = routeSpecificErrors[status]
} else if (status === 404) {
message = 'Could not connect to ComfyUI-Manager'
} else {
message =
axiosError.response?.data?.message ??
`${context} failed with status ${status}`
}
}
error.value = message
}
const executeRequest = async <T>(
requestCall: () => Promise<AxiosResponse<T>>,
options: {
errorContext: string
routeSpecificErrors?: Record<number, string>
isQueueOperation?: boolean
}
): Promise<T | null> => {
const { errorContext, routeSpecificErrors, isQueueOperation } = options
// Block service calls if not in NEW_UI state
if (!isManagerServiceAvailable()) {
error.value = 'Manager service is not available in current mode'
return null
}
isLoading.value = true
error.value = null
try {
const response = await requestCall()
if (isQueueOperation) await startQueue()
return response.data
} catch (err) {
handleRequestError(err, errorContext, routeSpecificErrors)
return null
} finally {
isLoading.value = false
}
}
const startQueue = async (signal?: AbortSignal) => {
const errorContext = 'Starting ComfyUI-Manager job queue'
const routeSpecificErrors = {
201: 'Created: ComfyUI-Manager job queue is already running'
}
return executeRequest<null>(
() => managerApiClient.get(ManagerRoute.START_QUEUE, { signal }),
{ errorContext, routeSpecificErrors }
)
}
const getQueueStatus = async (client_id?: string, signal?: AbortSignal) => {
const errorContext = 'Getting ComfyUI-Manager queue status'
return executeRequest<ManagerQueueStatus>(
() =>
managerApiClient.get(ManagerRoute.QUEUE_STATUS, {
params: client_id ? { client_id } : undefined,
signal
}),
{ errorContext }
)
}
const listInstalledPacks = async (signal?: AbortSignal) => {
const errorContext = 'Fetching installed packs'
return executeRequest<InstalledPacksResponse>(
() => managerApiClient.get(ManagerRoute.LIST_INSTALLED, { signal }),
{ errorContext }
)
}
const getImportFailInfo = async (signal?: AbortSignal) => {
const errorContext = 'Fetching import failure information'
return executeRequest<any>(
() => managerApiClient.get(ManagerRoute.IMPORT_FAIL_INFO, { signal }),
{ errorContext }
)
}
const getImportFailInfoBulk = async (
params: components['schemas']['ImportFailInfoBulkRequest'] = {},
signal?: AbortSignal
) => {
const errorContext = 'Fetching bulk import failure information'
if (!params.cnr_ids?.length && !params.urls?.length) {
return {}
}
return executeRequest<components['schemas']['ImportFailInfoBulkResponse']>(
() =>
managerApiClient.post(ManagerRoute.IMPORT_FAIL_INFO_BULK, params, {
signal
}),
{ errorContext }
)
}
const queueTask = async (
kind: QueueTaskItem['kind'],
params: QueueTaskItem['params'],
ui_id?: string,
signal?: AbortSignal
) => {
const task: QueueTaskItem = {
kind,
params,
ui_id: ui_id || uuidv4(),
client_id: api.clientId ?? api.initialClientId ?? 'unknown'
}
const errorContext = `Queueing ${task.kind} task`
const routeSpecificErrors = {
403: GENERIC_SECURITY_ERR_MSG,
404: `Not Found: Task could not be queued`
}
return executeRequest<null>(
() => managerApiClient.post(ManagerRoute.QUEUE_TASK, task, { signal }),
{ errorContext, routeSpecificErrors, isQueueOperation: true }
)
}
const installPack = async (
params: InstallPackParams,
ui_id?: string,
signal?: AbortSignal
) => {
return queueTask('install', params, ui_id, signal)
}
const uninstallPack = async (
params: components['schemas']['UninstallPackParams'],
ui_id?: string,
signal?: AbortSignal
) => {
return queueTask('uninstall', params, ui_id, signal)
}
const disablePack = async (
params: components['schemas']['DisablePackParams'],
ui_id?: string,
signal?: AbortSignal
): Promise<null> => {
return queueTask('disable', params, ui_id, signal)
}
const updatePack = async (
params: components['schemas']['UpdatePackParams'],
ui_id?: string,
signal?: AbortSignal
): Promise<null> => {
return queueTask('update', params, ui_id, signal)
}
const updateAllPacks = async (
params: UpdateAllPacksParams = {},
ui_id?: string,
signal?: AbortSignal
) => {
const errorContext = 'Updating all packs'
const routeSpecificErrors = {
403: 'Forbidden: To use this action, a security_level of `middle or below` is required',
401: 'Unauthorized: ComfyUI-Manager job queue is busy'
}
const queryParams = {
mode: params.mode,
client_id: api.clientId ?? api.initialClientId ?? 'unknown',
ui_id: ui_id || uuidv4()
}
return executeRequest<null>(
() =>
managerApiClient.get(ManagerRoute.UPDATE_ALL, {
params: queryParams,
signal
}),
{ errorContext, routeSpecificErrors, isQueueOperation: true }
)
}
const rebootComfyUI = async (signal?: AbortSignal) => {
const errorContext = 'Rebooting ComfyUI'
const routeSpecificErrors = {
403: 'Forbidden: Rebooting ComfyUI requires security_level of middle or below'
}
return executeRequest<null>(
() => managerApiClient.get(ManagerRoute.REBOOT, { signal }),
{ errorContext, routeSpecificErrors }
)
}
const isLegacyManagerUI = async (signal?: AbortSignal) => {
const errorContext = 'Checking if user set Manager to use the legacy UI'
return executeRequest<{ is_legacy_manager_ui: boolean }>(
() => managerApiClient.get(ManagerRoute.IS_LEGACY_MANAGER_UI, { signal }),
{ errorContext }
)
}
const getTaskHistory = async (
options: {
ui_id?: string
max_items?: number
client_id?: string
offset?: number
} = {},
signal?: AbortSignal
) => {
const errorContext = 'Getting ComfyUI-Manager task history'
return executeRequest<ManagerTaskHistory>(
() =>
managerApiClient.get(ManagerRoute.TASK_HISTORY, {
params: options,
signal
}),
{ errorContext }
)
}
return {
// State
isLoading,
error,
// Queue operations
startQueue,
getQueueStatus,
getTaskHistory,
// Pack management
listInstalledPacks,
getImportFailInfo,
getImportFailInfoBulk,
installPack,
uninstallPack,
enablePack: installPack, // enable is done via install
disablePack,
updatePack,
updateAllPacks,
// System operations
rebootComfyUI,
isLegacyManagerUI
}
}

View File

@@ -0,0 +1,426 @@
import { useEventListener, whenever } from '@vueuse/core'
import { defineStore } from 'pinia'
import { v4 as uuidv4 } from 'uuid'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCachedRequest } from '@/composables/useCachedRequest'
import { useServerLogs } from '@/composables/useServerLogs'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { normalizePackKeys } from '@/utils/packUtils'
import { useManagerQueue } from '@/workbench/extensions/manager/composables/useManagerQueue'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
import type { TaskLog } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import type { components } from '@/workbench/extensions/manager/types/generatedManagerTypes'
type InstallPackParams = components['schemas']['InstallPackParams']
type InstalledPacksResponse = components['schemas']['InstalledPacksResponse']
type ManagerPackInfo = components['schemas']['ManagerPackInfo']
type ManagerPackInstalled = components['schemas']['ManagerPackInstalled']
type ManagerTaskHistory = Record<
string,
components['schemas']['TaskHistoryItem']
>
type ManagerTaskQueue = components['schemas']['TaskStateMessage']
type UpdateAllPacksParams = components['schemas']['UpdateAllPacksParams']
/**
* Store for state of installed node packs
*/
export const useComfyManagerStore = defineStore('comfyManager', () => {
const { t } = useI18n()
const managerService = useComfyManagerService()
const { showManagerProgressDialog } = useDialogService()
const installedPacks = ref<InstalledPacksResponse>({})
const enabledPacksIds = ref<Set<string>>(new Set())
const disabledPacksIds = ref<Set<string>>(new Set())
const installedPacksIds = ref<Set<string>>(new Set())
const installingPacksIds = ref<Set<string>>(new Set())
const isStale = ref(true)
const taskLogs = ref<TaskLog[]>([])
const succeededTasksLogs = ref<TaskLog[]>([])
const failedTasksLogs = ref<TaskLog[]>([])
const taskHistory = ref<ManagerTaskHistory>({})
const succeededTasksIds = ref<string[]>([])
const failedTasksIds = ref<string[]>([])
const taskQueue = ref<ManagerTaskQueue>({
history: {},
running_queue: [],
pending_queue: [],
installed_packs: {}
})
// Track task ID to pack ID mapping for proper state cleanup
const taskIdToPackId = ref(new Map<string, string>())
const managerQueue = useManagerQueue(taskHistory, taskQueue, installedPacks)
// Listen for task completion events to clean up installing state
useEventListener(app.api, 'cm-task-completed', (event: any) => {
const taskId = event.detail?.ui_id
if (taskId && taskIdToPackId.value.has(taskId)) {
const packId = taskIdToPackId.value.get(taskId)!
installingPacksIds.value.delete(packId)
taskIdToPackId.value.delete(taskId)
}
})
const setStale = () => {
isStale.value = true
}
const partitionTaskLogs = () => {
const successTaskLogs: TaskLog[] = []
const failTaskLogs: TaskLog[] = []
for (const log of taskLogs.value) {
if (failedTasksIds.value.includes(log.taskId)) {
failTaskLogs.push(log)
} else {
successTaskLogs.push(log)
}
}
succeededTasksLogs.value = successTaskLogs
failedTasksLogs.value = failTaskLogs
}
const partitionTasks = () => {
const successTasksIds = []
const failTasksIds = []
for (const task of Object.values(taskHistory.value)) {
if (task.status?.status_str === 'success') {
successTasksIds.push(task.ui_id)
} else {
failTasksIds.push(task.ui_id)
}
}
succeededTasksIds.value = successTasksIds
failedTasksIds.value = failTasksIds
}
whenever(
taskHistory,
() => {
partitionTasks()
partitionTaskLogs()
},
{ deep: true }
)
const getPackId = (pack: ManagerPackInstalled) => pack.cnr_id || pack.aux_id
const isInstalledPackId = (packName: string | undefined): boolean =>
!!packName && installedPacksIds.value.has(packName)
const isEnabledPackId = (packName: string | undefined): boolean =>
!!packName &&
isInstalledPackId(packName) &&
enabledPacksIds.value.has(packName)
const isInstallingPackId = (packName: string | undefined): boolean =>
!!packName && installingPacksIds.value.has(packName)
const packsToIdSet = (packs: ManagerPackInstalled[]) =>
packs.reduce((acc, pack) => {
const id = pack.cnr_id || pack.aux_id
if (id) acc.add(id)
return acc
}, new Set<string>())
/**
* A pack is disabled if there is a disabled entry and no corresponding
* enabled entry. If `packname@1.0.2` is disabled, but `packname@1.0.3` is
* enabled, then `packname` is considered enabled.
*
* @example
* installedPacks = {
* "packname@1_0_2": { enabled: false, cnr_id: "packname" },
* "packname": { enabled: true, cnr_id: "packname" }
* }
* isDisabled("packname") // false
*
* installedPacks = {
* "packname@1_0_2": { enabled: false, cnr_id: "packname" },
* }
* isDisabled("packname") // true
*/
const updateDisabledIds = (packs: ManagerPackInstalled[]) => {
// Use temporary variables to avoid triggering reactivity
const enabledIds = new Set<string>()
const disabledIds = new Set<string>()
for (const pack of packs) {
const id = getPackId(pack)
if (!id) continue
const { enabled } = pack
if (enabled === true) enabledIds.add(id)
else if (enabled === false) disabledIds.add(id)
// If pack in both (has a disabled and enabled version), remove from disabled
const inBothSets = enabledIds.has(id) && disabledIds.has(id)
if (inBothSets) disabledIds.delete(id)
}
enabledPacksIds.value = enabledIds
disabledPacksIds.value = disabledIds
}
const updateInstalledIds = (packs: ManagerPackInstalled[]) => {
installedPacksIds.value = packsToIdSet(packs)
}
const onPacksChanged = () => {
const packs = Object.values(installedPacks.value)
updateDisabledIds(packs)
updateInstalledIds(packs)
}
watch(installedPacks, onPacksChanged, { deep: true })
const refreshInstalledList = async () => {
const packs = await managerService.listInstalledPacks()
if (packs) {
// Normalize pack keys to ensure consistent access
installedPacks.value = normalizePackKeys(packs)
}
isStale.value = false
}
whenever(isStale, refreshInstalledList, { immediate: true })
const enqueueTaskWithLogs = async (
task: (taskId: string) => Promise<null>,
taskName: string
) => {
const taskId = uuidv4()
const { logs } = useServerLogs({
ui_id: taskId,
immediate: true
})
try {
// Show progress dialog immediately when task is queued
showManagerProgressDialog()
managerQueue.isProcessing.value = true
// Prepare logging hook
taskLogs.value.push({ taskName, taskId, logs: logs.value })
// Queue the task to the server
await task(taskId)
} catch (error) {
// Reset processing state on error
managerQueue.isProcessing.value = false
// The server has authority over task history in general, but in rare
// case of client-side error, we add that to failed tasks from the client side
taskHistory.value[taskId] = {
ui_id: taskId,
client_id: api.clientId || 'unknown',
kind: 'error',
result: 'failed',
status: {
status_str: 'error',
completed: false,
messages: [error instanceof Error ? error.message : String(error)]
},
timestamp: new Date().toISOString()
}
}
}
const installPack = useCachedRequest<InstallPackParams, void>(
async (params: InstallPackParams, signal?: AbortSignal) => {
if (!params.id) return
let actionDescription = t('g.installing')
if (installedPacksIds.value.has(params.id)) {
const installedPack = installedPacks.value[params.id]
if (installedPack && installedPack.ver !== params.selected_version) {
actionDescription = t('manager.changingVersion', {
from: installedPack.ver,
to: params.selected_version
})
} else {
actionDescription = t('g.enabling')
}
}
installingPacksIds.value.add(params.id)
const task = (taskId: string) => {
taskIdToPackId.value.set(taskId, params.id)
return managerService.installPack(params, taskId, signal)
}
await enqueueTaskWithLogs(task, `${actionDescription} ${params.id}`)
},
{ maxSize: 1 }
)
const uninstallPack = async (
params: ManagerPackInfo,
signal?: AbortSignal
) => {
installPack.clear()
installPack.cancel()
installingPacksIds.value.add(params.id)
const uninstallParams: components['schemas']['UninstallPackParams'] = {
node_name: params.id,
is_unknown: false
}
const task = (taskId: string) => {
taskIdToPackId.value.set(taskId, params.id)
return managerService.uninstallPack(uninstallParams, taskId, signal)
}
await enqueueTaskWithLogs(
task,
t('manager.uninstalling', { id: params.id })
)
}
const updatePack = useCachedRequest<ManagerPackInfo, void>(
async (params: ManagerPackInfo, signal?: AbortSignal) => {
updateAllPacks.cancel()
const updateParams: components['schemas']['UpdatePackParams'] = {
node_name: params.id,
node_ver: params.version
}
const task = (taskId: string) =>
managerService.updatePack(updateParams, taskId, signal)
await enqueueTaskWithLogs(task, t('g.updating', { id: params.id }))
},
{ maxSize: 1 }
)
const updateAllPacks = useCachedRequest<UpdateAllPacksParams, void>(
async (params: UpdateAllPacksParams, signal?: AbortSignal) => {
const task = (taskId: string) =>
managerService.updateAllPacks(params, taskId, signal)
await enqueueTaskWithLogs(task, t('manager.updatingAllPacks'))
},
{ maxSize: 1 }
)
const disablePack = async (params: ManagerPackInfo, signal?: AbortSignal) => {
const disableParams: components['schemas']['DisablePackParams'] = {
node_name: params.id,
is_unknown: false
}
const task = (taskId: string) =>
managerService.disablePack(disableParams, taskId, signal)
await enqueueTaskWithLogs(task, t('g.disabling', { id: params.id }))
}
const getInstalledPackVersion = (packId: string) => {
const pack = installedPacks.value[packId]
return pack?.ver
}
const clearLogs = () => {
taskLogs.value = []
}
const resetTaskState = () => {
// Clear all task-related reactive state for fresh start after restart
taskLogs.value = []
taskHistory.value = {}
succeededTasksIds.value = []
failedTasksIds.value = []
succeededTasksLogs.value = []
failedTasksLogs.value = []
installingPacksIds.value.clear()
taskIdToPackId.value.clear()
// Reset task queue to initial state
taskQueue.value = {
history: {},
running_queue: [],
pending_queue: [],
installed_packs: {}
}
}
return {
// Manager state
isLoading: managerService.isLoading,
error: managerService.error,
taskLogs,
clearLogs,
resetTaskState,
setStale,
// Installed packs state
installedPacks,
installedPacksIds,
isPackInstalled: isInstalledPackId,
isPackEnabled: isEnabledPackId,
isPackInstalling: isInstallingPackId,
getInstalledPackVersion,
refreshInstalledList,
// Task queue state and actions
taskHistory,
taskQueue,
isProcessingTasks: managerQueue.isProcessing,
succeededTasksIds,
failedTasksIds,
succeededTasksLogs,
failedTasksLogs,
managerQueue,
// Pack actions
installPack,
uninstallPack,
updatePack,
updateAllPacks,
disablePack,
enablePack: installPack // Enable is done via install endpoint with a disabled pack
}
})
/**
* Store for state of the manager progress dialog content.
* The dialog itself is managed by the dialog store. This store is used to
* manage the visibility of the dialog's content, header, footer.
*/
export const useManagerProgressDialogStore = defineStore(
'managerProgressDialog',
() => {
const isExpanded = ref(false)
const activeTabIndex = ref(0)
const setActiveTabIndex = (index: number) => {
activeTabIndex.value = index
}
const getActiveTabIndex = () => {
return activeTabIndex.value
}
const toggle = () => {
isExpanded.value = !isExpanded.value
}
const collapse = () => {
isExpanded.value = false
}
const expand = () => {
isExpanded.value = true
}
return {
isExpanded,
toggle,
collapse,
expand,
setActiveTabIndex,
getActiveTabIndex
}
}
)

View File

@@ -0,0 +1,70 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
export const useConflictDetectionStore = defineStore(
'conflictDetection',
() => {
// State
const conflictedPackages = ref<ConflictDetectionResult[]>([])
const isDetecting = ref(false)
const lastDetectionTime = ref<string | null>(null)
// Getters
const hasConflicts = computed(() =>
conflictedPackages.value.some((pkg) => pkg.has_conflict)
)
const getConflictsForPackageByID = computed(
() => (packageId: string) =>
conflictedPackages.value.find((pkg) => pkg.package_id === packageId)
)
const bannedPackages = computed(() =>
conflictedPackages.value.filter((pkg) =>
pkg.conflicts.some((conflict) => conflict.type === 'banned')
)
)
const securityPendingPackages = computed(() =>
conflictedPackages.value.filter((pkg) =>
pkg.conflicts.some((conflict) => conflict.type === 'pending')
)
)
// Actions
function setConflictedPackages(packages: ConflictDetectionResult[]) {
conflictedPackages.value = [...packages]
}
function clearConflicts() {
conflictedPackages.value = []
}
function setDetecting(detecting: boolean) {
isDetecting.value = detecting
}
function setLastDetectionTime(time: string) {
lastDetectionTime.value = time
}
return {
// State
conflictedPackages,
isDetecting,
lastDetectionTime,
// Getters
hasConflicts,
getConflictsForPackageByID,
bannedPackages,
securityPendingPackages,
// Actions
setConflictedPackages,
clearConflicts,
setDetecting,
setLastDetectionTime
}
}
)

View File

@@ -0,0 +1,58 @@
import type { InjectionKey, Ref } from 'vue'
import type { AlgoliaNodePack } from '@/types/algoliaTypes'
import type { components } from '@/types/comfyRegistryTypes'
export type RegistryPack = components['schemas']['Node']
export type MergedNodePack = RegistryPack & AlgoliaNodePack
export const isMergedNodePack = (
nodePack: RegistryPack | AlgoliaNodePack
): nodePack is MergedNodePack => 'comfy_nodes' in nodePack
export const IsInstallingKey: InjectionKey<Ref<boolean>> =
Symbol('isInstalling')
export enum ManagerTab {
All = 'all',
Installed = 'installed',
Workflow = 'workflow',
Missing = 'missing',
UpdateAvailable = 'updateAvailable'
}
export interface TabItem {
id: ManagerTab
label: string
icon: string
}
export type TaskLog = {
taskName: string
taskId: string
logs: string[]
}
export interface UseNodePacksOptions {
immediate?: boolean
maxConcurrent?: number
}
export interface SearchOption<T> {
id: T
label: string
}
export enum SortableAlgoliaField {
Downloads = 'total_install',
Created = 'create_time',
Updated = 'update_time',
Publisher = 'publisher_id',
Name = 'name'
}
export interface ManagerState {
selectedTabId: ManagerTab
searchQuery: string
searchMode: 'nodes' | 'packs'
sortField: string
}

View File

@@ -0,0 +1,10 @@
/**
* System compatibility type definitions
* Registry supports exactly these values, null/undefined means compatible with all
*/
// Registry OS
export type RegistryOS = 'Windows' | 'macOS' | 'Linux'
// Registry Accelerator
export type RegistryAccelerator = 'CUDA' | 'ROCm' | 'Metal' | 'CPU'

View File

@@ -0,0 +1,80 @@
/**
* Type definitions for the conflict detection system.
* These types are used to detect compatibility issues between Node Packs and the system environment.
*
* This file extends and uses types from comfyRegistryTypes.ts to maintain consistency
* with the Registry API schema.
*/
import type { components } from '@/types/comfyRegistryTypes'
// Re-export core types from Registry API
export type Node = components['schemas']['Node']
/**
* Conflict types that can be detected in the system
* @enum {string}
*/
export type ConflictType =
| 'comfyui_version' // ComfyUI version mismatch
| 'frontend_version' // Frontend version mismatch
| 'import_failed'
| 'os' // Operating system incompatibility
| 'accelerator' // GPU/accelerator incompatibility
| 'banned' // Banned package
| 'pending' // Security verification pending
/**
* Node Pack requirements from Registry API
* Extends Node type with additional installation and compatibility metadata
*/
export interface NodeRequirements extends Node {
installed_version: string
is_enabled: boolean
is_banned: boolean
is_pending: boolean
// Aliases for backwards compatibility with existing code
version_status?: string
}
/**
* Current system environment information
*/
export interface SystemEnvironment {
// Version information
comfyui_version?: string
frontend_version?: string
// Platform information
os?: string
// GPU/accelerator information
accelerator?: string
}
/**
* Individual conflict detection result for a package
*/
export interface ConflictDetectionResult {
package_id: string
package_name: string
has_conflict: boolean
conflicts: ConflictDetail[]
is_compatible: boolean
}
/**
* Detailed information about a specific conflict
*/
export interface ConflictDetail {
type: ConflictType
current_value: string
required_value: string
}
/**
* Response payload from conflict detection API
*/
export interface ConflictDetectionResponse {
success: boolean
error_message?: string
results: ConflictDetectionResult[]
detected_system_environment?: Partial<SystemEnvironment>
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
import type { ComputedRef, InjectionKey } from 'vue'
interface ImportFailedContext {
importFailed: ComputedRef<boolean>
showImportFailedDialog: () => void
}
export const ImportFailedKey: InjectionKey<ImportFailedContext> =
Symbol('ImportFailed')

View File

@@ -0,0 +1,62 @@
import type { ConflictDetail } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
/**
* Generates a localized conflict message for a given conflict detail.
* This function should be used anywhere conflict messages need to be displayed.
*
* @param conflict The conflict detail object
* @param t The i18n translation function
* @returns A localized conflict message string
*/
export function getConflictMessage(
conflict: ConflictDetail,
t: (key: string, params?: Record<string, any>) => string
): string {
const messageKey = `manager.conflicts.conflictMessages.${conflict.type}`
// For version and compatibility conflicts, use interpolated message
if (
conflict.type === 'comfyui_version' ||
conflict.type === 'frontend_version' ||
conflict.type === 'os' ||
conflict.type === 'accelerator'
) {
return t(messageKey, {
current: conflict.current_value,
required: conflict.required_value
})
}
// For banned, pending, and import_failed, use simple message
if (
conflict.type === 'banned' ||
conflict.type === 'pending' ||
conflict.type === 'import_failed'
) {
return t(messageKey)
}
// Fallback to generic message with interpolation
return t('manager.conflicts.conflictMessages.generic', {
current: conflict.current_value,
required: conflict.required_value
})
}
/**
* Generates conflict messages for multiple conflicts and joins them.
*
* @param conflicts Array of conflict details
* @param t The i18n translation function
* @param separator The separator to use when joining messages (default: '; ')
* @returns A single string with all conflict messages joined
*/
export function getJoinedConflictMessages(
conflicts: ConflictDetail[],
t: (key: string, params?: Record<string, any>) => string,
separator = '; '
): string {
return conflicts
.map((conflict) => getConflictMessage(conflict, t))
.join(separator)
}

View File

@@ -0,0 +1,83 @@
import { groupBy, uniqBy } from 'es-toolkit/compat'
import { normalizePackId } from '@/utils/packUtils'
import type {
ConflictDetail,
ConflictDetectionResult
} from '@/workbench/extensions/manager/types/conflictDetectionTypes'
/**
* Checks for banned package status conflicts.
*/
export function createBannedConflict(
isBanned?: boolean
): ConflictDetail | null {
if (isBanned === true) {
return {
type: 'banned',
current_value: 'installed',
required_value: 'not_banned'
}
}
return null
}
/**
* Checks for pending package status conflicts.
*/
export function createPendingConflict(
isPending?: boolean
): ConflictDetail | null {
if (isPending === true) {
return {
type: 'pending',
current_value: 'installed',
required_value: 'not_pending'
}
}
return null
}
/**
* Groups and deduplicates conflicts by normalized package name.
* Consolidates multiple conflict sources (registry checks, import failures, disabled packages with version suffix)
* into a single UI entry per package.
*
* Example:
* - Input: [{name: "pack@1_0_3", conflicts: [...]}, {name: "pack", conflicts: [...]}]
* - Output: [{name: "pack", conflicts: [...combined unique conflicts...]}]
*
* @param conflicts Array of conflict detection results (may have duplicate packages with version suffixes)
* @returns Array of deduplicated conflict results grouped by normalized package name
*/
export function consolidateConflictsByPackage(
conflicts: ConflictDetectionResult[]
): ConflictDetectionResult[] {
// Group conflicts by normalized package name using es-toolkit
const grouped = groupBy(conflicts, (conflict) =>
normalizePackId(conflict.package_name)
)
// Merge conflicts for each group
return Object.entries(grouped).map(([packageName, packageConflicts]) => {
// Flatten all conflicts from the group
const allConflicts = packageConflicts.flatMap((pc) => pc.conflicts)
// Remove duplicate conflicts using uniqBy
const uniqueConflicts = uniqBy(
allConflicts,
(conflict) =>
`${conflict.type}|${conflict.current_value}|${conflict.required_value}`
)
// Use the first item as base and update with merged data
const baseItem = packageConflicts[0]
return {
...baseItem,
package_name: packageName, // Use normalized name
conflicts: uniqueConflicts,
has_conflict: uniqueConflicts.length > 0,
is_compatible: uniqueConflicts.length === 0
}
})
}

View File

@@ -0,0 +1,125 @@
import { isEmpty, isNil } from 'es-toolkit/compat'
import type {
RegistryAccelerator,
RegistryOS
} from '@/workbench/extensions/manager/types/compatibility.types'
import type { ConflictDetail } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
/**
* Maps system OS string to Registry OS format
* @param systemOS Raw OS string from system stats ('darwin', 'win32', 'linux', etc)
* @returns Registry OS or undefined if unknown
*/
function getRegistryOS(systemOS?: string): RegistryOS | undefined {
if (!systemOS) return undefined
const lower = systemOS.toLowerCase()
// Check darwin first to avoid matching 'win' in 'darwin'
if (lower.includes('darwin') || lower.includes('mac')) return 'macOS'
if (lower.includes('win')) return 'Windows'
if (lower.includes('linux')) return 'Linux'
return undefined
}
/**
* Maps device type to Registry accelerator format
* @param deviceType Raw device type from system stats ('cuda', 'mps', 'rocm', 'cpu', etc)
* @returns Registry accelerator
*/
function getRegistryAccelerator(deviceType?: string): RegistryAccelerator {
if (!deviceType) return 'CPU'
const lower = deviceType.toLowerCase()
if (lower === 'cuda') return 'CUDA'
if (lower === 'mps') return 'Metal'
if (lower === 'rocm') return 'ROCm'
return 'CPU'
}
/**
* Checks OS compatibility
* @param supported Supported OS list from Registry (null/undefined = all OS supported)
* @param current Current system OS
* @returns ConflictDetail if incompatible, null if compatible
*/
export function checkOSCompatibility(
supported?: RegistryOS[] | null,
current?: string
): ConflictDetail | null {
// null/undefined/empty = all OS supported
if (isNil(supported) || isEmpty(supported)) return null
const currentOS = getRegistryOS(current)
if (!currentOS) {
return {
type: 'os',
current_value: 'Unknown',
required_value: supported.join(', ')
}
}
if (!supported.includes(currentOS)) {
return {
type: 'os',
current_value: currentOS,
required_value: supported.join(', ')
}
}
return null
}
/**
* Checks accelerator compatibility
* @param supported Supported accelerators from Registry (null/undefined = all accelerators supported)
* @param current Current device type
* @returns ConflictDetail if incompatible, null if compatible
*/
export function checkAcceleratorCompatibility(
supported?: RegistryAccelerator[] | null,
current?: string
): ConflictDetail | null {
// null/undefined/empty = all accelerator supported
if (isNil(supported) || isEmpty(supported)) return null
const currentAcc = getRegistryAccelerator(current)
if (!supported.includes(currentAcc)) {
return {
type: 'accelerator',
current_value: currentAcc,
required_value: supported.join(', ')
}
}
return null
}
/**
* Normalizes OS values from Registry API
* Handles edge cases like "OS Independent"
* @returns undefined if all OS supported, otherwise filtered valid OS list
*/
export function normalizeOSList(
osValues?: string[] | null
): RegistryOS[] | undefined {
if (isNil(osValues) || isEmpty(osValues)) return undefined
// "OS Independent" means all OS supported
if (osValues.some((os) => os.toLowerCase() === 'os independent')) {
return undefined
}
// Filter to valid Registry OS values only
const validOS: RegistryOS[] = []
osValues.forEach((os) => {
if (os === 'Windows' || os === 'macOS' || os === 'Linux') {
if (!validOS.includes(os)) validOS.push(os)
}
})
return validOS.length > 0 ? validOS : undefined
}

View File

@@ -0,0 +1,73 @@
import { isEmpty, isNil } from 'es-toolkit/compat'
import { clean, satisfies } from 'semver'
import config from '@/config'
import type {
ConflictDetail,
ConflictType
} from '@/workbench/extensions/manager/types/conflictDetectionTypes'
/**
* Cleans a version string by removing common prefixes and normalizing format
* @param version Raw version string (e.g., "v1.2.3", "1.2.3-alpha")
* @returns Cleaned version string or original if cleaning fails
*/
function cleanVersion(version: string): string {
return clean(version) || version
}
/**
* Checks version compatibility and returns conflict details.
* Supports all semver ranges including >=, <=, >, <, ~, ^ operators.
* @param type Conflict type (e.g., 'comfyui_version', 'frontend_version')
* @param currentVersion Current version string
* @param supportedVersion Required version range string
* @returns ConflictDetail object if incompatible, null if compatible
*/
export function checkVersionCompatibility(
type: ConflictType,
currentVersion?: string,
supportedVersion?: string
): ConflictDetail | null {
// If current version is unknown, assume compatible (no conflict)
if (isNil(currentVersion) || isEmpty(currentVersion)) {
return null
}
// If no version requirement specified, assume compatible (no conflict)
if (isNil(supportedVersion) || isEmpty(supportedVersion?.trim())) {
return null
}
// Clean and check version compatibility
const cleanCurrent = cleanVersion(currentVersion)
// Check if version satisfies the range
let isCompatible = false
try {
isCompatible = satisfies(cleanCurrent, supportedVersion)
} catch {
// If semver can't parse it, return conflict
return {
type,
current_value: currentVersion,
required_value: supportedVersion
}
}
if (isCompatible) return null
return {
type,
current_value: currentVersion,
required_value: supportedVersion
}
}
/**
* get frontend version from config.
* @returns frontend version string or undefined
*/
export function getFrontendVersion(): string | undefined {
return config.app_version || import.meta.env.VITE_APP_VERSION || undefined
}