Add node pack actions: install, uninstall, enable, disable, change version (#3016)

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Christian Byrne
2025-03-13 10:53:56 -07:00
committed by GitHub
parent fe5e4e0d8a
commit 8b69b280fa
23 changed files with 671 additions and 339 deletions

View File

@@ -1,24 +0,0 @@
<template>
<Button
outlined
class="m-0 p-0 rounded-lg border-neutral-700"
severity="secondary"
:class="{
'w-full': fullWidth,
'w-min-content': !fullWidth
}"
>
<span class="py-2.5 px-3">
{{ multi ? $t('manager.installSelected') : $t('g.install') }}
</span>
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
defineProps<{
fullWidth?: boolean
multi?: boolean
}>()
</script>

View File

@@ -1,8 +1,7 @@
<template>
<div class="relative">
<Button
v-if="displayVersion"
:label="displayVersion"
:label="installedVersion"
severity="secondary"
icon="pi pi-chevron-right"
icon-pos="right"
@@ -22,44 +21,40 @@
}"
>
<PackVersionSelectorPopover
:selected-version="selectedVersion"
:installed-version="installedVersion"
:node-pack="nodePack"
@select="onSelect"
@cancel="closeVersionSelector"
@apply="applyVersionSelection"
@submit="closeVersionSelector"
/>
</Popover>
</div>
</template>
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import PackVersionSelectorPopover from '@/components/dialog/content/manager/PackVersionSelectorPopover.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
const { nodePack, version = SelectedVersion.NIGHTLY } = defineProps<{
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
version?: string
}>()
const emit = defineEmits<{
'update:version': [version: string]
}>()
const popoverRef = ref()
const selectedVersion = ref<string>(version)
const displayVersion = computed(() => {
if (selectedVersion.value === SelectedVersion.LATEST) {
// If there is no version, treat as unclaimed GitHub pack and use nightly
return nodePack?.latest_version?.version || SelectedVersion.NIGHTLY
}
return selectedVersion.value
const managerStore = useComfyManagerStore()
const installedVersion = computed(() => {
if (!nodePack.id) return SelectedVersion.NIGHTLY
return (
managerStore.installedPacks[nodePack.id]?.ver ??
nodePack.latest_version?.version ??
SelectedVersion.NIGHTLY
)
})
const toggleVersionSelector = (event: Event) => {
@@ -69,17 +64,4 @@ const toggleVersionSelector = (event: Event) => {
const closeVersionSelector = () => {
popoverRef.value.hide()
}
const onSelect = (newVersion: string) => {
selectedVersion.value = newVersion
}
const applyVersionSelection = (newVersion: string) => {
selectedVersion.value = newVersion
emit('update:version', newVersion)
// TODO: after manager store added, install the pack here
closeVersionSelector()
}
whenever(() => version, onSelect)
</script>

View File

@@ -4,7 +4,7 @@
{{ $t('manager.selectVersion') }}
</span>
<div
v-if="isLoading"
v-if="isLoadingVersions || isQueueing"
class="text-center text-muted py-4 flex flex-col items-center"
>
<ProgressSpinner class="w-8 h-8 mb-2" />
@@ -20,7 +20,7 @@
</div>
<Listbox
v-else
v-model="currentSelection"
v-model="selectedVersion"
option-label="label"
option-value="value"
:options="allVersionOptions"
@@ -31,9 +31,9 @@
<div class="flex justify-between items-center w-full p-1">
<span>{{ slotProps.option.label }}</span>
<i
v-if="currentSelection === slotProps.option.value"
v-if="selectedVersion === slotProps.option.value"
class="pi pi-check text-highlight"
></i>
/>
</div>
</template>
</Listbox>
@@ -43,46 +43,61 @@
text
severity="secondary"
:label="$t('g.cancel')"
:disabled="isQueueing"
@click="emit('cancel')"
/>
<Button
severity="secondary"
:label="$t('g.install')"
@click="emit('apply', currentSelection ?? SelectedVersion.LATEST)"
class="py-3 px-4 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 { useAsyncState } from '@vueuse/core'
import { useAsyncState, whenever } from '@vueuse/core'
import Button from 'primevue/button'
import Listbox from 'primevue/listbox'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import {
ManagerChannel,
ManagerDatabaseSource,
SelectedVersion
} from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
const { nodePack, selectedVersion = SelectedVersion.NIGHTLY } = defineProps<{
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
selectedVersion?: string
}>()
const emit = defineEmits<{
cancel: []
apply: [version: string]
submit: []
}>()
const { t } = useI18n()
const registryService = useComfyRegistryService()
const managerStore = useComfyManagerStore()
const currentSelection = ref<string>(selectedVersion)
const isQueueing = ref(false)
const selectedVersion = ref<string>(SelectedVersion.LATEST)
onMounted(() => {
selectedVersion.value =
nodePack.publisher?.name === 'Unclaimed'
? SelectedVersion.NIGHTLY
: nodePack.latest_version?.version ?? SelectedVersion.NIGHTLY
})
const fetchVersions = async () => {
if (!nodePack?.id) return []
@@ -90,21 +105,29 @@ const fetchVersions = async () => {
}
const {
isLoading,
isLoading: isLoadingVersions,
state: versions,
execute: startFetchVersions
} = useAsyncState(fetchVersions, [])
const specialOptions = computed(() => [
{
value: SelectedVersion.NIGHTLY,
label: t('manager.nightlyVersion')
},
{
value: SelectedVersion.LATEST,
label: t('manager.latestVersion')
const specialOptions = computed(() => {
const options = [
{
value: SelectedVersion.LATEST,
label: t('manager.latestVersion')
}
]
// Only include nightly option if there is a repo
if (nodePack.repository?.length) {
options.push({
value: SelectedVersion.NIGHTLY,
label: t('manager.nightlyVersion')
})
}
])
return options
})
const versionOptions = computed(() =>
versions.value.map((version) => ({
@@ -118,9 +141,28 @@ const allVersionOptions = computed(() => [
...versionOptions.value
])
watch(
() => nodePack,
whenever(
() => nodePack.id,
() => startFetchVersions(),
{ deep: true }
)
const handleSubmit = async () => {
isQueueing.value = true
await managerStore.installPack.call({
id: nodePack.id,
repository: nodePack.repository ?? '',
channel: ManagerChannel.DEFAULT,
mode: ManagerDatabaseSource.CACHE,
version: selectedVersion.value,
selected_version: selectedVersion.value
})
isQueueing.value = false
emit('submit')
}
onUnmounted(() => {
managerStore.installPack.clear()
})
</script>

View File

@@ -2,8 +2,7 @@ import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import Popover from 'primevue/popover'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -21,7 +20,36 @@ const mockNodePack = {
}
}
const mockInstalledPacks = {
'test-pack': { ver: '1.5.0' },
'installed-pack': { ver: '2.0.0' }
}
vi.mock('@/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
installedPacks: mockInstalledPacks,
isPackInstalled: (id: string) =>
!!mockInstalledPacks[id as keyof typeof mockInstalledPacks]
}))
}))
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()
})
const mountComponent = ({
props = {}
}: Record<string, any> = {}): VueWrapper => {
@@ -38,122 +66,100 @@ describe('PackVersionBadge', () => {
},
global: {
plugins: [PrimeVue, createPinia(), i18n],
components: {
Popover,
PackVersionSelectorPopover
stubs: {
Popover: PopoverStub,
PackVersionSelectorPopover: true
}
}
})
}
it('renders with default version (NIGHTLY)', () => {
it('renders with installed version from store', () => {
const wrapper = mountComponent()
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe('1.5.0') // From mockInstalledPacks
})
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 button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).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 button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe(SelectedVersion.NIGHTLY)
})
it('renders with provided version', () => {
const version = '2.0.0'
const wrapper = mountComponent({ props: { version } })
it('falls back to NIGHTLY when nodePack.id is missing', () => {
const invalidPack = {
name: 'Invalid Pack'
}
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe(version)
})
it('shows actual latest (semantic) version prop when version is set to latest', () => {
const wrapper = mountComponent({
props: { version: SelectedVersion.LATEST }
props: { nodePack: invalidPack }
})
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe(mockNodePack.latest_version.version)
expect(button.props('label')).toBe(SelectedVersion.NIGHTLY)
})
it('toggles the popover when button is clicked', async () => {
const wrapper = mountComponent()
// Spy on the toggle method
const popoverToggleSpy = vi.fn()
const popover = wrapper.findComponent(Popover)
popover.vm.toggle = popoverToggleSpy
// Open the popover
// Click the button
await wrapper.findComponent(Button).trigger('click')
// Verify that the toggle method was called
expect(popoverToggleSpy).toHaveBeenCalled()
expect(mockToggle).toHaveBeenCalled()
})
it('emits update:version event when version is selected', async () => {
it('closes the popover when cancel is emitted', async () => {
const wrapper = mountComponent()
// Open the popover
await wrapper.findComponent(Button).trigger('click')
// Simulate the popover emitting an apply event
wrapper.findComponent(PackVersionSelectorPopover).vm.$emit('apply', '3.0.0')
await nextTick()
// Check if the update:version event was emitted with the correct value
expect(wrapper.emitted('update:version')).toBeTruthy()
expect(wrapper.emitted('update:version')![0]).toEqual(['3.0.0'])
})
it('closes the popover when cancel is clicked', async () => {
const wrapper = mountComponent()
// Open the popover
await wrapper.findComponent(Button).trigger('click')
// Simulate the popover emitting a cancel event
wrapper.findComponent(PackVersionSelectorPopover).vm.$emit('cancel')
await nextTick()
// Check if the popover is hidden
expect(wrapper.findComponent(Popover).isVisible()).toBe(false)
// Verify that the hide method was called
expect(mockHide).toHaveBeenCalled()
})
it('updates displayed version when version prop changes', async () => {
const wrapper = mountComponent({ props: { version: '1.0.0' } })
it('closes the popover when submit is emitted', async () => {
const wrapper = mountComponent()
expect(wrapper.findComponent(Button).props('label')).toBe('1.0.0')
// Simulate the popover emitting a submit event
wrapper.findComponent(PackVersionSelectorPopover).vm.$emit('submit')
await nextTick()
// Update the version prop
await wrapper.setProps({ version: '2.0.0' })
// Check if the displayed version was updated
expect(wrapper.findComponent(Button).props('label')).toBe('2.0.0')
})
it('handles null or undefined nodePack', async () => {
const wrapper = mountComponent({ props: { nodePack: null } })
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe(SelectedVersion.NIGHTLY)
// Should not crash when clicking the button
await button.trigger('click')
expect(wrapper.findComponent(Popover).isVisible()).toBe(false)
})
it('handles missing latest_version (unclaimed pack) by falling back to NIGHTLY', async () => {
const incompleteNodePack = { id: 'test-pack', name: 'Test Pack' }
const wrapper = mountComponent({
props: {
nodePack: incompleteNodePack,
version: SelectedVersion.LATEST
}
})
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
// Should fallback to nightly string when latest_version is missing
expect(button.props('label')).toBe(SelectedVersion.NIGHTLY)
// Verify that the hide method was called
expect(mockHide).toHaveBeenCalled()
})
})

View File

@@ -12,7 +12,8 @@ import { SelectedVersion } from '@/types/comfyManagerTypes'
import PackVersionSelectorPopover from '../PackVersionSelectorPopover.vue'
const mockVersions = [
// Default mock versions for reference
const defaultMockVersions = [
{ version: '1.0.0', createdAt: '2023-01-01' },
{ version: '0.9.0', createdAt: '2022-12-01' },
{ version: '0.8.0', createdAt: '2022-11-01' }
@@ -21,17 +22,31 @@ const mockVersions = [
const mockNodePack = {
id: 'test-pack',
name: 'Test Pack',
latest_version: { version: '1.0.0' }
latest_version: { version: '1.0.0' },
repository: 'https://github.com/user/repo'
}
const mockGetPackVersions = vi.fn().mockResolvedValue(mockVersions)
// Create mock functions
const mockGetPackVersions = vi.fn()
const mockInstallPack = vi.fn().mockResolvedValue(undefined)
// Mock the registry service
vi.mock('@/services/comfyRegistryService', () => ({
useComfyRegistryService: vi.fn(() => ({
getPackVersions: mockGetPackVersions
}))
}))
// Mock the manager store
vi.mock('@/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
installPack: {
call: mockInstallPack,
clear: vi.fn()
}
}))
}))
const waitForPromises = async () => {
await new Promise((resolve) => setTimeout(resolve, 16))
await nextTick()
@@ -40,8 +55,8 @@ const waitForPromises = async () => {
describe('PackVersionSelectorPopover', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetPackVersions.mockClear()
mockGetPackVersions.mockResolvedValue(mockVersions)
mockGetPackVersions.mockReset()
mockInstallPack.mockReset().mockResolvedValue(undefined)
})
const mountComponent = ({
@@ -56,7 +71,6 @@ describe('PackVersionSelectorPopover', () => {
return mount(PackVersionSelectorPopover, {
props: {
nodePack: mockNodePack,
selectedVersion: SelectedVersion.NIGHTLY,
...props
},
global: {
@@ -69,6 +83,9 @@ describe('PackVersionSelectorPopover', () => {
}
it('fetches versions on mount', async () => {
// Set up the mock for this specific test
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
mountComponent()
await waitForPromises()
@@ -79,7 +96,9 @@ describe('PackVersionSelectorPopover', () => {
// Delay the promise resolution
mockGetPackVersions.mockImplementationOnce(
() =>
new Promise((resolve) => setTimeout(() => resolve(mockVersions), 1000))
new Promise((resolve) =>
setTimeout(() => resolve(defaultMockVersions), 1000)
)
)
const wrapper = mountComponent()
@@ -88,6 +107,9 @@ describe('PackVersionSelectorPopover', () => {
})
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()
@@ -95,29 +117,23 @@ describe('PackVersionSelectorPopover', () => {
expect(listbox.exists()).toBe(true)
const options = listbox.props('options')!
expect(options.length).toBe(5) // 2 special options + 3 version options
// Check that we have both special options and version options
expect(options.length).toBe(defaultMockVersions.length + 2) // 2 special options + version options
// Check special options
expect(options[0].value).toBe(SelectedVersion.NIGHTLY)
expect(options[1].value).toBe(SelectedVersion.LATEST)
// Check that special options exist
expect(options.some((o) => o.value === SelectedVersion.NIGHTLY)).toBe(true)
expect(options.some((o) => o.value === SelectedVersion.LATEST)).toBe(true)
// Check version options
expect(options[2].value).toBe('1.0.0')
expect(options[3].value).toBe('0.9.0')
expect(options[4].value).toBe('0.8.0')
})
it('initializes with the provided selectedVersion prop', async () => {
const selectedVersion = '0.9.0'
const wrapper = mountComponent({ props: { selectedVersion } })
await waitForPromises()
// Check that the listbox has the correct model value
const listbox = wrapper.findComponent(Listbox)
expect(listbox.props('modelValue')).toBe(selectedVersion)
// Check that version options exist
expect(options.some((o) => o.value === '1.0.0')).toBe(true)
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()
@@ -127,35 +143,42 @@ describe('PackVersionSelectorPopover', () => {
expect(wrapper.emitted('cancel')).toBeTruthy()
})
it('emits apply event with current selection when apply button is clicked', async () => {
const selectedVersion = '0.9.0'
const wrapper = mountComponent({ props: { selectedVersion } })
await waitForPromises()
it('calls installPack and emits submit when install button is clicked', async () => {
// Set up the mock for this specific test
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const applyButton = wrapper.findAllComponents(Button)[1]
await applyButton.trigger('click')
expect(wrapper.emitted('apply')).toBeTruthy()
expect(wrapper.emitted('apply')![0]).toEqual([selectedVersion])
})
it('emits apply event with LATEST when no selection and apply button is clicked', async () => {
const wrapper = mountComponent({ props: { selectedVersion: null } })
await waitForPromises()
const applyButton = wrapper.findAllComponents(Button)[1]
await applyButton.trigger('click')
expect(wrapper.emitted('apply')).toBeTruthy()
expect(wrapper.emitted('apply')![0]).toEqual([SelectedVersion.LATEST])
})
it('is reactive to nodePack prop changes', async () => {
const wrapper = mountComponent()
await waitForPromises()
// Clear mock calls to check if getPackVersions is called again
mockGetPackVersions.mockClear()
// 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' }
@@ -167,23 +190,45 @@ describe('PackVersionSelectorPopover', () => {
})
describe('Unclaimed GitHub packs handling', () => {
it('falls back to nightly when comfy-api returns null when fetching versions', async () => {
mockGetPackVersions.mockResolvedValueOnce(null)
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
}
})
const wrapper = mountComponent()
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.exists()).toBe(true)
expect(listbox.props('modelValue')).toBe(SelectedVersion.NIGHTLY)
})
it('falls back to nightly when component mounts with no versions (unclaimed pack)', async () => {
mockGetPackVersions.mockResolvedValueOnce([])
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
}
})
const wrapper = mountComponent()
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.exists()).toBe(true)
expect(listbox.props('modelValue')).toBe(SelectedVersion.NIGHTLY)
})
})

View File

@@ -0,0 +1,52 @@
<template>
<Button
outlined
class="m-0 p-0 rounded-lg border-neutral-700"
:class="{
'w-full': fullWidth,
'w-min-content': !fullWidth
}"
:disabled="isExecuted"
v-bind="$attrs"
@click="onClick"
>
<span class="py-2.5 px-3">
<template v-if="isExecuted">
{{ loadingMessage ?? $t('g.loading') }}
</template>
<template v-else>
{{ label }}
</template>
</span>
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { ref } from 'vue'
const {
label,
loadingMessage,
fullWidth = false
} = defineProps<{
label: string
loadingMessage?: string
fullWidth?: boolean
}>()
const emit = defineEmits<{
action: []
}>()
defineOptions({
inheritAttrs: false
})
const isExecuted = ref(false)
const onClick = (): void => {
isExecuted.value = true
emit('action')
}
</script>

View File

@@ -0,0 +1,79 @@
<template>
<div class="flex items-center">
<ToggleSwitch
:model-value="isEnabled"
:disabled="isLoading"
aria-label="Enable or disable pack"
@update:model-value="onToggle"
/>
</div>
</template>
<script setup lang="ts">
import { debounce } from 'lodash'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, ref } from 'vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import {
InstallPackParams,
ManagerChannel,
SelectedVersion
} from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
const TOGGLE_DEBOUNCE_MS = 300
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const managerStore = useComfyManagerStore()
const isLoading = ref(false)
const isEnabled = computed(() => managerStore.isPackEnabled(nodePack.id))
const version = computed(() => {
const id = nodePack.id
if (!id) return SelectedVersion.NIGHTLY
return (
managerStore.installedPacks[id]?.ver ??
nodePack.latest_version?.version ??
SelectedVersion.NIGHTLY
)
})
const handleEnable = async () => {
if (!nodePack.id) return
// Enable is done by using the install endpoint with a disabled pack
managerStore.installPack.call({
id: nodePack.id,
version: version.value,
selected_version: version.value,
repository: nodePack.repository ?? '',
channel: ManagerChannel.DEFAULT,
mode: 'default' as InstallPackParams['mode']
})
}
const handleDisable = async () => {
managerStore.disablePack({
id: nodePack.id,
version: version.value
})
}
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(handleToggle, TOGGLE_DEBOUNCE_MS)
</script>

View File

@@ -0,0 +1,61 @@
<template>
<PackActionButton
v-bind="$attrs"
:label="
nodePacks.length > 1 ? $t('manager.installSelected') : $t('g.install')
"
severity="secondary"
:loading-message="$t('g.installing')"
@action="installAllPacks"
/>
</template>
<script setup lang="ts">
import PackActionButton from '@/components/dialog/content/manager/button/PackActionButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import {
ManagerChannel,
ManagerDatabaseSource,
SelectedVersion
} from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
type NodePack = components['schemas']['Node']
const { nodePacks } = defineProps<{
nodePacks: NodePack[]
}>()
const managerStore = useComfyManagerStore()
const createPayload = (installItem: NodePack) => {
const isUnclaimedPack = installItem.publisher?.name === 'Unclaimed'
const versionToInstall = isUnclaimedPack
? SelectedVersion.NIGHTLY
: installItem.latest_version?.version ?? SelectedVersion.LATEST
return {
id: installItem.id,
repository: installItem.repository ?? '',
channel: ManagerChannel.DEV,
mode: ManagerDatabaseSource.CACHE,
selected_version: versionToInstall,
version: versionToInstall
}
}
const installPack = (item: NodePack) =>
managerStore.installPack.call(createPayload(item))
const installAllPacks = async () => {
if (!nodePacks?.length) return
const uninstalledPacks = nodePacks.filter(
(pack) => !managerStore.isPackInstalled(pack.id)
)
if (!uninstalledPacks.length) return
await Promise.all(uninstalledPacks.map(installPack))
managerStore.installPack.clear()
}
</script>

View File

@@ -0,0 +1,43 @@
<template>
<PackActionButton
v-bind="$attrs"
:label="
nodePacks.length > 1
? $t('manager.uninstallSelected')
: $t('manager.uninstall')
"
severity="danger"
:loading-message="$t('manager.uninstalling')"
@action="uninstallItems"
/>
</template>
<script setup lang="ts">
import PackActionButton from '@/components/dialog/content/manager/button/PackActionButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { ManagerPackInfo } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
type NodePack = components['schemas']['Node']
const { nodePacks } = defineProps<{
nodePacks: NodePack[]
}>()
const managerStore = useComfyManagerStore()
const createPayload = (uninstallItem: NodePack): ManagerPackInfo => {
return {
id: uninstallItem.id,
version: uninstallItem.latest_version?.version
}
}
const uninstallPack = (item: NodePack) =>
managerStore.uninstallPack(createPayload(item))
const uninstallItems = async () => {
if (!nodePacks?.length) return
await Promise.all(nodePacks.map(uninstallPack))
}
</script>

View File

@@ -1,10 +1,7 @@
<template>
<div class="flex flex-col h-full z-40 hidden-scrollbar w-80">
<div class="p-6 flex-1 overflow-hidden text-sm">
<PackCardHeader
:node-pack="nodePack"
:install-button-full-width="false"
/>
<InfoPanelHeader :node-packs="[nodePack]" />
<div class="mb-6">
<MetadataRow
v-for="item in infoItems"
@@ -21,11 +18,7 @@
/>
</MetadataRow>
<MetadataRow :label="t('manager.version')">
<PackVersionBadge
:node-pack="nodePack"
:version="selectedVersion"
@update:version="updateSelectedVersion"
/>
<PackVersionBadge :node-pack="nodePack" />
</MetadataRow>
</div>
<div class="mb-6 overflow-hidden">
@@ -36,17 +29,15 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import PackStatusMessage from '@/components/dialog/content/manager/PackStatusMessage.vue'
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
import InfoPanelHeader from '@/components/dialog/content/manager/infoPanel/InfoPanelHeader.vue'
import InfoTabs from '@/components/dialog/content/manager/infoPanel/InfoTabs.vue'
import MetadataRow from '@/components/dialog/content/manager/infoPanel/MetadataRow.vue'
import PackCardHeader from '@/components/dialog/content/manager/packCard/PackCardHeader.vue'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import { formatNumber } from '@/utils/formatUtil'
interface InfoItem {
key: string
@@ -58,31 +49,18 @@ const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const { t, d } = useI18n()
const packCardHeaderRef = ref(null)
const selectedVersion = ref<string>(
nodePack.latest_version?.version || SelectedVersion.NIGHTLY
)
const updateSelectedVersion = (version: string) => {
selectedVersion.value = version
if (packCardHeaderRef.value) {
packCardHeaderRef.value.updateVersion?.(version)
}
}
const { t, d, n } = useI18n()
const infoItems = computed<InfoItem[]>(() => [
{
key: 'publisher',
label: t('manager.createdBy'),
// TODO: handle all Comfy Registry publisher types dynamically (e.g., organizations, multiple authors)
value: nodePack.publisher?.name
value: nodePack.publisher?.name ?? nodePack.publisher?.id
},
{
key: 'downloads',
label: t('manager.downloads'),
value: nodePack.downloads ? formatNumber(nodePack.downloads) : undefined
value: nodePack.downloads ? n(nodePack.downloads) : undefined
},
{
key: 'lastUpdated',

View File

@@ -0,0 +1,52 @@
<template>
<div v-if="nodePacks?.length" class="flex flex-col items-center mb-6">
<slot name="thumbnail">
<PackIcon :node-pack="nodePacks[0]" width="24" height="24" />
</slot>
<h2
class="text-2xl font-bold text-center mt-4 mb-2"
style="word-break: break-all"
>
<slot name="title">
{{ nodePacks[0].name }}
</slot>
</h2>
<div class="mt-2 mb-4 w-full max-w-xs flex justify-center">
<slot name="install-button">
<PackUninstallButton
v-if="isAllInstalled"
v-bind="$attrs"
:node-packs="nodePacks"
/>
<PackInstallButton v-else v-bind="$attrs" :node-packs="nodePacks" />
</slot>
</div>
</div>
<div v-else class="flex flex-col items-center mb-6">
<NoResultsPlaceholder
:message="$t('manager.status.unknown')"
:title="$t('manager.tryAgainLater')"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import PackUninstallButton from '@/components/dialog/content/manager/button/PackUninstallButton.vue'
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { components } from '@/types/comfyRegistryTypes'
const { nodePacks } = defineProps<{
nodePacks: components['schemas']['Node'][]
}>()
const managerStore = useComfyManagerStore()
const isAllInstalled = computed(() =>
nodePacks.every((nodePack) => managerStore.isPackInstalled(nodePack.id))
)
</script>

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col h-full">
<div class="p-6 flex-1 overflow-auto">
<PackCardHeader>
<InfoPanelHeader :node-packs="nodePacks">
<template #thumbnail>
<PackIconStacked :node-packs="nodePacks" />
</template>
@@ -10,9 +10,9 @@
{{ $t('manager.packsSelected') }}
</template>
<template #install-button>
<PackInstallButton :full-width="true" :multi="true" />
<PackInstallButton :full-width="true" :node-packs="nodePacks" />
</template>
</PackCardHeader>
</InfoPanelHeader>
<div class="mb-6">
<MetadataRow :label="$t('g.status')">
<PackStatusMessage status-type="NodeVersionStatusActive" />
@@ -30,10 +30,10 @@
import { useAsyncState } from '@vueuse/core'
import { computed } from 'vue'
import PackInstallButton from '@/components/dialog/content/manager/PackInstallButton.vue'
import PackStatusMessage from '@/components/dialog/content/manager/PackStatusMessage.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import InfoPanelHeader from '@/components/dialog/content/manager/infoPanel/InfoPanelHeader.vue'
import MetadataRow from '@/components/dialog/content/manager/infoPanel/MetadataRow.vue'
import PackCardHeader from '@/components/dialog/content/manager/packCard/PackCardHeader.vue'
import PackIconStacked from '@/components/dialog/content/manager/packIcon/PackIconStacked.vue'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import { components } from '@/types/comfyRegistryTypes'
@@ -59,6 +59,7 @@ const { state: allNodeDefs } = useAsyncState(
immediate: true
}
)
const totalNodesCount = computed(() =>
allNodeDefs.value.reduce(
(total, nodeDefs) => total + (nodeDefs?.length || 0),

View File

@@ -17,7 +17,7 @@
<i
class="pi pi-box text-muted text-2xl ml-1 mr-5"
style="opacity: 0.5"
></i>
/>
<span class="text-lg relative top-[.25rem]">{{
$t('manager.nodePack')
}}</span>
@@ -27,10 +27,15 @@
v-if="nodePack.downloads"
class="flex items-center text-sm text-muted tracking-tighter"
>
<i class="pi pi-download mr-2"></i>
{{ formatNumber(nodePack.downloads) }}
<i class="pi pi-download mr-2" />
{{ $n(nodePack.downloads) }}
</div>
<PackInstallButton />
<template v-if="isPackInstalled">
<PackEnableToggle :node-pack="nodePack" />
</template>
<template v-else>
<PackInstallButton :node-packs="[nodePack]" />
</template>
</div>
</div>
</template>
@@ -66,17 +71,13 @@
<span v-if="nodePack.publisher?.name">
{{ nodePack.publisher.name }}
</span>
<PackVersionBadge
v-if="isPackInstalled"
:node-pack="nodePack"
v-model:version="selectedVersion"
/>
<PackVersionBadge v-if="isPackInstalled" :node-pack="nodePack" />
<span v-else-if="nodePack.latest_version">
{{ nodePack.latest_version.version }}
</span>
</div>
<div
v-if="nodePack.latest_version"
v-if="nodePack.latest_version?.createdAt"
class="flex items-center gap-2 truncate"
>
{{ $t('g.updated') }}
@@ -93,24 +94,24 @@
<script setup lang="ts">
import Card from 'primevue/card'
import { computed, ref } from 'vue'
import { computed } from 'vue'
import ContentDivider from '@/components/common/ContentDivider.vue'
import PackInstallButton from '@/components/dialog/content/manager/PackInstallButton.vue'
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
import PackEnableToggle from '@/components/dialog/content/manager/button/PackEnableToggle.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
import { formatNumber } from '@/utils/formatUtil'
const { nodePack, isSelected = false } = defineProps<{
nodePack: components['schemas']['Node']
isSelected?: boolean
}>()
const selectedVersion = ref<string | undefined>(undefined)
const isPackInstalled = computed(
() =>
// TODO: after manager store added
// managerStore.isPackInstalled(nodePack?.id)
true
const managerStore = useComfyManagerStore()
const isPackInstalled = computed(() =>
managerStore.isPackInstalled(nodePack?.id)
)
</script>

View File

@@ -1,33 +0,0 @@
<template>
<div class="flex flex-col items-center mb-6">
<slot name="thumbnail">
<PackIcon :node-pack="nodePack" width="24" height="24" />
</slot>
<h2
class="text-2xl font-bold text-center mt-4 mb-2"
style="word-break: break-all"
>
<slot name="title">{{ nodePack?.name }}</slot>
</h2>
<div class="mt-2 mb-4 w-full max-w-xs flex justify-center">
<slot name="install-button">
<PackInstallButton
:full-width="installButtonFullWidth"
:multi="multi"
/>
</slot>
</div>
</div>
</template>
<script setup lang="ts">
import PackInstallButton from '@/components/dialog/content/manager/PackInstallButton.vue'
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { components } from '@/types/comfyRegistryTypes'
defineProps<{
nodePack?: components['schemas']['Node']
multi?: boolean
installButtonFullWidth?: boolean
}>()
</script>

View File

@@ -46,6 +46,7 @@
"back": "Back",
"next": "Next",
"install": "Install",
"installing": "Installing",
"overwrite": "Overwrite",
"customize": "Customize",
"experimental": "BETA",
@@ -98,6 +99,10 @@
"selectVersion": "Select Version",
"downloads": "Downloads",
"repository": "Repository",
"uninstall": "Uninstall",
"uninstalling": "Uninstalling",
"update": "Update",
"uninstallSelected": "Uninstall Selected",
"license": "License",
"nightlyVersion": "Nightly",
"latestVersion": "Latest",

View File

@@ -158,6 +158,7 @@
"import": "Importer",
"insert": "Insérer",
"install": "Installer",
"installing": "Installation",
"keybinding": "Raccourci clavier",
"loadAllFolders": "Charger tous les dossiers",
"loadWorkflow": "Charger le flux de travail",
@@ -411,6 +412,10 @@
"totalNodes": "Total de Nœuds",
"tryAgainLater": "Veuillez réessayer plus tard.",
"tryDifferentSearch": "Veuillez essayer une autre requête de recherche.",
"uninstall": "Désinstaller",
"uninstallSelected": "Désinstaller sélectionné",
"uninstalling": "Désinstallation",
"update": "Mettre à jour",
"version": "Version"
},
"menu": {

View File

@@ -158,6 +158,7 @@
"import": "インポート",
"insert": "挿入",
"install": "インストール",
"installing": "インストール中",
"keybinding": "キーバインディング",
"loadAllFolders": "すべてのフォルダーを読み込む",
"loadWorkflow": "ワークフローを読み込む",
@@ -411,6 +412,10 @@
"totalNodes": "合計ノード数",
"tryAgainLater": "後ほど再試行してください。",
"tryDifferentSearch": "別の検索クエリを試してみてください。",
"uninstall": "アンインストール",
"uninstallSelected": "選択したものをアンインストール",
"uninstalling": "アンインストール中",
"update": "更新",
"version": "バージョン"
},
"menu": {

View File

@@ -158,6 +158,7 @@
"import": "가져오기",
"insert": "삽입",
"install": "설치",
"installing": "설치 중",
"keybinding": "키 바인딩",
"loadAllFolders": "모든 폴더 로드",
"loadWorkflow": "워크플로 로드",
@@ -411,6 +412,10 @@
"totalNodes": "총 노드",
"tryAgainLater": "나중에 다시 시도해 주세요.",
"tryDifferentSearch": "다른 검색어를 시도해 주세요.",
"uninstall": "제거",
"uninstallSelected": "선택 항목 제거",
"uninstalling": "제거 중",
"update": "업데이트",
"version": "버전"
},
"menu": {

View File

@@ -158,6 +158,7 @@
"import": "Импорт",
"insert": "Вставить",
"install": "Установить",
"installing": "Установка",
"keybinding": "Привязка клавиш",
"loadAllFolders": "Загрузить все папки",
"loadWorkflow": "Загрузить рабочий процесс",
@@ -411,6 +412,10 @@
"totalNodes": "Всего Узлов",
"tryAgainLater": "Пожалуйста, попробуйте позже.",
"tryDifferentSearch": "Пожалуйста, попробуйте изменить запрос.",
"uninstall": "Удалить",
"uninstallSelected": "Удалить выбранное",
"uninstalling": "Удаление",
"update": "Обновить",
"version": "Версия"
},
"menu": {

View File

@@ -158,6 +158,7 @@
"import": "导入",
"insert": "插入",
"install": "安装",
"installing": "正在安装",
"keybinding": "按键绑定",
"loadAllFolders": "加载所有文件夹",
"loadWorkflow": "加载工作流",
@@ -411,6 +412,10 @@
"totalNodes": "节点总数",
"tryAgainLater": "请稍后再试。",
"tryDifferentSearch": "请尝试不同的搜索查询。",
"uninstall": "卸载",
"uninstallSelected": "卸载所选",
"uninstalling": "正在卸载",
"update": "更新",
"version": "版本"
},
"menu": {

View File

@@ -1,6 +1,7 @@
import { whenever } from '@vueuse/core'
import { partition } from 'lodash'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ref, watchEffect } from 'vue'
import { useCachedRequest } from '@/composables/useCachedRequest'
import { useManagerQueue } from '@/composables/useManagerQueue'
@@ -9,6 +10,7 @@ import {
InstallPackParams,
InstalledPacksResponse,
ManagerPackInfo,
ManagerPackInstalled,
UpdateAllPacksParams
} from '@/types/comfyManagerTypes'
@@ -18,20 +20,61 @@ import {
export const useComfyManagerStore = defineStore('comfyManager', () => {
const managerService = useComfyManagerService()
const installedPacks = ref<InstalledPacksResponse>({})
const enabledPacks = ref<Set<string>>(new Set())
const disabledPacks = ref<Set<string>>(new Set())
const isStale = ref(true)
const { statusMessage, allTasksDone, enqueueTask } = useManagerQueue()
const refreshInstalledList = async () => {
const packs = await managerService.listInstalledPacks()
if (packs) installedPacks.value = packs
isStale.value = false
}
const setStale = () => {
isStale.value = true
}
const isPackInstalled = (packName: string | undefined): boolean => {
if (!packName) return false
return !!installedPacks.value[packName] || disabledPacks.value.has(packName)
}
const isPackEnabled = (packName: string | undefined): boolean =>
!!packName && enabledPacks.value.has(packName)
/**
* A pack is disabled if there is a disabled entry and no corresponding enabled entry
* @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 isPackDisabled = (pack: ManagerPackInstalled) =>
pack.enabled === false && pack.cnr_id && !installedPacks.value[pack.cnr_id]
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>())
watchEffect(() => {
const packs = Object.values(installedPacks.value)
const [disabled, enabled] = partition(packs, isPackDisabled)
enabledPacks.value = packsToIdSet(enabled)
disabledPacks.value = packsToIdSet(disabled)
})
const refreshInstalledList = async () => {
isStale.value = false
const packs = await managerService.listInstalledPacks()
if (packs) installedPacks.value = packs
}
whenever(isStale, refreshInstalledList, { immediate: true })
const installPack = useCachedRequest<InstallPackParams, void>(
@@ -84,16 +127,6 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
})
}
const isPackInstalled = (packName: string | undefined): boolean => {
if (!packName) return false
return !!installedPacks.value[packName]
}
const isPackEnabled = (packName: string | undefined): boolean => {
if (!packName) return false
return !!installedPacks.value[packName]?.enabled
}
return {
// Manager state
isLoading: managerService.isLoading,

View File

@@ -5,11 +5,6 @@ type RegistryPack = components['schemas']['Node']
type WorkflowNodeProperties = ComfyWorkflowJSON['nodes'][0]['properties']
export type PackField = keyof RegistryPack | null
export type PackWithSelectedVersion = {
nodePack: RegistryPack
selectedVersion?: InstallPackParams['selected_version']
}
export interface TabItem {
id: string
label: string
@@ -175,10 +170,6 @@ export interface InstallPackParams extends ManagerPackInfo {
* Semantic version, Git commit hash, `latest`, or `nightly`.
*/
selected_version: WorkflowNodeProperties['ver'] | SelectedVersion
/**
* If set to `imported`, returns only the packs that were imported at app startup.
*/
mode?: 'imported' | 'default'
/**
* The GitHub link to the repository of the pack to install.
* Required if `selected_version` is `nightly`.
@@ -189,6 +180,7 @@ export interface InstallPackParams extends ManagerPackInfo {
* Used in coordination with pip package whitelist and version lock features.
*/
pip?: string[]
mode: ManagerDatabaseSource
channel: ManagerChannel
skip_post_install?: boolean
}

View File

@@ -336,14 +336,6 @@ export const generateUUID = (): string => {
})
}
/**
* Formats a number to a locale-specific string
* @param num The number to format
* @returns The formatted number or 'N/A' if the number is undefined
*/
export const formatNumber = (num?: number): string =>
num?.toLocaleString() ?? 'N/A'
/**
* Checks if a URL is a Civitai model URL
* @example