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

@@ -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>