mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-27 18:24:11 +00:00
merge main into rh-test
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
170
src/workbench/extensions/manager/composables/useManagerQueue.ts
Normal file
170
src/workbench/extensions/manager/composables/useManagerQueue.ts
Normal 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
|
||||
}
|
||||
}
|
||||
208
src/workbench/extensions/manager/composables/useManagerState.ts
Normal file
208
src/workbench/extensions/manager/composables/useManagerState.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
333
src/workbench/extensions/manager/services/comfyManagerService.ts
Normal file
333
src/workbench/extensions/manager/services/comfyManagerService.ts
Normal 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
|
||||
}
|
||||
}
|
||||
426
src/workbench/extensions/manager/stores/comfyManagerStore.ts
Normal file
426
src/workbench/extensions/manager/stores/comfyManagerStore.ts
Normal 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
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
58
src/workbench/extensions/manager/types/comfyManagerTypes.ts
Normal file
58
src/workbench/extensions/manager/types/comfyManagerTypes.ts
Normal 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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
}
|
||||
1909
src/workbench/extensions/manager/types/generatedManagerTypes.ts
generated
Normal file
1909
src/workbench/extensions/manager/types/generatedManagerTypes.ts
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
import type { ComputedRef, InjectionKey } from 'vue'
|
||||
|
||||
interface ImportFailedContext {
|
||||
importFailed: ComputedRef<boolean>
|
||||
showImportFailedDialog: () => void
|
||||
}
|
||||
|
||||
export const ImportFailedKey: InjectionKey<ImportFailedContext> =
|
||||
Symbol('ImportFailed')
|
||||
@@ -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)
|
||||
}
|
||||
83
src/workbench/extensions/manager/utils/conflictUtils.ts
Normal file
83
src/workbench/extensions/manager/utils/conflictUtils.ts
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
125
src/workbench/extensions/manager/utils/systemCompatibility.ts
Normal file
125
src/workbench/extensions/manager/utils/systemCompatibility.ts
Normal 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
|
||||
}
|
||||
73
src/workbench/extensions/manager/utils/versionUtil.ts
Normal file
73
src/workbench/extensions/manager/utils/versionUtil.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user