Manager Conflict Nofitication (#4443)

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jin Yi
2025-07-25 08:35:46 +09:00
parent 4b6739c6fb
commit af0dde0ac8
48 changed files with 4684 additions and 374 deletions

View File

@@ -15,8 +15,8 @@ import ProgressSpinner from 'primevue/progressspinner'
import { computed, onMounted } from 'vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { useConflictDetection } from '@/composables/useConflictDetection'
import config from '@/config'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI, isElectron } from './utils/envUtil'

View File

@@ -27,7 +27,7 @@
/>
<template v-if="item.footerComponent" #footer>
<component :is="item.footerComponent" />
<component :is="item.footerComponent" v-bind="item.footerProps" />
</template>
</Dialog>
</template>

View File

@@ -26,6 +26,27 @@
}"
>
<div class="px-6 flex flex-col h-full">
<!-- Conflict Warning Banner -->
<div
v-if="shouldShowManagerBanner"
class="bg-yellow-600 bg-opacity-20 border border-yellow-400 rounded-lg p-4 mt-3 mb-4 flex items-center gap-6"
>
<i class="pi pi-exclamation-triangle text-yellow-600 text-lg"></i>
<div class="flex flex-col gap-2">
<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>
</div>
<RegistrySearchBar
v-model:searchQuery="searchQuery"
v-model:searchMode="searchMode"
@@ -70,7 +91,9 @@
:is-selected="
selectedNodePacks.some((pack) => pack.id === item.id)
"
@click.stop="(event) => selectNodePack(item, event)"
@click.stop="
(event: MouseEvent) => selectNodePack(item, event)
"
/>
</template>
</VirtualGrid>
@@ -102,7 +125,8 @@ import {
onMounted,
onUnmounted,
ref,
watch
watch,
watchEffect
} from 'vue'
import { useI18n } from 'vue-i18n'
@@ -120,7 +144,9 @@ import { useManagerStatePersistence } from '@/composables/manager/useManagerStat
import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
import { useConflictBannerState } from '@/composables/useConflictBannerState'
import { useRegistrySearch } from '@/composables/useRegistrySearch'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { TabItem } from '@/types/comfyManagerTypes'
@@ -134,6 +160,8 @@ const { initialTab } = defineProps<{
const { t } = useI18n()
const comfyManagerStore = useComfyManagerStore()
const { getPackById } = useComfyRegistryStore()
const registryService = useComfyRegistryService()
const conflictBannerState = useConflictBannerState()
const persistedState = useManagerStatePersistence()
const initialState = persistedState.loadStoredState()
@@ -150,6 +178,9 @@ const {
toggle: toggleSideNav
} = useResponsiveCollapse()
// Use conflict banner state from composable
const { shouldShowManagerBanner, markConflictsAsSeen } = conflictBannerState
const tabs = ref<TabItem[]>([
{ id: ManagerTab.All, label: t('g.all'), icon: 'pi-list' },
{ id: ManagerTab.Installed, label: t('g.installed'), icon: 'pi-box' },
@@ -313,6 +344,13 @@ watch([isAllTab, searchResults], () => {
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:
@@ -441,11 +479,57 @@ whenever(selectedNodePack, async () => {
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)
let data = null
// For installed nodes only, fetch version-specific information
if (comfyManagerStore.isPackInstalled(pack.id)) {
const installedPack = comfyManagerStore.getInstalledPackByCnrId(pack.id)
if (installedPack?.ver) {
// Fetch information for the installed version
data = await registryService.getPackByVersion(pack.id, installedPack.ver)
// Race condition check: ensure selected pack hasn't changed during async operation
if (selectedNodePack.value?.id !== pack.id) {
return
}
}
}
// For uninstalled nodes or if version-specific data fetch failed, use default API
if (!data) {
data = await getPackById.call(pack.id)
}
// Race condition check: ensure selected pack hasn't changed during async operations
if (selectedNodePack.value?.id !== pack.id) {
return
}
// If selected node hasn't changed since request, merge registry & Algolia data
if (data?.id === pack.id) {
const isNodeData = data && 'id' in data && data.id === pack.id
const isVersionData = data && 'node_id' in data && data.node_id === pack.id
if (isNodeData || isVersionData) {
lastFetchedPackId.value = pack.id
const mergedPack = merge({}, pack, data)
// Merge API data first, then pack data (API data takes priority)
const mergedPack = merge({}, data, pack)
// Ensure compatibility fields from API data take priority
if (data?.supported_os !== undefined) {
mergedPack.supported_os = data.supported_os
}
if (data?.supported_accelerators !== undefined) {
mergedPack.supported_accelerators = data.supported_accelerators
}
if (data?.supported_comfyui_version !== undefined) {
mergedPack.supported_comfyui_version = data.supported_comfyui_version
}
if (data?.supported_comfyui_frontend_version !== undefined) {
mergedPack.supported_comfyui_frontend_version =
data.supported_comfyui_frontend_version
}
// Update the pack in current selection without changing selection state
const packIndex = selectedNodePacks.value.findIndex(
(p) => p.id === mergedPack.id
@@ -473,6 +557,14 @@ watch([searchQuery, selectedTab], () => {
}
})
// Automatically mark conflicts as seen when banner is displayed
// This ensures red dots disappear and banner is dismissed once user sees it
watchEffect(() => {
if (shouldShowManagerBanner.value) {
markConflictsAsSeen()
}
})
onBeforeUnmount(() => {
persistedState.persistState({
selectedTabId: selectedTab.value?.id,

View File

@@ -0,0 +1,163 @@
<template>
<div class="w-[552px] max-h-[246px] flex flex-col">
<ContentDivider :width="1" />
<div class="px-4 py-6 w-full h-full flex flex-col gap-2">
<!-- Description -->
<!-- <div>
<p class="text-sm leading-4 text-gray-100 m-0 mb-4">
{{ $t('manager.conflicts.description') }}
<br /><br />
{{ $t('manager.conflicts.info') }}
</p>
</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 Button from 'primevue/button'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import { getConflictMessage } from '@/utils/conflictMessageUtil'
interface Props {
conflicts?: ConflictDetectionResult[]
conflictedPackages?: ConflictDetectionResult[]
}
const props = withDefaults(defineProps<Props>(), {
conflicts: () => [],
conflictedPackages: () => []
})
const { t } = useI18n()
const conflictsExpanded = ref<boolean>(false)
const extensionsExpanded = ref<boolean>(false)
// Use conflictedPackages if provided, otherwise fallback to conflicts
const conflictData = computed(() =>
props.conflictedPackages.length > 0
? props.conflictedPackages
: props.conflicts
)
const allConflictDetails = computed(() =>
conflictData.value.flatMap((result) => result.conflicts)
)
const toggleConflictsPanel = () => {
conflictsExpanded.value = !conflictsExpanded.value
extensionsExpanded.value = false
}
const toggleExtensionsPanel = () => {
extensionsExpanded.value = !extensionsExpanded.value
conflictsExpanded.value = false
}
</script>
<style scoped>
.conflict-list-item:hover {
background-color: rgba(0, 122, 255, 0.2);
}
</style>

View File

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

View File

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

View File

@@ -10,6 +10,14 @@ import enMessages from '@/locales/en/main.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',

View File

@@ -1,8 +1,8 @@
<template>
<div>
<div
class="inline-flex items-center gap-1 rounded-2xl text-xs cursor-pointer px-2 py-1"
:class="{ 'bg-gray-100 dark-theme:bg-neutral-700': fill }"
class="inline-flex items-center gap-1 rounded-2xl text-xs cursor-pointer py-1"
:class="{ 'bg-gray-100 dark-theme:bg-neutral-700 px-1.5': fill }"
aria-haspopup="true"
role="button"
tabindex="0"
@@ -12,8 +12,7 @@
>
<i
v-if="isUpdateAvailable"
class="pi pi-arrow-circle-up text-blue-600"
style="font-size: 8px"
class="pi pi-arrow-circle-up text-blue-600 text-xs"
/>
<span>{{ installedVersion }}</span>
<i class="pi pi-chevron-right" style="font-size: 8px" />

View File

@@ -3,10 +3,13 @@ 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'
// SelectedVersion is now using direct strings instead of enum
@@ -15,7 +18,17 @@ import PackVersionSelectorPopover from './PackVersionSelectorPopover.vue'
// Default mock versions for reference
const defaultMockVersions = [
{ version: '1.0.0', createdAt: '2023-01-01' },
{
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' }
]
@@ -23,13 +36,24 @@ const defaultMockVersions = [
const mockNodePack = {
id: 'test-pack',
name: 'Test Pack',
latest_version: { version: '1.0.0' },
repository: 'https://github.com/user/repo'
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 mockCheckVersionCompatibility = vi.fn()
// Mock the registry service
vi.mock('@/services/comfyRegistryService', () => ({
@@ -50,6 +74,13 @@ vi.mock('@/stores/comfyManagerStore', () => ({
}))
}))
// Mock the conflict detection composable
vi.mock('@/composables/useConflictDetection', () => ({
useConflictDetection: vi.fn(() => ({
checkVersionCompatibility: mockCheckVersionCompatibility
}))
}))
const waitForPromises = async () => {
await new Promise((resolve) => setTimeout(resolve, 16))
await nextTick()
@@ -60,6 +91,9 @@ describe('PackVersionSelectorPopover', () => {
vi.clearAllMocks()
mockGetPackVersions.mockReset()
mockInstallPack.mockReset().mockResolvedValue(undefined)
mockCheckVersionCompatibility
.mockReset()
.mockReturnValue({ hasConflict: false, conflicts: [] })
})
const mountComponent = ({
@@ -79,7 +113,12 @@ describe('PackVersionSelectorPopover', () => {
global: {
plugins: [PrimeVue, createPinia(), i18n],
components: {
Listbox
Listbox,
VerifiedIcon,
Select
},
directives: {
tooltip: Tooltip
}
}
})
@@ -121,14 +160,15 @@ describe('PackVersionSelectorPopover', () => {
const options = listbox.props('options')!
// Check that we have both special options and version options
expect(options.length).toBe(defaultMockVersions.length + 2) // 2 special options + 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
expect(options.some((o) => o.value === '1.0.0')).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)
})
@@ -329,4 +369,357 @@ describe('PackVersionSelectorPopover', () => {
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
mockCheckVersionCompatibility.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(mockCheckVersionCompatibility).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
mockCheckVersionCompatibility.mockReturnValue({
hasConflict: false,
conflicts: []
})
const wrapper = mountComponent()
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckVersionCompatibility).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()
// Trigger compatibility check by accessing getVersionCompatibility
const vm = wrapper.vm as any
vm.getVersionCompatibility('1.0.0')
// Verify that checkVersionCompatibility was called with correct data
// Since 1.0.0 is the latest version, it should use latest_version data
expect(mockCheckVersionCompatibility).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
})
})
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
mockCheckVersionCompatibility.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(mockCheckVersionCompatibility).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
// Test latest version
vm.getVersionCompatibility('latest')
expect(mockCheckVersionCompatibility).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
})
// Test nightly version
vm.getVersionCompatibility('nightly')
expect(mockCheckVersionCompatibility).toHaveBeenCalledWith({
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: undefined,
is_banned: false,
has_registry_data: false
})
})
it('shows python version conflict warnings', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return python version conflicts
mockCheckVersionCompatibility.mockImplementation((versionData) => {
if (versionData.supported_python_version) {
return {
hasConflict: true,
conflicts: [
{
type: 'python_version',
current_value: '3.8.0',
required_value: versionData.supported_python_version
}
]
}
}
return { hasConflict: false, conflicts: [] }
})
const nodePackWithPythonRequirement = {
...mockNodePack,
supported_python_version: '>=3.9.0'
}
const wrapper = mountComponent({
props: { nodePack: nodePackWithPythonRequirement }
})
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckVersionCompatibility).toHaveBeenCalled()
// The warning icon should be shown for python version incompatible packages
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
expect(warningIcons.length).toBeGreaterThan(0)
})
it('shows banned package warnings', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return banned conflicts
mockCheckVersionCompatibility.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(mockCheckVersionCompatibility).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
mockCheckVersionCompatibility.mockImplementation((versionData) => {
if (versionData.has_registry_data === false) {
return {
hasConflict: true,
conflicts: [
{
type: 'security_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(mockCheckVersionCompatibility).toHaveBeenCalled()
// The warning icon should be shown for security pending packages
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
expect(warningIcons.length).toBeGreaterThan(0)
})
})
})

View File

@@ -1,6 +1,6 @@
<template>
<div class="w-64 mt-2">
<span class="pl-3 text-muted text-md font-semibold opacity-70">
<span class="pl-3 text-muted text-md font-semibold text-neutral-500">
{{ $t('manager.selectVersion') }}
</span>
<div
@@ -25,11 +25,32 @@
option-value="value"
:options="versionOptions"
:highlight-on-select="false"
class="my-3 w-full max-h-[50vh] border-none shadow-none"
class="my-3 w-full max-h-[50vh] border-none shadow-none rounded-md"
>
<template #option="slotProps">
<div class="flex justify-between items-center w-full p-1">
<span>{{ slotProps.option.label }}</span>
<div class="flex items-center gap-2">
<!-- Show no icon for nightly versions since compatibility is uncertain -->
<template v-if="slotProps.option.value === 'nightly'">
<div class="w-4"></div>
<!-- Empty space to maintain alignment -->
</template>
<template v-else>
<i
v-if="
getVersionCompatibility(slotProps.option.value).hasConflict
"
v-tooltip="{
value: getVersionCompatibility(slotProps.option.value)
.conflictMessage,
showDelay: 300
}"
class="pi pi-exclamation-triangle text-yellow-500"
/>
<VerifiedIcon v-else :size="16" />
</template>
<span>{{ slotProps.option.label }}</span>
</div>
<i
v-if="selectedVersion === slotProps.option.value"
class="pi pi-check text-highlight"
@@ -67,10 +88,13 @@ 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 { useConflictDetection } from '@/composables/useConflictDetection'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
import { getJoinedConflictMessages } from '@/utils/conflictMessageUtil'
import { isSemVer } from '@/utils/formatUtil'
const { nodePack } = defineProps<{
@@ -85,6 +109,7 @@ const emit = defineEmits<{
const { t } = useI18n()
const registryService = useComfyRegistryService()
const managerStore = useComfyManagerStore()
const { checkVersionCompatibility } = useConflictDetection()
const isQueueing = ref(false)
@@ -123,6 +148,9 @@ const versionOptions = ref<
}[]
>([])
// Store fetched versions with their full data
const fetchedVersions = ref<components['schemas']['NodeVersion'][]>([])
const isLoadingVersions = ref(false)
const onNodePackChange = async () => {
@@ -130,18 +158,27 @@ const onNodePackChange = async () => {
// Fetch versions from the registry
const versions = await fetchVersions()
fetchedVersions.value = versions
// Get latest version number to exclude from the list
const latestVersionNumber = nodePack.latest_version?.version
const availableVersionOptions = versions
.map((version) => ({
value: version.version ?? '',
label: version.version ?? ''
}))
.filter((option) => option.value)
.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: 'latest' as ManagerComponents['schemas']['SelectedVersion'],
label: t('manager.latestVersion')
label: latestLabel
}
]
@@ -149,7 +186,7 @@ const onNodePackChange = async () => {
if (nodePack.repository?.length) {
defaultVersions.push({
value: 'nightly' as ManagerComponents['schemas']['SelectedVersion'],
label: t('manager.nightlyVersion')
label: t('manager.nightlyVersion') // Keep as just "nightly" - no version number
})
}
@@ -173,16 +210,141 @@ const handleSubmit = async () => {
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,
version: selectedVersion.value,
version: actualVersion,
repository: nodePack.repository ?? '',
channel: 'default' as ManagerComponents['schemas']['ManagerChannel'],
mode: 'cache' as ManagerComponents['schemas']['ManagerDatabaseSource'],
selected_version: selectedVersion.value
selected_version: actualVersion
})
isQueueing.value = false
emit('submit')
}
// Function to get version data (either from nodePack or fetchedVersions)
const getVersionData = (version: string) => {
// Use latest_version data for both "latest" and the actual latest version number
const latestVersionNumber = nodePack.latest_version?.version
const useLatestVersionData =
version === 'latest' || version === latestVersionNumber
if (useLatestVersionData) {
// For "latest" and the actual latest version number, use consistent data from latest_version
const latestVersionData = nodePack.latest_version
return {
supported_os: latestVersionData?.supported_os ?? nodePack.supported_os,
supported_accelerators:
latestVersionData?.supported_accelerators ??
nodePack.supported_accelerators,
supported_comfyui_version:
latestVersionData?.supported_comfyui_version ??
nodePack.supported_comfyui_version,
supported_comfyui_frontend_version:
latestVersionData?.supported_comfyui_frontend_version ??
nodePack.supported_comfyui_frontend_version,
supported_python_version:
(latestVersionData && 'supported_python_version' in latestVersionData
? latestVersionData.supported_python_version as string | undefined
: undefined) ??
('supported_python_version' in nodePack
? nodePack.supported_python_version as string | undefined
: undefined),
is_banned:
(latestVersionData && 'is_banned' in latestVersionData
? latestVersionData.is_banned as boolean | undefined
: undefined) ?? ('is_banned' in nodePack ? nodePack.is_banned as boolean | undefined : false),
has_registry_data:
(latestVersionData && 'has_registry_data' in latestVersionData
? latestVersionData.has_registry_data as boolean | undefined
: undefined) ??
('has_registry_data' in nodePack ? nodePack.has_registry_data as boolean | undefined : false)
}
}
if (version === 'nightly') {
// For nightly, we can't determine exact compatibility since it's dynamic Git HEAD
// But we can assume it's generally compatible (nightly = latest development)
// Use nodePack data as fallback, but nightly is typically more permissive
return {
supported_os: nodePack.supported_os || [], // If no OS restrictions, assume all supported
supported_accelerators: nodePack.supported_accelerators || [], // If no accelerator restrictions, assume all supported
supported_comfyui_version: nodePack.supported_comfyui_version, // Use latest known requirement
supported_comfyui_frontend_version:
nodePack.supported_comfyui_frontend_version, // Use latest known requirement
supported_python_version:
'supported_python_version' in nodePack
? nodePack.supported_python_version as string | undefined
: undefined,
is_banned: false, // Nightly versions from repositories are typically not banned
has_registry_data: false // Nightly doesn't come from registry
}
}
// For specific versions, find in fetched versions
const versionData = fetchedVersions.value.find((v) => v.version === version)
if (versionData) {
return {
supported_os: versionData.supported_os,
supported_accelerators: versionData.supported_accelerators,
supported_comfyui_version: versionData.supported_comfyui_version,
supported_comfyui_frontend_version:
versionData.supported_comfyui_frontend_version,
supported_python_version:
'supported_python_version' in versionData
? versionData.supported_python_version as string | undefined
: undefined,
is_banned: 'is_banned' in versionData ? versionData.is_banned as boolean | undefined : false,
has_registry_data:
'has_registry_data' in versionData
? versionData.has_registry_data as boolean | undefined
: false
}
}
// Fallback to nodePack data
return {
supported_os: nodePack.supported_os,
supported_accelerators: nodePack.supported_accelerators,
supported_comfyui_version: nodePack.supported_comfyui_version,
supported_comfyui_frontend_version:
nodePack.supported_comfyui_frontend_version,
supported_python_version:
'supported_python_version' in nodePack
? nodePack.supported_python_version as string | undefined
: undefined,
is_banned: 'is_banned' in nodePack ? nodePack.is_banned as boolean | undefined : false,
has_registry_data:
'has_registry_data' in nodePack ? nodePack.has_registry_data as boolean | undefined : false
}
}
// Function to check version compatibility using centralized logic
const checkVersionCompatibilityLocal = (
versionData: ReturnType<typeof getVersionData>
) => {
return checkVersionCompatibility(versionData)
}
// Main function to get version compatibility info
const getVersionCompatibility = (version: string) => {
const versionData = getVersionData(version)
const compatibility = checkVersionCompatibilityLocal(versionData)
const conflictMessage = compatibility.hasConflict
? getJoinedConflictMessages(compatibility.conflicts, t)
: ''
return {
hasConflict: compatibility.hasConflict,
conflictMessage
}
}
</script>

View File

@@ -12,7 +12,11 @@
v-bind="$attrs"
@click="onClick"
>
<span class="py-2 px-3 whitespace-nowrap">
<span class="py-2 px-3 whitespace-nowrap text-xs flex items-center gap-2">
<i
v-if="hasWarning && !loading"
class="pi pi-exclamation-triangle text-yellow-500"
></i>
<template v-if="loading">
{{ loadingMessage ?? $t('g.loading') }}
</template>
@@ -31,13 +35,15 @@ const {
loading = false,
loadingMessage,
fullWidth = false,
variant = 'default'
variant = 'default',
hasWarning = false
} = defineProps<{
label: string
loading?: boolean
loadingMessage?: string
fullWidth?: boolean
variant?: 'default' | 'black'
hasWarning?: boolean
}>()
const emit = defineEmits<{

View File

@@ -11,9 +11,22 @@ import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import PackEnableToggle from './PackEnableToggle.vue'
// Mock debounce to execute immediately
vi.mock('lodash', () => ({
debounce: <T extends (...args: any[]) => any>(fn: T) => fn
// Mock lodash functions used throughout the app
vi.mock('lodash', async (importOriginal) => {
const actual = await importOriginal<typeof import('lodash')>()
return {
...actual,
debounce: <T extends (...args: any[]) => any>(fn: T) => fn,
memoize: <T extends (...args: any[]) => any>(fn: T) => fn
}
})
// Mock config to prevent __COMFYUI_FRONTEND_VERSION__ error
vi.mock('@/config', () => ({
default: {
app_title: 'ComfyUI',
app_version: '1.0.0'
}
}))
const mockNodePack = {

View File

@@ -1,10 +1,21 @@
<template>
<div class="flex items-center">
<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"
>
<i class="pi pi-exclamation-triangle text-yellow-500 text-xl"></i>
</div>
<ToggleSwitch
:model-value="isEnabled"
:disabled="isLoading"
aria-label="Enable or disable pack"
@update:model-value="onToggle"
@update:model-value="handleToggleClick"
/>
</div>
</template>
@@ -13,18 +24,28 @@
import { debounce } from 'lodash'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
import { useDialogService } from '@/services/dialogService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import type { components } from '@/types/comfyRegistryTypes'
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
const TOGGLE_DEBOUNCE_MS = 256
const { nodePack } = defineProps<{
const { nodePack, hasConflict } = defineProps<{
nodePack: components['schemas']['Node']
hasConflict?: boolean
}>()
const { t } = useI18n()
const { isPackEnabled, enablePack, disablePack } = useComfyManagerStore()
const conflictStore = useConflictDetectionStore()
const { showNodeConflictDialog } = useDialogService()
const { acknowledgeConflict, isConflictAcknowledged } =
useConflictAcknowledgment()
const isLoading = ref(false)
@@ -61,9 +82,41 @@ const handleDisable = () => {
})
}
const handleToggle = async (enable: boolean) => {
const handleToggle = async (enable: boolean, skipConflictCheck = false) => {
if (isLoading.value) return
// Check for conflicts when enabling
if (enable && hasConflict && !skipConflictCheck) {
const conflicts = conflictStore.getConflictsForPackage(nodePack.id || '')
if (conflicts) {
// Check if conflicts have been acknowledged
const hasUnacknowledgedConflicts = conflicts.conflicts.some(
(conflict) => !isConflictAcknowledged(nodePack.id || '', conflict.type)
)
if (hasUnacknowledgedConflicts) {
showNodeConflictDialog({
conflictedPackages: [conflicts],
buttonText: t('manager.conflicts.enableAnyway'),
onButtonClick: async () => {
// User chose "Enable Anyway" - acknowledge all conflicts and proceed
for (const conflict of conflicts.conflicts) {
acknowledgeConflict(nodePack.id || '', conflict.type, '0.1.0')
}
// Proceed with enabling using debounced function
onToggle(enable)
}
})
return
}
}
}
// No conflicts or conflicts acknowledged - proceed with toggle
await performToggle(enable)
}
const performToggle = async (enable: boolean) => {
isLoading.value = true
if (enable) {
await handleEnable()
@@ -73,11 +126,39 @@ const handleToggle = async (enable: boolean) => {
isLoading.value = false
}
// Handle initial toggle click - check for conflicts first
const handleToggleClick = (enable: boolean) => {
void handleToggle(enable)
}
const onToggle = debounce(
(enable: boolean) => {
void handleToggle(enable)
void performToggle(enable) // Direct call to avoid circular reference
},
TOGGLE_DEBOUNCE_MS,
{ trailing: true }
)
// Show conflict modal when warning icon is clicked
const showConflictModal = () => {
const conflicts = conflictStore.getConflictsForPackage(nodePack.id || '')
if (conflicts) {
showNodeConflictDialog({
conflictedPackages: [conflicts],
buttonText: isEnabled.value
? t('manager.conflicts.understood')
: t('manager.conflicts.enableAnyway'),
onButtonClick: async () => {
// User chose button action - acknowledge all conflicts
for (const conflict of conflicts.conflicts) {
acknowledgeConflict(nodePack.id || '', conflict.type, '0.1.0')
}
// Only enable if currently disabled
if (!isEnabled.value) {
onToggle(true)
}
}
})
}
}
</script>

View File

@@ -9,6 +9,7 @@
:variant="variant"
:loading="isInstalling"
:loading-message="$t('g.installing')"
:has-warning="hasConflict"
@action="installAllPacks"
@click="onClick"
/>
@@ -16,29 +17,41 @@
<script setup lang="ts">
import { inject, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import PackActionButton from '@/components/dialog/content/manager/button/PackActionButton.vue'
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useDialogService } from '@/services/dialogService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
type NodePack = components['schemas']['Node']
const { nodePacks, variant, label } = defineProps<{
nodePacks: NodePack[]
variant?: 'default' | 'black'
label?: string
}>()
const { nodePacks, variant, label, hasConflict, skipConflictCheck } =
defineProps<{
nodePacks: NodePack[]
variant?: 'default' | 'black'
label?: string
hasConflict?: boolean
skipConflictCheck?: boolean
}>()
const { t } = useI18n()
const isInstalling = inject(IsInstallingKey, ref(false))
const managerStore = useComfyManagerStore()
const { showNodeConflictDialog } = useDialogService()
const { checkVersionCompatibility } = useConflictDetection()
const { acknowledgeConflict, isConflictAcknowledged } =
useConflictAcknowledgment()
const onClick = (): void => {
isInstalling.value = true
}
const managerStore = useComfyManagerStore()
const createPayload = (
installItem: NodePack
): ManagerComponents['schemas']['InstallPackParams'] => {
@@ -65,17 +78,87 @@ const createPayload = (
const installPack = (item: NodePack) =>
managerStore.installPack.call(createPayload(item))
// Function to check compatibility for uninstalled packages using centralized logic
function checkUninstalledPackageCompatibility(
pack: NodePack
): ConflictDetectionResult | null {
const compatibility = checkVersionCompatibility({
supported_os: pack.supported_os,
supported_accelerators: pack.supported_accelerators,
supported_comfyui_version: pack.supported_comfyui_version,
supported_comfyui_frontend_version: pack.supported_comfyui_frontend_version
})
if (compatibility.hasConflict) {
return {
package_id: pack.id || 'unknown',
package_name: pack.name || 'unknown',
has_conflict: true,
conflicts: compatibility.conflicts,
is_compatible: false
}
}
return null
}
const installAllPacks = async () => {
if (!nodePacks?.length) return
// isInstalling.value = true
const uninstalledPacks = nodePacks.filter(
(pack) => !managerStore.isPackInstalled(pack.id)
)
if (!uninstalledPacks.length) return
await Promise.all(uninstalledPacks.map(installPack))
// Skip conflict check if explicitly requested (e.g., from "Install Anyway" button)
if (!skipConflictCheck) {
// Check for conflicts in uninstalled packages
const packsWithConflicts: ConflictDetectionResult[] = []
for (const pack of uninstalledPacks) {
const conflicts = checkUninstalledPackageCompatibility(pack)
if (conflicts) {
// Check if conflicts have been acknowledged
const hasUnacknowledgedConflicts = conflicts.conflicts.some(
(conflict) => !isConflictAcknowledged(pack.id || '', conflict.type)
)
if (hasUnacknowledgedConflicts) {
packsWithConflicts.push(conflicts)
}
}
}
// If there are unacknowledged conflicts, show modal
if (packsWithConflicts.length > 0) {
showNodeConflictDialog({
conflictedPackages: packsWithConflicts,
buttonText: t('manager.conflicts.installAnyway'),
onButtonClick: async () => {
// User chose "Install Anyway" - acknowledge all conflicts and proceed
for (const conflictedPack of packsWithConflicts) {
for (const conflict of conflictedPack.conflicts) {
acknowledgeConflict(
conflictedPack.package_id,
conflict.type,
'0.1.0'
)
}
}
// Proceed with installation
await performInstallation(uninstalledPacks)
}
})
return
}
}
// No conflicts or conflicts acknowledged - proceed with installation
await performInstallation(uninstalledPacks)
}
const performInstallation = async (packs: NodePack[]) => {
await Promise.all(packs.map(installPack))
managerStore.installPack.clear()
}
</script>

View File

@@ -2,11 +2,14 @@
<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]" />
<InfoPanelHeader
:node-packs="[nodePack]"
:has-conflict="hasCompatibilityIssues"
/>
</div>
<div
ref="scrollContainer"
class="p-6 pt-2 overflow-y-auto flex-1 text-sm hidden-scrollbar"
class="p-6 pt-2 overflow-y-auto flex-1 text-sm scrollbar-hide"
>
<div class="mb-6">
<MetadataRow
@@ -36,7 +39,11 @@
</MetadataRow>
</div>
<div class="mb-6 overflow-hidden">
<InfoTabs :node-pack="nodePack" />
<InfoTabs
:node-pack="nodePack"
:has-compatibility-issues="hasCompatibilityIssues"
:conflict-result="conflictResult"
/>
</div>
</div>
</div>
@@ -59,9 +66,11 @@ import PackEnableToggle from '@/components/dialog/content/manager/button/PackEna
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 { useConflictDetection } from '@/composables/useConflictDetection'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
interface InfoItem {
key: string
@@ -84,9 +93,41 @@ whenever(isInstalled, () => {
})
const { isPackInstalled } = useComfyManagerStore()
const { checkVersionCompatibility } = useConflictDetection()
const { t, d, n } = useI18n()
// Check compatibility once and pass to children
const conflictResult = computed((): ConflictDetectionResult | null => {
const compatibility = checkVersionCompatibility({
supported_os: nodePack.supported_os,
supported_accelerators: nodePack.supported_accelerators,
supported_comfyui_version: nodePack.supported_comfyui_version,
supported_comfyui_frontend_version:
nodePack.supported_comfyui_frontend_version
// TODO: Add when API provides these fields
// supported_python_version: nodePack.supported_python_version,
// is_banned: nodePack.is_banned,
// has_registry_data: nodePack.has_registry_data
})
if (compatibility.hasConflict && nodePack.id && nodePack.name) {
return {
package_id: nodePack.id,
package_name: nodePack.name,
has_conflict: true,
conflicts: compatibility.conflicts,
is_compatible: false
}
}
return null
})
const hasCompatibilityIssues = computed(
() => conflictResult.value?.has_conflict ?? false
)
const infoItems = computed<InfoItem[]>(() => [
{
key: 'publisher',
@@ -128,17 +169,3 @@ whenever(
{ immediate: true }
)
</script>
<style scoped>
.hidden-scrollbar {
/* Firefox */
scrollbar-width: none;
&::-webkit-scrollbar {
width: 1px;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
}
}
</style>

View File

@@ -18,7 +18,12 @@
v-bind="$attrs"
:node-packs="nodePacks"
/>
<PackInstallButton v-else v-bind="$attrs" :node-packs="nodePacks" />
<PackInstallButton
v-else
v-bind="$attrs"
:node-packs="nodePacks"
:has-conflict="hasConflict"
/>
</slot>
</div>
</div>
@@ -40,8 +45,9 @@ import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { components } from '@/types/comfyRegistryTypes'
const { nodePacks } = defineProps<{
const { nodePacks, hasConflict } = defineProps<{
nodePacks: components['schemas']['Node'][]
hasConflict?: boolean
}>()
const managerStore = useComfyManagerStore()

View File

@@ -1,15 +1,27 @@
<template>
<div class="overflow-hidden">
<Tabs :value="activeTab">
<TabList>
<Tab value="description">
<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>
{{ $t('g.warning') }}
</div>
</Tab>
<Tab value="description" class="p-2 mr-6">
{{ $t('g.description') }}
</Tab>
<Tab value="nodes">
<Tab value="nodes" class="p-2">
{{ $t('g.nodes') }}
</Tab>
</TabList>
<TabPanels class="overflow-auto">
<TabPanels class="overflow-auto py-4 px-2">
<TabPanel v-if="hasCompatibilityIssues" value="warning">
<WarningTabPanel
:node-pack="nodePack"
:conflict-result="conflictResult"
/>
</TabPanel>
<TabPanel value="description">
<DescriptionTabPanel :node-pack="nodePack" />
</TabPanel>
@@ -27,14 +39,18 @@ import TabList from 'primevue/tablist'
import TabPanel from 'primevue/tabpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { computed, ref } from 'vue'
import { computed, ref, watchEffect } from 'vue'
import DescriptionTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/DescriptionTabPanel.vue'
import NodesTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/NodesTabPanel.vue'
import WarningTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/WarningTabPanel.vue'
import { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
const { nodePack } = defineProps<{
const { nodePack, hasCompatibilityIssues, conflictResult } = defineProps<{
nodePack: components['schemas']['Node']
hasCompatibilityIssues?: boolean
conflictResult?: ConflictDetectionResult | null
}>()
const nodeNames = computed(() => {
@@ -44,4 +60,14 @@ const nodeNames = computed(() => {
})
const activeTab = ref('description')
// Watch for compatibility issues and automatically switch to warning tab
watchEffect(() => {
if (hasCompatibilityIssues) {
activeTab.value = 'warning'
} else if (activeTab.value === 'warning') {
// If currently on warning tab but no issues, switch to description
activeTab.value = 'description'
}
}, { flush: 'post' })
</script>

View File

@@ -0,0 +1,24 @@
<template>
<div class="flex flex-col gap-3">
<div
v-for="(conflict, index) in conflictResult?.conflicts || []"
:key="index"
class="p-3 bg-yellow-800/20 rounded-md"
>
<div class="text-sm">
{{ getConflictMessage(conflict, $t) }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import { getConflictMessage } from '@/utils/conflictMessageUtil'
const { conflictResult } = defineProps<{
nodePack: components['schemas']['Node']
conflictResult?: ConflictDetectionResult | null
}>()
</script>

View File

@@ -43,7 +43,7 @@
</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"
class="flex-1 text-muted text-xs font-medium break-words overflow-hidden max-h-12 line-clamp-3 my-0 leading-4 mb-2 overflow-hidden"
>
{{ nodePack.description }}
</p>

View File

@@ -1,13 +1,22 @@
<template>
<div
class="min-h-12 flex justify-between items-center px-4 py-2 text-xs text-muted font-medium leading-3"
class="h-12 flex justify-between items-center px-4 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]" />
<PackEnableToggle v-else :node-pack="nodePack" />
<div class="flex justify-end items-center gap-2">
<template v-if="!isInstalled">
<PackInstallButton
:node-packs="[nodePack]"
:has-conflict="hasConflict"
/>
</template>
<template v-else>
<PackEnableToggle :node-pack="nodePack" :has-conflict="hasConflict" />
</template>
</div>
</div>
</template>
@@ -17,7 +26,9 @@ import { useI18n } from 'vue-i18n'
import PackEnableToggle from '@/components/dialog/content/manager/button/PackEnableToggle.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import type { components } from '@/types/comfyRegistryTypes'
const { nodePack } = defineProps<{
@@ -32,4 +43,42 @@ const { n } = useI18n()
const formattedDownloads = computed(() =>
nodePack.downloads ? n(nodePack.downloads) : ''
)
const conflictStore = useConflictDetectionStore()
const { checkVersionCompatibility } = useConflictDetection()
// TODO: Package version mismatch issue - Package IDs include version suffixes (@1_0_3)
// but UI searches without version. This causes conflict detection failures.
// Once getConflictsForPackage is improved to handle version matching properly,
// all the complex fallback logic below can be removed.
const hasConflict = computed(() => {
if (!nodePack.id) return false
// For installed packages, check conflicts from store
if (isInstalled.value) {
// Try exact match first
let conflicts = conflictStore.getConflictsForPackage(nodePack.id)
if (conflicts) return true
return false
}
// For uninstalled packages, check compatibility directly
if (
nodePack.supported_os ||
nodePack.supported_accelerators ||
nodePack.supported_comfyui_version
) {
const compatibility = checkVersionCompatibility({
supported_os: nodePack.supported_os,
supported_accelerators: nodePack.supported_accelerators,
supported_comfyui_version: nodePack.supported_comfyui_version,
supported_comfyui_frontend_version:
nodePack.supported_comfyui_frontend_version
})
return compatibility.hasConflict
}
return false
})
</script>

View File

@@ -20,7 +20,10 @@
{{ $n(nodePack.downloads) }}
</div>
<template v-if="isInstalled">
<PackEnableToggle :node-pack="nodePack" />
<PackEnableToggle
:node-pack="nodePack"
:has-conflict="!!packageConflicts"
/>
</template>
<template v-else>
<PackInstallButton :node-packs="[nodePack]" />
@@ -35,6 +38,7 @@ import { computed } from 'vue'
import PackEnableToggle from '@/components/dialog/content/manager/button/PackEnableToggle.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import type { components } from '@/types/comfyRegistryTypes'
const { nodePack } = defineProps<{
@@ -42,5 +46,13 @@ const { nodePack } = defineProps<{
}>()
const { isPackInstalled } = useComfyManagerStore()
const conflictStore = useConflictDetectionStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const packageConflicts = computed(() => {
if (!nodePack.id || !isInstalled.value) return null
// For installed packages, check conflicts from store
return conflictStore.getConflictsForPackage(nodePack.id)
})
</script>

View File

@@ -14,7 +14,17 @@
@mouseenter="onMenuItemHover(menuItem.key, $event)"
@mouseleave="onMenuItemLeave(menuItem.key)"
>
<i :class="menuItem.icon" class="help-menu-icon" />
<div class="help-menu-icon-container">
<div class="help-menu-icon">
<component
:is="menuItem.icon"
v-if="typeof menuItem.icon === 'object'"
:size="16"
/>
<i v-else :class="menuItem.icon" />
</div>
<div v-if="menuItem.showRedDot" class="menu-red-dot" />
</div>
<span class="menu-label">{{ menuItem.label }}</span>
<i v-if="menuItem.key === 'more'" class="pi pi-chevron-right" />
</button>
@@ -119,10 +129,21 @@
</template>
<script setup lang="ts">
// No need to import useStorage anymore - it's in the composable
import Button from 'primevue/button'
import { type CSSProperties, computed, nextTick, onMounted, ref } from 'vue'
import {
type CSSProperties,
type Component,
computed,
nextTick,
onMounted,
ref
} from 'vue'
import { useI18n } from 'vue-i18n'
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
import { useConflictBannerState } from '@/composables/useConflictBannerState'
import { useDialogService } from '@/services/dialogService'
import { type ReleaseNote } from '@/services/releaseService'
import { useCommandStore } from '@/stores/commandStore'
import { useReleaseStore } from '@/stores/releaseStore'
@@ -133,12 +154,13 @@ import { formatVersionAnchor } from '@/utils/formatUtil'
// Types
interface MenuItem {
key: string
icon?: string
icon?: string | Component
label?: string
action?: () => void
visible?: boolean
type?: 'item' | 'divider'
items?: MenuItem[]
showRedDot?: boolean
}
// Constants
@@ -170,6 +192,7 @@ const { t, locale } = useI18n()
const releaseStore = useReleaseStore()
const commandStore = useCommandStore()
const settingStore = useSettingStore()
const dialogService = useDialogService()
// Emits
const emit = defineEmits<{
@@ -192,6 +215,11 @@ const moreMenuItem = computed(() =>
menuItems.value.find((item) => item.key === 'more')
)
// Use conflict banner state from composable
const conflictBannerState = useConflictBannerState()
const { shouldShowConflictRedDot: shouldShowManagerRedDot } =
conflictBannerState
const menuItems = computed<MenuItem[]>(() => {
const moreItems: MenuItem[] = [
{
@@ -271,6 +299,17 @@ const menuItems = computed<MenuItem[]>(() => {
emit('close')
}
},
{
key: 'manager',
type: 'item',
icon: PuzzleIcon,
label: t('helpCenter.managerExtension'),
showRedDot: shouldShowManagerRedDot.value,
action: () => {
dialogService.showManagerDialog()
emit('close')
}
},
{
key: 'more',
type: 'item',
@@ -505,16 +544,39 @@ onMounted(async () => {
box-shadow: none;
}
.help-menu-icon {
.help-menu-icon-container {
position: relative;
margin-right: 0.75rem;
width: 16px;
flex-shrink: 0;
}
.help-menu-icon {
font-size: 1rem;
color: var(--p-text-muted-color);
width: 16px;
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
}
.help-menu-icon svg {
color: var(--p-text-muted-color);
}
.menu-red-dot {
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
background: #ff3b30;
border-radius: 50%;
border: 1.5px solid var(--p-content-background);
z-index: 1;
}
.menu-label {
flex: 1;
}

View File

@@ -75,6 +75,11 @@ import { formatVersionAnchor } from '@/utils/formatUtil'
const { locale, t } = useI18n()
const releaseStore = useReleaseStore()
// Emit event for parent component
const emit = defineEmits<{
'whats-new-dismissed': []
}>()
// Local state for dismissed status
const isDismissed = ref(false)
@@ -134,6 +139,10 @@ const closePopup = async () => {
await releaseStore.handleWhatsNewSeen(latestRelease.value.version)
}
hide()
// Emit event to notify parent that What's New was dismissed
// Parent can then check if conflict modal should be shown
emit('whats-new-dismissed')
}
// Learn more handled by anchor href

View File

@@ -0,0 +1,43 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 16 16"
fill="none"
:class="iconClass"
>
<g clip-path="url(#clip0_1099_16244)">
<path
d="M4.99992 3.00016C4.99992 2.07969 5.74611 1.3335 6.66658 1.3335C7.58706 1.3335 8.33325 2.07969 8.33325 3.00016V4.00016H8.99992C9.9318 4.00016 10.3977 4.00016 10.7653 4.1524C11.2553 4.35539 11.6447 4.74474 11.8477 5.2348C11.9999 5.60234 11.9999 6.06828 11.9999 7.00016H12.9999C13.9204 7.00016 14.6666 7.74635 14.6666 8.66683C14.6666 9.5873 13.9204 10.3335 12.9999 10.3335H11.9999V11.4668C11.9999 12.5869 11.9999 13.147 11.7819 13.5748C11.5902 13.9511 11.2842 14.2571 10.9079 14.4488C10.4801 14.6668 9.92002 14.6668 8.79992 14.6668H8.33325V13.5002C8.33325 12.6717 7.66168 12.0002 6.83325 12.0002C6.00482 12.0002 5.33325 12.6717 5.33325 13.5002V14.6668H4.53325C3.41315 14.6668 2.85309 14.6668 2.42527 14.4488C2.04895 14.2571 1.74299 13.9511 1.55124 13.5748C1.33325 13.147 1.33325 12.5869 1.33325 11.4668V10.3335H2.33325C3.25373 10.3335 3.99992 9.5873 3.99992 8.66683C3.99992 7.74635 3.25373 7.00016 2.33325 7.00016H1.33325C1.33325 6.06828 1.33325 5.60234 1.48549 5.2348C1.68848 4.74474 2.07783 4.35539 2.56789 4.1524C2.93543 4.00016 3.40137 4.00016 4.33325 4.00016H4.99992V3.00016Z"
:stroke="color"
stroke-width="1.2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_1099_16244">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
size?: number | string
color?: string
class?: string
}
const {
size = 16,
color = 'currentColor',
class: className
} = defineProps<Props>()
const iconClass = computed(() => className || '')
</script>

View File

@@ -0,0 +1,29 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 16 16"
fill="none"
:class="iconClass"
>
<path
d="M8.00049 1.3335C8.73661 1.33367 9.33332 1.93038 9.3335 2.6665V2.83447C9.82278 2.96041 10.2851 3.15405 10.7095 3.40479L10.8286 3.28564C11.3493 2.76525 12.1937 2.76519 12.7144 3.28564C13.235 3.80626 13.2348 4.65067 12.7144 5.17139L12.5952 5.29053C12.846 5.71486 13.0396 6.17725 13.1655 6.6665H13.3335C14.0699 6.6665 14.6665 7.26411 14.6665 8.00049C14.6663 8.73672 14.0698 9.3335 13.3335 9.3335H13.1655C13.0396 9.82284 12.846 10.2851 12.5952 10.7095L12.7144 10.8286C13.235 11.3493 13.235 12.1937 12.7144 12.7144C12.1937 13.235 11.3493 13.235 10.8286 12.7144L10.7095 12.5952C10.2851 12.846 9.82284 13.0396 9.3335 13.1655V13.3335C9.3335 14.0698 8.73672 14.6663 8.00049 14.6665C7.26411 14.6665 6.6665 14.0699 6.6665 13.3335V13.1655C6.17725 13.0396 5.71486 12.846 5.29053 12.5952L5.17139 12.7144C4.65067 13.2348 3.80626 13.235 3.28564 12.7144C2.76519 12.1937 2.76525 11.3493 3.28564 10.8286L3.40479 10.7095C3.15405 10.2851 2.96041 9.82278 2.83447 9.3335H2.6665C1.93038 9.33332 1.33367 8.73661 1.3335 8.00049C1.3335 7.26422 1.93027 6.66668 2.6665 6.6665H2.83447C2.96043 6.17722 3.15403 5.71488 3.40479 5.29053L3.28564 5.17139C2.76536 4.65065 2.76508 3.80621 3.28564 3.28564C3.80621 2.76508 4.65065 2.76536 5.17139 3.28564L5.29053 3.40479C5.71488 3.15403 6.17722 2.96043 6.6665 2.83447V2.6665C6.66668 1.93027 7.26422 1.3335 8.00049 1.3335ZM7.3335 8.00049L6.00049 6.6665L4.6665 8.00049L7.3335 10.6665L11.3335 6.6665L10.0005 5.3335L7.3335 8.00049Z"
:fill="color"
/>
</svg>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
size?: number | string
color?: string
class?: string
}
const { size = 16, color = '#60A5FA', class: className } = defineProps<Props>()
const iconClass = computed(() => className || '')
</script>

View File

@@ -42,6 +42,7 @@
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': sidebarSize === 'small'
}"
@whats-new-dismissed="handleWhatsNewDismissed"
/>
</Teleport>
@@ -57,12 +58,14 @@
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import HelpCenterMenuContent from '@/components/helpcenter/HelpCenterMenuContent.vue'
import ReleaseNotificationToast from '@/components/helpcenter/ReleaseNotificationToast.vue'
import WhatsNewPopup from '@/components/helpcenter/WhatsNewPopup.vue'
import { useConflictBannerState } from '@/composables/useConflictBannerState'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useDialogService } from '@/services/dialogService'
import { useReleaseStore } from '@/stores/releaseStore'
import { useSettingStore } from '@/stores/settingStore'
@@ -70,9 +73,20 @@ import SidebarIcon from './SidebarIcon.vue'
const settingStore = useSettingStore()
const releaseStore = useReleaseStore()
const { shouldShowRedDot } = storeToRefs(releaseStore)
const conflictDetection = useConflictDetection()
const conflictBannerState = useConflictBannerState()
const dialogService = useDialogService()
const isHelpCenterVisible = ref(false)
// Use conflict banner state from composable
const { shouldShowConflictRedDot } = conflictBannerState
// Use either release red dot or conflict red dot
const shouldShowRedDot = computed(() => {
const releaseRedDot = releaseStore.shouldShowRedDot
return releaseRedDot || shouldShowConflictRedDot.value
})
const sidebarLocation = computed(() =>
settingStore.get('Comfy.Sidebar.Location')
)
@@ -87,6 +101,44 @@ const closeHelpCenter = () => {
isHelpCenterVisible.value = false
}
/**
* Handle What's New popup dismissal
* Check if conflict modal should be shown after ComfyUI update
*/
const handleWhatsNewDismissed = async () => {
try {
// Check if conflict modal should be shown after update
const shouldShow =
await conflictDetection.shouldShowConflictModalAfterUpdate()
if (shouldShow) {
showConflictModal()
}
} catch (error) {
console.error('[HelpCenter] Error checking conflict modal:', error)
}
}
/**
* Show the node conflict dialog with current conflict data
*/
const showConflictModal = () => {
// Pass conflict data to the dialog, including onClose callback
const conflictData = {
conflictedPackages: conflictDetection.conflictedPackages.value
}
// Show dialog with onClose callback in dialogComponentProps
dialogService.showNodeConflictDialog({
...conflictData,
dialogComponentProps: {
onClose: () => {
conflictDetection.dismissConflictModal()
}
}
})
}
// Initialize release store on mount
onMounted(async () => {
// Initialize release store to fetch releases for toast and popup

View File

@@ -0,0 +1,223 @@
import { useStorage } from '@vueuse/core'
import { computed } from 'vue'
/**
* LocalStorage keys for conflict acknowledgment tracking
*/
const STORAGE_KEYS = {
CONFLICT_MODAL_DISMISSED: 'comfy_manager_conflict_banner_dismissed',
CONFLICT_RED_DOT_DISMISSED: 'comfy_help_center_conflict_seen',
ACKNOWLEDGED_CONFLICTS: 'comfy_conflict_acknowledged',
LAST_COMFYUI_VERSION: 'comfyui.last_version'
} as const
/**
* Interface for tracking individual conflict acknowledgments
*/
interface AcknowledgedConflict {
package_id: string
conflict_type: string
timestamp: string
comfyui_version: string
}
/**
* Interface for conflict acknowledgment state
*/
interface ConflictAcknowledgmentState {
modal_dismissed: boolean
red_dot_dismissed: boolean
acknowledged_conflicts: AcknowledgedConflict[]
last_comfyui_version: string
}
/**
* 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() {
// Reactive state using VueUse's useStorage for automatic persistence
const modalDismissed = useStorage(
STORAGE_KEYS.CONFLICT_MODAL_DISMISSED,
false
)
const redDotDismissed = useStorage(
STORAGE_KEYS.CONFLICT_RED_DOT_DISMISSED,
false
)
const acknowledgedConflicts = useStorage<AcknowledgedConflict[]>(
STORAGE_KEYS.ACKNOWLEDGED_CONFLICTS,
[]
)
const lastComfyUIVersion = useStorage(STORAGE_KEYS.LAST_COMFYUI_VERSION, '')
// Create computed state object for backward compatibility
const state = computed<ConflictAcknowledgmentState>(() => ({
modal_dismissed: modalDismissed.value,
red_dot_dismissed: redDotDismissed.value,
acknowledged_conflicts: acknowledgedConflicts.value,
last_comfyui_version: lastComfyUIVersion.value
}))
/**
* Check if ComfyUI version has changed since last run
* If version changed, reset acknowledgment state
*/
function checkComfyUIVersionChange(currentVersion: string): boolean {
const lastVersion = lastComfyUIVersion.value
const versionChanged = lastVersion !== '' && lastVersion !== currentVersion
if (versionChanged) {
console.log(
`[ConflictAcknowledgment] ComfyUI version changed from ${lastVersion} to ${currentVersion}, resetting acknowledgment state`
)
resetAcknowledgmentState()
}
// Update last known version
lastComfyUIVersion.value = currentVersion
return versionChanged
}
/**
* Reset all acknowledgment state (called when ComfyUI version changes)
*/
function resetAcknowledgmentState(): void {
modalDismissed.value = false
redDotDismissed.value = false
acknowledgedConflicts.value = []
}
/**
* Mark conflict modal as dismissed
*/
function dismissConflictModal(): void {
modalDismissed.value = true
console.log('[ConflictAcknowledgment] Conflict modal dismissed')
}
/**
* Mark red dot notification as dismissed
*/
function dismissRedDotNotification(): void {
redDotDismissed.value = true
console.log('[ConflictAcknowledgment] Red dot notification dismissed')
}
/**
* Acknowledge a specific conflict for a package
*/
function acknowledgeConflict(
packageId: string,
conflictType: string,
comfyuiVersion: string
): void {
const acknowledgment: AcknowledgedConflict = {
package_id: packageId,
conflict_type: conflictType,
timestamp: new Date().toISOString(),
comfyui_version: comfyuiVersion
}
// Remove any existing acknowledgment for the same package and conflict type
acknowledgedConflicts.value = acknowledgedConflicts.value.filter(
(ack) =>
!(ack.package_id === packageId && ack.conflict_type === conflictType)
)
// Add new acknowledgment
acknowledgedConflicts.value.push(acknowledgment)
console.log(
`[ConflictAcknowledgment] Acknowledged conflict for ${packageId}:${conflictType}`
)
}
/**
* Check if a specific conflict has been acknowledged
*/
function isConflictAcknowledged(
packageId: string,
conflictType: string
): boolean {
return acknowledgedConflicts.value.some(
(ack) =>
ack.package_id === packageId && ack.conflict_type === conflictType
)
}
/**
* Remove acknowledgment for a specific conflict
*/
function removeConflictAcknowledgment(
packageId: string,
conflictType: string
): void {
acknowledgedConflicts.value = acknowledgedConflicts.value.filter(
(ack) =>
!(ack.package_id === packageId && ack.conflict_type === conflictType)
)
console.log(
`[ConflictAcknowledgment] Removed acknowledgment for ${packageId}:${conflictType}`
)
}
/**
* Clear all acknowledgments (for debugging/admin purposes)
*/
function clearAllAcknowledgments(): void {
resetAcknowledgmentState()
console.log('[ConflictAcknowledgment] Cleared all acknowledgments')
}
// Computed properties
const shouldShowConflictModal = computed(() => !modalDismissed.value)
const shouldShowRedDot = computed(() => !redDotDismissed.value)
/**
* Get all acknowledged package IDs
*/
const acknowledgedPackageIds = computed(() => {
return Array.from(
new Set(acknowledgedConflicts.value.map((ack) => ack.package_id))
)
})
/**
* Get acknowledgment statistics
*/
const acknowledgmentStats = computed(() => {
return {
total_acknowledged: acknowledgedConflicts.value.length,
unique_packages: acknowledgedPackageIds.value.length,
modal_dismissed: modalDismissed.value,
red_dot_dismissed: redDotDismissed.value,
last_comfyui_version: lastComfyUIVersion.value
}
})
return {
// State
acknowledgmentState: state,
shouldShowConflictModal,
shouldShowRedDot,
acknowledgedPackageIds,
acknowledgmentStats,
// Methods
checkComfyUIVersionChange,
dismissConflictModal,
dismissRedDotNotification,
acknowledgeConflict,
isConflictAcknowledged,
removeConflictAcknowledgment,
clearAllAcknowledgments,
resetAcknowledgmentState
}
}

View File

@@ -0,0 +1,71 @@
import { useStorage } from '@vueuse/core'
import { computed } from 'vue'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
/**
* Composable for managing conflict banner state across components
* Provides centralized logic for conflict visibility and dismissal
*/
export function useConflictBannerState() {
const conflictDetectionStore = useConflictDetectionStore()
// Storage keys
const HELP_CENTER_CONFLICT_SEEN_KEY = 'comfy_help_center_conflict_seen'
const MANAGER_CONFLICT_BANNER_DISMISSED_KEY =
'comfy_manager_conflict_banner_dismissed'
// Reactive storage state
const hasSeenConflicts = useStorage(HELP_CENTER_CONFLICT_SEEN_KEY, false)
const isConflictBannerDismissed = useStorage(
MANAGER_CONFLICT_BANNER_DISMISSED_KEY,
false
)
// Computed states
const hasConflicts = computed(() => conflictDetectionStore.hasConflicts)
/**
* Check if the help center should show a red dot for conflicts
*/
const shouldShowConflictRedDot = computed(() => {
if (!hasConflicts.value) return false
return !hasSeenConflicts.value
})
/**
* Check if the manager conflict banner should be visible
*/
const shouldShowManagerBanner = computed(() => {
return hasConflicts.value && !isConflictBannerDismissed.value
})
/**
* Mark conflicts as seen (used when user opens manager dialog or help center)
*/
const markConflictsAsSeen = () => {
if (hasConflicts.value) {
hasSeenConflicts.value = true
isConflictBannerDismissed.value = true
// Force localStorage update as backup due to useStorage sync timing issue
// useStorage updates localStorage asynchronously, but we need immediate persistence
localStorage.setItem(HELP_CENTER_CONFLICT_SEEN_KEY, 'true')
localStorage.setItem(MANAGER_CONFLICT_BANNER_DISMISSED_KEY, 'true')
}
}
return {
// State
hasConflicts,
hasSeenConflicts,
isConflictBannerDismissed,
// Computed
shouldShowConflictRedDot,
shouldShowManagerBanner,
// Actions
markConflictsAsSeen
}
}

View File

@@ -1,8 +1,11 @@
import { uniqBy } from 'lodash'
import { computed, getCurrentInstance, onUnmounted, readonly, ref } from 'vue'
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
import config from '@/config'
import { useComfyManagerService } from '@/services/comfyManagerService'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { SystemStats } from '@/types'
import type { components } from '@/types/comfyRegistryTypes'
@@ -13,7 +16,6 @@ import type {
ConflictDetectionSummary,
ConflictType,
NodePackRequirements,
RecommendedAction,
SupportedAccelerator,
SupportedOS,
SystemEnvironment
@@ -21,8 +23,8 @@ import type {
import type { components as ManagerComponents } from '@/types/generatedManagerTypes'
import {
cleanVersion,
describeVersionRange,
satisfiesVersion
satisfiesVersion,
checkVersionCompatibility as utilCheckVersionCompatibility
} from '@/utils/versionUtil'
/**
@@ -40,36 +42,28 @@ export function useConflictDetection() {
// Conflict detection results
const detectionResults = ref<ConflictDetectionResult[]>([])
// Store merged conflicts separately for testing
const storedMergedConflicts = ref<ConflictDetectionResult[]>([])
const detectionSummary = ref<ConflictDetectionSummary | null>(null)
// Registry API request cancellation
const abortController = ref<AbortController | null>(null)
// Computed properties
const hasConflicts = computed(() =>
detectionResults.value.some((result) => result.has_conflict)
)
// Acknowledgment management
const acknowledgment = useConflictAcknowledgment()
const conflictedPackages = computed(() =>
detectionResults.value.filter((result) => result.has_conflict)
)
// Store management
const conflictStore = useConflictDetectionStore()
const bannedPackages = computed(() =>
detectionResults.value.filter((result) =>
result.conflicts.some((conflict) => conflict.type === 'banned')
)
)
// Computed properties - use store instead of local state
const hasConflicts = computed(() => conflictStore.hasConflicts)
const conflictedPackages = computed(() => {
return conflictStore.conflictedPackages
})
const securityPendingPackages = computed(() =>
detectionResults.value.filter((result) =>
result.conflicts.some((conflict) => conflict.type === 'security_pending')
)
)
const criticalConflicts = computed(() =>
detectionResults.value.flatMap((result) =>
result.conflicts.filter((conflict) => conflict.severity === 'error')
)
const bannedPackages = computed(() => conflictStore.bannedPackages)
const securityPendingPackages = computed(
() => conflictStore.securityPendingPackages
)
/**
@@ -388,52 +382,167 @@ export function useConflictDetection() {
if (acceleratorConflict) conflicts.push(acceleratorConflict)
}
// 5. Banned package check
if (packageReq.is_banned) {
conflicts.push({
type: 'banned',
severity: 'error',
description: `Package is banned: ${packageReq.ban_reason || 'Unknown reason'}`,
current_value: 'installed',
required_value: 'not_banned',
resolution_steps: ['Remove package', 'Find alternative package']
})
// 5. Banned package check using shared logic
const bannedConflict = checkBannedStatus(packageReq.is_banned)
if (bannedConflict) {
conflicts.push(bannedConflict)
}
// 6. Registry data availability check
if (!packageReq.has_registry_data) {
conflicts.push({
type: 'security_pending',
severity: 'warning',
description:
'Registry data not available - compatibility cannot be verified',
current_value: 'no_registry_data',
required_value: 'registry_data_available',
resolution_steps: [
'Check if package exists in Registry',
'Verify package name is correct',
'Try again later if Registry is temporarily unavailable'
]
})
// 6. Registry data availability check using shared logic
const securityConflict = checkSecurityStatus(packageReq.has_registry_data)
if (securityConflict) {
conflicts.push(securityConflict)
}
// Generate result
const hasConflict = conflicts.length > 0
const canAutoResolve = conflicts.every(
(c) => c.resolution_steps && c.resolution_steps.length > 0
)
return {
package_id: packageReq.package_id,
package_name: packageReq.package_name,
has_conflict: hasConflict,
conflicts,
is_compatible: !hasConflict,
can_auto_resolve: canAutoResolve,
recommended_action: determineRecommendedAction(conflicts)
is_compatible: !hasConflict
}
}
/**
* Fetches Python import failure information from ComfyUI Manager.
* Gets installed packages and checks each one for import failures.
* @returns Promise that resolves to import failure data
*/
async function fetchImportFailInfo(): Promise<Record<string, any>> {
try {
const comfyManagerService = useComfyManagerService()
// Get installed packages first
const installedPacks = await comfyManagerService.listInstalledPacks(
abortController.value?.signal
)
if (!installedPacks) {
console.warn('[ConflictDetection] No installed packages found')
return {}
}
const importFailures: Record<string, any> = {}
// Check each installed package for import failures
// Process in smaller batches to avoid overwhelming the API
const packageNames = Object.keys(installedPacks)
const batchSize = 10
for (let i = 0; i < packageNames.length; i += batchSize) {
const batch = packageNames.slice(i, i + batchSize)
const batchResults = await Promise.allSettled(
batch.map(async (packageName) => {
try {
// Try to get import failure info for this package
const failInfo = await comfyManagerService.getImportFailInfo(
{ cnr_id: packageName },
abortController.value?.signal
)
if (failInfo) {
console.log(
`[ConflictDetection] Import failure found for ${packageName}:`,
failInfo
)
return { packageName, failInfo }
}
} catch (error) {
// If API returns 400, it means no import failure info available
// This is normal for packages that imported successfully
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as any
if (axiosError.response?.status === 400) {
return null // No failure info available (normal case)
}
}
console.warn(
`[ConflictDetection] Failed to check import failure for ${packageName}:`,
error
)
}
return null
})
)
// Process batch results
batchResults.forEach((result) => {
if (result.status === 'fulfilled' && result.value) {
const { packageName, failInfo } = result.value
importFailures[packageName] = failInfo
}
})
}
return importFailures
} 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 detectPythonImportConflicts(
importFailInfo: Record<string, any>
): ConflictDetectionResult[] {
const results: ConflictDetectionResult[] = []
if (!importFailInfo || typeof importFailInfo !== 'object') {
return results
}
// Process import failures
for (const [packageName, 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 || ''
// Parse error message to extract missing dependency
const missingDependency = extractMissingDependency(errorMsg)
const conflicts: ConflictDetail[] = [
{
type: 'python_dependency',
current_value: 'missing',
required_value: missingDependency
}
]
results.push({
package_id: packageName,
package_name: packageName,
has_conflict: true,
conflicts,
is_compatible: false
})
console.warn(
`[ConflictDetection] Python import failure detected for ${packageName}:`,
{
path: modulePath,
error: errorMsg,
missingDependency
}
)
}
}
return results
}
/**
* Performs complete conflict detection.
* @returns Promise that resolves to conflict detection response
@@ -477,24 +586,83 @@ export function useConflictDetection() {
)
const conflictResults = await Promise.allSettled(conflictDetectionTasks)
const results: ConflictDetectionResult[] = conflictResults
const packageResults: ConflictDetectionResult[] = conflictResults
.map((result) => (result.status === 'fulfilled' ? result.value : null))
.filter((result): result is ConflictDetectionResult => result !== null)
// 4. Generate summary information
const summary = generateSummary(results, Date.now() - startTime)
// 4. Detect Python import failures
const importFailInfo = await fetchImportFailInfo()
const importFailResults = detectPythonImportConflicts(importFailInfo)
console.log(
'[ConflictDetection] Python import failures detected:',
importFailResults
)
// 5. Update state
detectionResults.value = results
// 5. Combine all results
const allResults = [...packageResults, ...importFailResults]
// 6. Generate summary information
const summary = generateSummary(allResults, Date.now() - startTime)
// 7. Update state
detectionResults.value = allResults
detectionSummary.value = summary
lastDetectionTime.value = new Date().toISOString()
console.log('[ConflictDetection] Conflict detection completed:', summary)
// 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 = mergeConflictsByPackageName(conflictedResults)
console.log(
'[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.splice(
0,
detectionResults.value.length,
...mergedConflicts
)
storedMergedConflicts.value = [...mergedConflicts]
// Check for ComfyUI version change to reset acknowledgments
if (sysEnv.comfyui_version !== 'unknown') {
acknowledgment.checkComfyUIVersionChange(sysEnv.comfyui_version)
}
// TODO: Show red dot on Help Center based on acknowledgment.shouldShowRedDot
// TODO: Store conflict state for event-based dialog triggers
// Use merged conflicts in response as well
const response: ConflictDetectionResponse = {
success: true,
summary,
results: mergedConflicts,
detected_system_environment: sysEnv
}
return response
} else {
// No conflicts detected, clear the results
conflictStore.clearConflicts()
detectionResults.value = []
}
const response: ConflictDetectionResponse = {
success: true,
summary,
results,
results: allResults,
detected_system_environment: sysEnv
}
@@ -557,6 +725,180 @@ export function useConflictDetection() {
// 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> {
console.log(
'[ConflictDetection] Checking if conflict modal should show after update...'
)
// Ensure conflict detection has run
if (detectionResults.value.length === 0) {
console.log(
'[ConflictDetection] No detection results, running conflict detection...'
)
await performConflictDetection()
}
// 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
console.log('[ConflictDetection] Modal check:', {
hasConflicts: hasActualConflicts,
canShowModal: canShowModal,
conflictedPackagesCount: conflictedPackages.value.length
})
return hasActualConflicts && canShowModal
}
/**
* Mark conflict modal as dismissed
*/
function dismissConflictModal(): void {
acknowledgment.dismissConflictModal()
}
/**
* Mark red dot notification as dismissed
*/
function dismissRedDotNotification(): void {
acknowledgment.dismissRedDotNotification()
}
/**
* Acknowledge a specific conflict
*/
function acknowledgePackageConflict(
packageId: string,
conflictType: string
): void {
const currentVersion = systemEnvironment.value?.comfyui_version || 'unknown'
acknowledgment.acknowledgeConflict(packageId, conflictType, currentVersion)
}
/**
* Check compatibility for a specific version of a package.
* Used by components like PackVersionSelectorPopover.
*/
function checkVersionCompatibility(versionData: {
supported_os?: string[]
supported_accelerators?: string[]
supported_comfyui_version?: string
supported_comfyui_frontend_version?: string
supported_python_version?: string
is_banned?: boolean
has_registry_data?: boolean
}) {
const systemStatsStore = useSystemStatsStore()
const systemStats = systemStatsStore.systemStats
if (!systemStats) return { hasConflict: false, conflicts: [] }
const conflicts: ConflictDetail[] = []
// Check OS compatibility using centralized function
if (versionData.supported_os && versionData.supported_os.length > 0) {
const currentOS = systemStats.system?.os || 'unknown'
const osConflict = checkOSConflict(
versionData.supported_os as SupportedOS[],
currentOS as SupportedOS
)
if (osConflict) {
conflicts.push(osConflict)
}
}
// Check accelerator compatibility using centralized function
if (
versionData.supported_accelerators &&
versionData.supported_accelerators.length > 0
) {
// Extract available accelerators from system stats
const acceleratorInfo = extractAcceleratorInfo(systemStats)
const availableAccelerators: SupportedAccelerator[] = []
acceleratorInfo.available.forEach((accel) => {
if (accel === 'CUDA') availableAccelerators.push('CUDA')
if (accel === 'Metal') availableAccelerators.push('Metal')
if (accel === 'CPU') availableAccelerators.push('CPU')
})
const acceleratorConflict = checkAcceleratorConflict(
versionData.supported_accelerators as SupportedAccelerator[],
availableAccelerators
)
if (acceleratorConflict) {
conflicts.push(acceleratorConflict)
}
}
// Check ComfyUI version compatibility
if (versionData.supported_comfyui_version) {
const currentComfyUIVersion = systemStats.system?.comfyui_version
if (currentComfyUIVersion && currentComfyUIVersion !== 'unknown') {
const versionConflict = utilCheckVersionCompatibility(
'comfyui_version',
currentComfyUIVersion,
versionData.supported_comfyui_version
)
if (versionConflict) {
conflicts.push(versionConflict)
}
}
}
// Check ComfyUI Frontend version compatibility
if (versionData.supported_comfyui_frontend_version) {
const currentFrontendVersion = config.app_version
if (currentFrontendVersion && currentFrontendVersion !== 'unknown') {
const versionConflict = utilCheckVersionCompatibility(
'frontend_version',
currentFrontendVersion,
versionData.supported_comfyui_frontend_version
)
if (versionConflict) {
conflicts.push(versionConflict)
}
}
}
// Check Python version compatibility
if (versionData.supported_python_version) {
const currentPythonVersion = systemStats.system?.python_version
if (currentPythonVersion && currentPythonVersion !== 'unknown') {
const versionConflict = utilCheckVersionCompatibility(
'python_version',
currentPythonVersion,
versionData.supported_python_version
)
if (versionConflict) {
conflicts.push(versionConflict)
}
}
}
// Check banned package status using shared logic
const bannedConflict = checkBannedStatus(versionData.is_banned)
if (bannedConflict) {
conflicts.push(bannedConflict)
}
// Check security/registry data status using shared logic
const securityConflict = checkSecurityStatus(versionData.has_registry_data)
if (securityConflict) {
conflicts.push(securityConflict)
}
return {
hasConflict: conflicts.length > 0,
conflicts
}
}
return {
// State
isDetecting: readonly(isDetecting),
@@ -571,18 +913,110 @@ export function useConflictDetection() {
conflictedPackages,
bannedPackages,
securityPendingPackages,
criticalConflicts,
// Acknowledgment state
shouldShowConflictModal: acknowledgment.shouldShowConflictModal,
shouldShowRedDot: acknowledgment.shouldShowRedDot,
acknowledgedPackageIds: acknowledgment.acknowledgedPackageIds,
// Methods
performConflictDetection,
detectSystemEnvironment,
initializeConflictDetection,
cancelRequests
cancelRequests,
shouldShowConflictModalAfterUpdate,
dismissConflictModal,
dismissRedDotNotification,
acknowledgePackageConflict,
// Helper functions for other components
checkVersionCompatibility
}
}
// Helper Functions Implementation
/**
* Merges conflict results for packages with the same name.
* Combines all conflicts from different detection sources (registry, python, extension)
* into a single result per package name.
* @param conflicts Array of conflict detection results
* @returns Array of merged conflict detection results
*/
function mergeConflictsByPackageName(
conflicts: ConflictDetectionResult[]
): ConflictDetectionResult[] {
const mergedMap = new Map<string, ConflictDetectionResult>()
conflicts.forEach((conflict) => {
const packageName = conflict.package_name
if (mergedMap.has(packageName)) {
// Package already exists, merge conflicts
const existing = mergedMap.get(packageName)!
// Combine all conflicts, avoiding duplicates using lodash uniqBy for O(n) performance
const allConflicts = [...existing.conflicts, ...conflict.conflicts]
const uniqueConflicts = uniqBy(
allConflicts,
(conflict) =>
`${conflict.type}|${conflict.current_value}|${conflict.required_value}`
)
// Update the existing entry
mergedMap.set(packageName, {
...existing,
conflicts: uniqueConflicts,
has_conflict: uniqueConflicts.length > 0,
is_compatible: uniqueConflicts.length === 0
})
} else {
// New package, add as-is
mergedMap.set(packageName, conflict)
}
})
return Array.from(mergedMap.values())
}
/**
* Extracts missing dependency name from Python error message.
* @param errorMsg Error message from Python import failure
* @returns Name of missing dependency
*/
function extractMissingDependency(errorMsg: string): string {
// Try to extract module name from common error patterns
// Pattern 1: "ModuleNotFoundError: No module named 'module_name'"
const moduleNotFoundMatch = errorMsg.match(/No module named '([^']+)'/)
if (moduleNotFoundMatch) {
return moduleNotFoundMatch[1]
}
// Pattern 2: "ImportError: cannot import name 'something' from 'module_name'"
const importErrorMatch = errorMsg.match(
/cannot import name '[^']+' from '([^']+)'/
)
if (importErrorMatch) {
return importErrorMatch[1]
}
// Pattern 3: "from module_name import something" in the traceback
const fromImportMatch = errorMsg.match(/from ([a-zA-Z_][a-zA-Z0-9_]*) import/)
if (fromImportMatch) {
return fromImportMatch[1]
}
// Pattern 4: "import module_name" in the traceback
const importMatch = errorMsg.match(/import ([a-zA-Z_][a-zA-Z0-9_]*)/)
if (importMatch) {
return importMatch[1]
}
// Fallback: return generic message
return 'unknown dependency'
}
/**
* Fetches frontend version from config.
* @returns Promise that resolves to frontend version string
@@ -876,51 +1310,10 @@ function checkVersionConflict(
const isCompatible = satisfiesVersion(cleanCurrent, supportedVersion)
if (!isCompatible) {
// Generate user-friendly description using shared utility
const rangeDescription = describeVersionRange(supportedVersion)
const description = `${type} version incompatible: requires ${rangeDescription}`
// Generate resolution steps based on version range type
let resolutionSteps: string[] = []
if (supportedVersion.startsWith('>=')) {
const minVersion = supportedVersion.substring(2).trim()
resolutionSteps = [
`Update ${type} to version ${minVersion} or higher`,
'Check release notes for compatibility changes'
]
} else if (supportedVersion.includes(' - ')) {
resolutionSteps = [
'Verify your version is within the supported range',
`Supported range: ${supportedVersion}`
]
} else if (supportedVersion.includes('||')) {
resolutionSteps = [
'Check which version ranges are supported',
`Supported: ${supportedVersion}`
]
} else if (
supportedVersion.startsWith('^') ||
supportedVersion.startsWith('~')
) {
resolutionSteps = [
`Compatible versions: ${supportedVersion}`,
'Consider updating or downgrading as needed'
]
} else {
resolutionSteps = [
`Required version: ${supportedVersion}`,
'Check if your version is compatible'
]
}
return {
type,
severity: 'warning',
description,
current_value: currentVersion,
required_value: supportedVersion,
resolution_steps: resolutionSteps
required_value: supportedVersion
}
}
@@ -932,14 +1325,8 @@ function checkVersionConflict(
)
return {
type,
severity: 'info',
description: `Unable to parse version requirement: ${supportedVersion}`,
current_value: currentVersion,
required_value: supportedVersion,
resolution_steps: [
'Check version format in Registry',
'Manually verify compatibility'
]
required_value: supportedVersion
}
}
}
@@ -957,11 +1344,8 @@ function checkOSConflict(
return {
type: 'os',
severity: 'error',
description: `Unsupported operating system`,
current_value: currentOS,
required_value: supportedOS.join(', '),
resolution_steps: ['Switch to supported OS', 'Find alternative package']
required_value: supportedOS.join(', ')
}
}
@@ -981,39 +1365,37 @@ function checkAcceleratorConflict(
return {
type: 'accelerator',
severity: 'error',
description: `Required GPU/accelerator not available`,
current_value: availableAccelerators.join(', '),
required_value: supportedAccelerators.join(', '),
resolution_steps: ['Install GPU drivers', 'Install CUDA/ROCm']
required_value: supportedAccelerators.join(', ')
}
}
/**
* Determines recommended action based on detected conflicts.
* Checks for banned package status conflicts.
*/
function determineRecommendedAction(
conflicts: ConflictDetail[]
): RecommendedAction {
if (conflicts.length === 0) {
function checkBannedStatus(isBanned?: boolean): ConflictDetail | null {
if (isBanned === true) {
return {
action_type: 'ignore',
reason: 'No conflicts detected',
steps: [],
estimated_difficulty: 'easy'
type: 'banned',
current_value: 'installed',
required_value: 'not_banned'
}
}
return null
}
const hasError = conflicts.some((c) => c.severity === 'error')
return {
action_type: hasError ? 'disable' : 'manual_review',
reason: hasError
? 'Critical compatibility issues found'
: 'Warning items need review',
steps: conflicts.flatMap((c) => c.resolution_steps || []),
estimated_difficulty: hasError ? 'hard' : 'medium'
/**
* Checks for security/registry data availability conflicts.
*/
function checkSecurityStatus(hasRegistryData?: boolean): ConflictDetail | null {
if (hasRegistryData === false) {
return {
type: 'security_pending',
current_value: 'no_registry_data',
required_value: 'registry_data_available'
}
}
return null
}
/**
@@ -1030,7 +1412,8 @@ function generateSummary(
os: 0,
accelerator: 0,
banned: 0,
security_pending: 0
security_pending: 0,
python_dependency: 0
}
const conflictsByTypeDetails: Record<ConflictType, string[]> = {
@@ -1040,7 +1423,8 @@ function generateSummary(
os: [],
accelerator: [],
banned: [],
security_pending: []
security_pending: [],
python_dependency: []
}
let bannedCount = 0
@@ -1088,7 +1472,8 @@ function getEmptySummary(): ConflictDetectionSummary {
os: [],
accelerator: [],
banned: [],
security_pending: []
security_pending: [],
python_dependency: []
},
last_check_timestamp: new Date().toISOString(),
check_duration_ms: 0

View File

@@ -109,6 +109,7 @@
"resultsCount": "Found {count} Results",
"status": "Status",
"description": "Description",
"warning": "Warning",
"name": "Name",
"category": "Category",
"sort": "Sort",
@@ -212,6 +213,34 @@
"nodePack": "Node Pack",
"enabled": "Enabled",
"disabled": "Disabled"
},
"conflicts": {
"title": "Conflicts Detected!",
"description": "Weve detected conflicts between some of your extensions and the new version of ComfyUI. By updating you risk breaking workflows that rely on those extensions.",
"info": "If you continue with the update, the conflicting extensions will be disabled automatically. You can review and manage them anytime in the ComfyUI Manager.",
"extensionAtRisk": "Extension at Risk",
"conflicts": "Conflicts",
"conflictInfoTitle": "Why is this happening?",
"installAnyway": "Install Anyway",
"enableAnyway": "Enable Anyway",
"understood": "Understood",
"warningBanner": {
"title": "Some extensions are disabled due to incompatibility with your current setup",
"message": "These extensions require versions of system packages that differ from your current setup. Installing them may override core dependencies and affect other extensions or workflows.",
"button": "Learn More..."
},
"conflictMessages": {
"comfyui_version": "ComfyUI version mismatch (current: {current}, required: {required})",
"frontend_version": "Frontend version mismatch (current: {current}, required: {required})",
"python_version": "Python version mismatch (current: {current}, required: {required})",
"os": "Operating system not supported (current: {current}, required: {required})",
"accelerator": "GPU/Accelerator not supported (available: {current}, required: {required})",
"generic": "Compatibility issue (current: {current}, required: {required})",
"banned": "This package is banned for security reasons",
"security_pending": "Security verification pending - compatibility cannot be verified",
"python_dependency": "Missing Python dependency: {required}"
},
"warningTooltip": "This package may have compatibility issues with your current environment"
}
},
"issueReport": {
@@ -503,6 +532,7 @@
"docs": "Docs",
"github": "Github",
"helpFeedback": "Help & Feedback",
"managerExtension": "Manager Extension",
"more": "More...",
"whatsNew": "What's New?",
"clickToLearnMore": "Click to learn more →",

View File

@@ -776,7 +776,6 @@
"Custom Nodes Manager": "自定义节点管理器",
"Decrease Brush Size in MaskEditor": "在 MaskEditor 中減小筆刷大小",
"Custom Nodes (Legacy)": "自定义节点(旧版)",
"Custom Nodes Manager": "自定义节点管理器",
"Delete Selected Items": "删除选定的项目",
"Desktop User Guide": "桌面端用户指南",
"Duplicate Current Workflow": "复制当前工作流",

View File

@@ -135,11 +135,17 @@ export const useComfyManagerService = () => {
)
}
const getImportFailInfo = async (signal?: AbortSignal) => {
const getImportFailInfo = async (
params: { cnr_id?: string; url?: string } = {},
signal?: AbortSignal
) => {
const errorContext = 'Fetching import failure information'
return executeRequest<any>(
() => managerApiClient.get(ManagerRoute.IMPORT_FAIL_INFO, { signal }),
() =>
managerApiClient.post(ManagerRoute.IMPORT_FAIL_INFO, params, {
signal
}),
{ errorContext }
)
}

View File

@@ -12,6 +12,9 @@ import TopUpCreditsDialogContent from '@/components/dialog/content/TopUpCreditsD
import UpdatePasswordContent from '@/components/dialog/content/UpdatePasswordContent.vue'
import ManagerDialogContent from '@/components/dialog/content/manager/ManagerDialogContent.vue'
import ManagerHeader from '@/components/dialog/content/manager/ManagerHeader.vue'
import NodeConflictDialogContent from '@/components/dialog/content/manager/NodeConflictDialogContent.vue'
import NodeConflictFooter from '@/components/dialog/content/manager/NodeConflictFooter.vue'
import NodeConflictHeader from '@/components/dialog/content/manager/NodeConflictHeader.vue'
import ManagerProgressFooter from '@/components/dialog/footer/ManagerProgressFooter.vue'
import ComfyOrgHeader from '@/components/dialog/header/ComfyOrgHeader.vue'
import ManagerProgressHeader from '@/components/dialog/header/ManagerProgressHeader.vue'
@@ -20,7 +23,11 @@ import TemplateWorkflowsContent from '@/components/templates/TemplateWorkflowsCo
import TemplateWorkflowsDialogHeader from '@/components/templates/TemplateWorkflowsDialogHeader.vue'
import { t } from '@/i18n'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import { type ShowDialogOptions, useDialogStore } from '@/stores/dialogStore'
import {
type DialogComponentProps,
type ShowDialogOptions,
useDialogStore
} from '@/stores/dialogStore'
export type ConfirmationDialogType =
| 'default'
@@ -424,6 +431,44 @@ export const useDialogService = () => {
}
}
function showNodeConflictDialog(
options: InstanceType<typeof NodeConflictDialogContent>['$props'] & {
dialogComponentProps?: DialogComponentProps
buttonText?: string
onButtonClick?: () => void
} = {}
) {
const { dialogComponentProps, buttonText, onButtonClick, ...props } =
options
return dialogStore.showDialog({
key: 'global-node-conflict',
headerComponent: NodeConflictHeader,
footerComponent: NodeConflictFooter,
component: NodeConflictDialogContent,
dialogComponentProps: {
closable: true,
pt: {
header: { class: '!p-0 !m-0' },
content: { class: '!p-0 overflow-y-hidden' },
footer: { class: '!p-0' },
pcCloseButton: {
root: {
class:
'!w-7 !h-7 !border-none !outline-none !p-2 !m-1.5 bg-gray-500 dark-theme:bg-neutral-700 text-white'
}
}
},
...dialogComponentProps
},
props,
footerProps: {
buttonText,
onButtonClick
}
})
}
return {
showLoadWorkflowWarning,
showMissingModelsWarning,
@@ -440,6 +485,7 @@ export const useDialogService = () => {
showTopUpCreditsDialog,
showUpdatePasswordDialog,
showExtensionDialog,
showNodeConflictDialog,
prompt,
confirm,
toggleManagerDialog,

View File

@@ -295,6 +295,21 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
return pack?.ver
}
/**
* Get installed pack by CNR ID or aux ID for O(1) lookup performance
* @param id - The CNR ID or aux ID to search for
* @returns The installed pack if found, undefined otherwise
*/
const getInstalledPackByCnrId = (id: string) => {
// Use cached Map for O(1) lookup instead of O(n) Object.values().find()
for (const pack of Object.values(installedPacks.value)) {
if (pack.cnr_id === id || pack.aux_id === id) {
return pack
}
}
return undefined
}
const clearLogs = () => {
taskLogs.value = []
}
@@ -332,6 +347,7 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
isPackInstalled: isInstalledPackId,
isPackEnabled: isEnabledPackId,
getInstalledPackVersion,
getInstalledPackByCnrId,
refreshInstalledList,
// Task queue state and actions

View File

@@ -0,0 +1,70 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type { ConflictDetectionResult } from '@/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 getConflictsForPackage = 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 === 'security_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,
getConflictsForPackage,
bannedPackages,
securityPendingPackages,
// Actions
setConflictedPackages,
clearConflicts,
setDetecting,
setLastDetectionTime
}
}
)

View File

@@ -30,7 +30,7 @@ interface CustomDialogComponentProps {
dismissableMask?: boolean
}
type DialogComponentProps = InstanceType<typeof GlobalDialog>['$props'] &
export type DialogComponentProps = InstanceType<typeof GlobalDialog>['$props'] &
CustomDialogComponentProps
interface DialogInstance {
@@ -41,6 +41,7 @@ interface DialogInstance {
component: Component
contentProps: Record<string, any>
footerComponent?: Component
footerProps?: Record<string, any>
dialogComponentProps: DialogComponentProps
priority: number
}
@@ -52,6 +53,7 @@ export interface ShowDialogOptions {
footerComponent?: Component
component: Component
props?: Record<string, any>
footerProps?: Record<string, any>
dialogComponentProps?: DialogComponentProps
/**
* Optional priority for dialog stacking.
@@ -125,6 +127,7 @@ export const useDialogStore = defineStore('dialog', () => {
footerComponent?: Component
component: Component
props?: Record<string, any>
footerProps?: Record<string, any>
dialogComponentProps?: DialogComponentProps
priority?: number
}) {
@@ -144,6 +147,7 @@ export const useDialogStore = defineStore('dialog', () => {
: undefined,
component: markRaw(options.component),
contentProps: { ...options.props },
footerProps: { ...options.footerProps },
priority: options.priority ?? 1,
dialogComponentProps: {
maximizable: false,

View File

@@ -15,6 +15,7 @@ export type ConflictType =
| 'accelerator' // GPU/accelerator incompatibility
| 'banned' // Banned package
| 'security_pending' // Security verification pending
| 'python_dependency' // Python module dependency missing
/**
* Security scan status for packages
@@ -147,10 +148,6 @@ export interface ConflictDetectionResult {
conflicts: ConflictDetail[]
/** @description Overall compatibility status */
is_compatible: boolean
/** @description Whether conflicts can be automatically resolved */
can_auto_resolve: boolean
/** @description Recommended action to resolve conflicts */
recommended_action: RecommendedAction
}
/**
@@ -159,30 +156,10 @@ export interface ConflictDetectionResult {
export interface ConflictDetail {
/** @description Type of conflict detected */
type: ConflictType
/** @description Severity level of the conflict */
severity: 'error' | 'warning' | 'info'
/** @description Human-readable description of the conflict */
description: string
/** @description Current system value */
current_value: string
/** @description Required value for compatibility */
required_value: string
/** @description Optional steps to resolve the conflict */
resolution_steps?: string[]
}
/**
* Recommended action to resolve conflicts
*/
export interface RecommendedAction {
/** @description Type of action to take */
action_type: 'disable' | 'update' | 'ignore' | 'manual_review'
/** @description Reason for the recommended action */
reason: string
/** @description Step-by-step instructions */
steps: string[]
/** @description Estimated difficulty of implementing the action */
estimated_difficulty: 'easy' | 'medium' | 'hard'
}
/**
@@ -207,24 +184,6 @@ export interface ConflictDetectionSummary {
check_duration_ms: number
}
/**
* API request/response interfaces
*/
/**
* Request payload for conflict detection API
*/
export interface ConflictDetectionRequest {
/** @description Current system environment information */
system_environment: SystemEnvironment
/** @description Optional list of specific package IDs to check */
package_ids?: string[]
/** @description Whether to include banned packages in the check */
include_banned?: boolean
/** @description Whether to include security-pending packages in the check */
include_security_pending?: boolean
}
/**
* Response payload from conflict detection API
*/
@@ -242,23 +201,3 @@ export interface ConflictDetectionResponse {
/** @description System environment information detected by the server (for comparison) */
detected_system_environment?: Partial<SystemEnvironment>
}
/**
* Real-time conflict detection event
*/
export interface ConflictDetectionEvent {
/** @description Type of event */
event_type:
| 'conflict_detected'
| 'conflict_resolved'
| 'scan_started'
| 'scan_completed'
/** @description Event timestamp */
timestamp: string
/** @description Package ID associated with the event, if applicable */
package_id?: string
/** @description Type of conflict, if applicable */
conflict_type?: ConflictType
/** @description Additional event details */
details?: string
}

View File

@@ -0,0 +1,66 @@
import type { ConflictDetail } from '@/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 === 'python_version' ||
conflict.type === 'os' ||
conflict.type === 'accelerator'
) {
return t(messageKey, {
current: conflict.current_value,
required: conflict.required_value
})
}
// For dependency conflicts, show the missing dependency
if (conflict.type === 'python_dependency') {
return t(messageKey, {
required: conflict.required_value
})
}
// For banned and security_pending, use simple message
if (conflict.type === 'banned' || conflict.type === 'security_pending') {
return t(messageKey)
}
// Fallback to generic message with interpolation
return t('manager.conflicts.conflictMessages.generic', {
current: conflict.current_value,
required: conflict.required_value
})
}
/**
* Generates conflict messages for multiple conflicts and joins them.
*
* @param conflicts Array of conflict details
* @param t The i18n translation function
* @param separator The separator to use when joining messages (default: '; ')
* @returns A single string with all conflict messages joined
*/
export function getJoinedConflictMessages(
conflicts: ConflictDetail[],
t: (key: string, params?: Record<string, any>) => string,
separator = '; '
): string {
return conflicts
.map((conflict) => getConflictMessage(conflict, t))
.join(separator)
}

View File

@@ -1,5 +1,10 @@
import * as semver from 'semver'
import type {
ConflictDetail,
ConflictType
} from '@/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")
@@ -53,28 +58,54 @@ export function isValidVersion(version: string): boolean {
}
/**
* Gets a human-readable description of a version range
* @param range Version range string
* @returns Description of what the range means
* 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 describeVersionRange(range: string): string {
if (range.startsWith('>=')) {
return `version ${range.substring(2)} or higher`
} else if (range.startsWith('>')) {
return `version higher than ${range.substring(1)}`
} else if (range.startsWith('<=')) {
return `version ${range.substring(2)} or lower`
} else if (range.startsWith('<')) {
return `version lower than ${range.substring(1)}`
} else if (range.startsWith('^')) {
return `compatible with version ${range.substring(1)}`
} else if (range.startsWith('~')) {
return `approximately version ${range.substring(1)}`
} else if (range.includes(' - ')) {
const [min, max] = range.split(' - ')
return `version between ${min} and ${max}`
} else if (range.includes('||')) {
return `one of multiple version ranges: ${range}`
export function checkVersionCompatibility(
type: ConflictType,
currentVersion: string,
supportedVersion: string
): ConflictDetail | null {
// If current version is unknown, assume compatible (no conflict)
if (!currentVersion || currentVersion === 'unknown') {
return null
}
return `version ${range}`
}
// If no version requirement specified, assume compatible (no conflict)
if (!supportedVersion || supportedVersion.trim() === '') {
return null
}
try {
// Clean the current version using semver utilities
const cleanCurrent = cleanVersion(currentVersion)
// Check version compatibility using semver library
const isCompatible = satisfiesVersion(cleanCurrent, supportedVersion)
if (!isCompatible) {
return {
type,
current_value: currentVersion,
required_value: supportedVersion
}
}
return null
} catch (error) {
console.warn(
`[VersionUtil] Failed to parse version requirement: ${supportedVersion}`,
error
)
// On error, assume incompatible to be safe
return {
type,
current_value: currentVersion,
required_value: supportedVersion
}
}
}