Compare commits

..

6 Commits

Author SHA1 Message Date
Claude
d930514bea fix: incorporate Fuse search scores into template sorting
When searching templates, the Fuse.js relevance scores were being
discarded when any sort option other than 'default' was selected.
This caused templates with better search matches to be ranked lower
than templates with higher usage/popularity but worse search relevance.

Changes:
- Store Fuse search scores in a Map for use during sorting
- For 'recommended' sort with active search: weight search relevance
  at 60% and base recommendation score at 40%
- For 'popular' sort with active search: weight both equally at 50%
- For VRAM/size sorts: use search relevance as tiebreaker
- 'default' sort preserves Fuse's original relevance order
2026-01-08 20:01:41 +00:00
AustinMroz
99cb7a2da1 Fix linked asset widget promotion in vue (#7895)
Asset widgets resolve the list of models by checking the name of the
node the widget is contained on. When an asset widget is linked to a
subgraph node, a clone is made of the widget and then the clone is used
to initialize an asset widget in vue mode. Since the widget no longer
holds any form of reference to the original node, asset data fails to
resolve.

This is fixed by storing the original nodeType as an option on the
cloned widget when an asset widget is linked to a subgraph input.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/345f9cc1-da04-44ab-8fed-76379c8528de"/>
| <img width="360" alt="after"
src="https://github.com/user-attachments/assets/88d1ddaa-56fb-41b3-8d5d-0ded02aaa7d2"
/>|

See also #7563, #7560

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7895-Fix-linked-asset-widget-promotion-in-vue-2e26d73d365081e5b295f6236458b978)
by [Unito](https://www.unito.io)
2026-01-08 09:12:02 -08:00
Terry Jia
b3d87673ec feat: display label_on/label_off for boolean widgets in vueNodes mode (#7894)
## Summary

Add support for displaying custom on/off labels for boolean toggle
widgets, matching the behavior in litegraph mode.

## Screenshots
before - litegraph
<img width="1232" height="600" alt="image"
src="https://github.com/user-attachments/assets/aae91acd-4b6b-4a89-aded-c5445e352006"
/>
before - vueNodes
<img width="869" height="584" alt="image"
src="https://github.com/user-attachments/assets/a69dc71e-45f7-4941-911f-f037a2b1c5c2"
/>

after - vueNodes
<img width="1156" height="608" alt="image"
src="https://github.com/user-attachments/assets/818164a6-826b-4545-bc20-e01625f11d7d"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7894-feat-display-label_on-label_off-for-boolean-widgets-in-vueNodes-mode-2e26d73d365081a3b938c87dd4cf23aa)
by [Unito](https://www.unito.io)
2026-01-07 23:04:47 -05:00
Jin Yi
6a733918a7 Improve Import Failed Error Messages (#7871) 2026-01-07 18:54:01 -07:00
Terry Jia
a87d2cf1bd fix: use pre-bundled wwobjloader2 worker for production builds (#7879)
## Summary

The unbundled worker from 'wwobjloader2/worker' has ES module imports
that fail in production builds because Vite's ?url suffix doesn't bundle
dependencies.
Switch to 'wwobjloader2/bundle/worker/module' which is self-contained
with all dependencies included.

Fixes OBJ loading failing in production with Worker error events.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7879-fix-use-pre-bundled-wwobjloader2-worker-for-production-builds-2e16d73d365081f4a485c993852be1d3)
by [Unito](https://www.unito.io)
2026-01-07 20:46:22 -05:00
Terry Jia
a1d689d3b3 fix: wrap image preview navigation dots when overflowing node width (#7891)
## Summary

When Preview Image node has many images, the navigation dots would
overflow beyond the node boundaries. Adding flex-wrap ensures dots wrap
to multiple lines instead of overflowing.

## Screenshots
before
<img width="1175" height="1357" alt="image"
src="https://github.com/user-attachments/assets/1903ae13-c304-4c75-a947-aa879ef9c2e1"
/>

after
<img width="654" height="840" alt="image"
src="https://github.com/user-attachments/assets/37012379-b72f-4b7d-9355-08bac11b094b"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7891-fix-wrap-image-preview-navigation-dots-when-overflowing-node-width-2e26d73d36508130a5edf0a0d34f966c)
by [Unito](https://www.unito.io)
2026-01-07 20:42:44 -05:00
31 changed files with 715 additions and 366 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -22,7 +22,7 @@
<!-- OSS mode: Open Manager + Install All buttons -->
<div v-else-if="showManagerButtons" class="flex justify-end gap-1 py-2 px-4">
<Button variant="textonly" size="sm" @click="openManager">{{
<Button variant="textonly" @click="openManager">{{
$t('g.openManager')
}}</Button>
<PackInstallButton

View File

@@ -64,12 +64,12 @@ import { storeToRefs } from 'pinia'
import { computed, onMounted, toRefs } from 'vue'
import HelpCenterMenuContent from '@/components/helpcenter/HelpCenterMenuContent.vue'
import { useNodeConflictDialog } from '@/composables/useNodeConflictDialog'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import ReleaseNotificationToast from '@/platform/updates/components/ReleaseNotificationToast.vue'
import WhatsNewPopup from '@/platform/updates/components/WhatsNewPopup.vue'
import { useDialogService } from '@/services/dialogService'
import { useHelpCenterStore } from '@/stores/helpCenterStore'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
@@ -84,7 +84,7 @@ const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const conflictDetection = useConflictDetection()
const { show: showNodeConflictDialog } = useNodeConflictDialog()
const { showNodeConflictDialog } = useDialogService()
// Use conflict acknowledgment state from composable - call only once
const { shouldShowRedDot: shouldShowConflictRedDot, markConflictsAsSeen } =

View File

@@ -1,29 +0,0 @@
import MissingNodesContent from '@/components/dialog/content/MissingNodesContent.vue'
import MissingNodesFooter from '@/components/dialog/content/MissingNodesFooter.vue'
import MissingNodesHeader from '@/components/dialog/content/MissingNodesHeader.vue'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import type { ComponentProps } from 'vue-component-type-helpers'
const DIALOG_KEY = 'global-missing-nodes'
export const useMissingNodesDialog = () => {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show(props: ComponentProps<typeof MissingNodesContent>) {
dialogService.showSmallDialog({
key: DIALOG_KEY,
headerComponent: MissingNodesHeader,
footerComponent: MissingNodesFooter,
component: MissingNodesContent,
props
})
}
return { show, hide }
}

View File

@@ -1,48 +0,0 @@
import NodeConflictDialogContent from '@/workbench/extensions/manager/components/manager/NodeConflictDialogContent.vue'
import NodeConflictFooter from '@/workbench/extensions/manager/components/manager/NodeConflictFooter.vue'
import NodeConflictHeader from '@/workbench/extensions/manager/components/manager/NodeConflictHeader.vue'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import type { DialogComponentProps } from '@/stores/dialogStore'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
const DIALOG_KEY = 'global-node-conflict'
export const useNodeConflictDialog = () => {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show(
options: {
showAfterWhatsNew?: boolean
conflictedPackages?: ConflictDetectionResult[]
dialogComponentProps?: DialogComponentProps
buttonText?: string
onButtonClick?: () => void
} = {}
) {
const { buttonText, onButtonClick, showAfterWhatsNew, conflictedPackages } =
options
return dialogService.showSmallDialog({
key: DIALOG_KEY,
headerComponent: NodeConflictHeader,
footerComponent: NodeConflictFooter,
component: NodeConflictDialogContent,
props: {
showAfterWhatsNew,
conflictedPackages
},
footerProps: {
buttonText,
onButtonClick
}
})
}
return { show, hide }
}

View File

@@ -272,4 +272,108 @@ describe('useTemplateFiltering', () => {
'beta-pro'
])
})
it('incorporates search relevance into recommended sorting', async () => {
vi.useFakeTimers()
const templates = ref<TemplateInfo[]>([
{
name: 'wan-video-exact',
title: 'Wan Video Template',
description: 'A template with Wan in title',
mediaType: 'image',
mediaSubtype: 'png',
date: '2024-01-01',
usage: 10
},
{
name: 'qwen-image-partial',
title: 'Qwen Image Editor',
description: 'A template that contains w, a, n scattered',
mediaType: 'image',
mediaSubtype: 'png',
date: '2024-01-01',
usage: 1000 // Higher usage but worse search match
},
{
name: 'wan-text-exact',
title: 'Wan2.5: Text to Image',
description: 'Another exact match for Wan',
mediaType: 'image',
mediaSubtype: 'png',
date: '2024-01-01',
usage: 50
}
])
const { searchQuery, sortBy, filteredTemplates } =
useTemplateFiltering(templates)
// Search for "Wan"
searchQuery.value = 'Wan'
sortBy.value = 'recommended'
await nextTick()
await vi.runOnlyPendingTimersAsync()
await nextTick()
// Templates with "Wan" in title should rank higher than Qwen despite lower usage
// because search relevance is now factored into the recommended sort
const results = filteredTemplates.value.map((t) => t.name)
// Verify exact matches appear (Qwen might be filtered out by threshold)
expect(results).toContain('wan-video-exact')
expect(results).toContain('wan-text-exact')
// If Qwen appears, it should be ranked lower than exact matches
if (results.includes('qwen-image-partial')) {
const wanIndex = results.indexOf('wan-video-exact')
const qwenIndex = results.indexOf('qwen-image-partial')
expect(wanIndex).toBeLessThan(qwenIndex)
}
vi.useRealTimers()
})
it('preserves Fuse search order when using default sort', async () => {
vi.useFakeTimers()
const templates = ref<TemplateInfo[]>([
{
name: 'portrait-basic',
title: 'Basic Portrait',
description: 'A basic template',
mediaType: 'image',
mediaSubtype: 'png'
},
{
name: 'portrait-pro',
title: 'Portrait Pro Edition',
description: 'Advanced portrait features',
mediaType: 'image',
mediaSubtype: 'png'
},
{
name: 'landscape-view',
title: 'Landscape Generator',
description: 'Generate landscapes',
mediaType: 'image',
mediaSubtype: 'png'
}
])
const { searchQuery, sortBy, filteredTemplates } =
useTemplateFiltering(templates)
searchQuery.value = 'Portrait Pro'
sortBy.value = 'default'
await nextTick()
await vi.runOnlyPendingTimersAsync()
await nextTick()
const results = filteredTemplates.value.map((t) => t.name)
// With default sort, Fuse's relevance ordering is preserved
// "Portrait Pro Edition" should be first as it's the best match
expect(results[0]).toBe('portrait-pro')
})
})

View File

@@ -82,13 +82,31 @@ export function useTemplateFiltering(
const debouncedSearchQuery = refDebounced(searchQuery, 50)
const filteredBySearch = computed(() => {
// Store Fuse search results with scores for use in sorting
const fuseSearchResults = computed(() => {
if (!debouncedSearchQuery.value.trim()) {
return null
}
return fuse.value.search(debouncedSearchQuery.value)
})
// Map of template name to search score (lower is better in Fuse, 0 = perfect match)
const searchScoreMap = computed(() => {
const map = new Map<string, number>()
if (fuseSearchResults.value) {
fuseSearchResults.value.forEach((result) => {
// Store the score (0 = perfect match, 1 = worst match)
map.set(result.item.name, result.score ?? 1)
})
}
return map
})
const filteredBySearch = computed(() => {
if (!fuseSearchResults.value) {
return templatesArray.value
}
const results = fuse.value.search(debouncedSearchQuery.value)
return results.map((result) => result.item)
return fuseSearchResults.value.map((result) => result.item)
})
const filteredByModels = computed(() => {
@@ -165,31 +183,66 @@ export function useTemplateFiltering(
{ immediate: true }
)
// Helper to get search relevance score (higher is better, 0-1 range)
// Fuse returns scores where 0 = perfect match, 1 = worst match
// We invert it so higher = better for combining with other scores
const getSearchRelevance = (template: TemplateInfo): number => {
const fuseScore = searchScoreMap.value.get(template.name)
if (fuseScore === undefined) return 0 // Not in search results or no search
return 1 - fuseScore // Invert: 0 (worst) -> 1 (best)
}
const hasActiveSearch = computed(
() => debouncedSearchQuery.value.trim() !== ''
)
const sortedTemplates = computed(() => {
const templates = [...filteredByRunsOn.value]
switch (sortBy.value) {
case 'recommended':
// Curated: usage × 0.5 + internal × 0.3 + freshness × 0.2
// When searching, heavily weight search relevance
// Formula with search: searchRelevance × 0.6 + (usage × 0.5 + internal × 0.3 + freshness × 0.2) × 0.4
// Formula without search: usage × 0.5 + internal × 0.3 + freshness × 0.2
return templates.sort((a, b) => {
const scoreA = rankingStore.computeDefaultScore(
const baseScoreA = rankingStore.computeDefaultScore(
a.date,
a.searchRank,
a.usage
)
const scoreB = rankingStore.computeDefaultScore(
const baseScoreB = rankingStore.computeDefaultScore(
b.date,
b.searchRank,
b.usage
)
return scoreB - scoreA
if (hasActiveSearch.value) {
const searchA = getSearchRelevance(a)
const searchB = getSearchRelevance(b)
const finalA = searchA * 0.6 + baseScoreA * 0.4
const finalB = searchB * 0.6 + baseScoreB * 0.4
return finalB - finalA
}
return baseScoreB - baseScoreA
})
case 'popular':
// User-driven: usage × 0.9 + freshness × 0.1
// When searching, include search relevance
// Formula with search: searchRelevance × 0.5 + (usage × 0.9 + freshness × 0.1) × 0.5
// Formula without search: usage × 0.9 + freshness × 0.1
return templates.sort((a, b) => {
const scoreA = rankingStore.computePopularScore(a.date, a.usage)
const scoreB = rankingStore.computePopularScore(b.date, b.usage)
return scoreB - scoreA
const baseScoreA = rankingStore.computePopularScore(a.date, a.usage)
const baseScoreB = rankingStore.computePopularScore(b.date, b.usage)
if (hasActiveSearch.value) {
const searchA = getSearchRelevance(a)
const searchB = getSearchRelevance(b)
const finalA = searchA * 0.5 + baseScoreA * 0.5
const finalB = searchB * 0.5 + baseScoreB * 0.5
return finalB - finalA
}
return baseScoreB - baseScoreA
})
case 'alphabetical':
return templates.sort((a, b) => {
@@ -209,6 +262,12 @@ export function useTemplateFiltering(
const vramB = getVramMetric(b)
if (vramA === vramB) {
// Use search relevance as tiebreaker when searching
if (hasActiveSearch.value) {
const searchA = getSearchRelevance(a)
const searchB = getSearchRelevance(b)
if (searchA !== searchB) return searchB - searchA
}
const nameA = a.title || a.name || ''
const nameB = b.title || b.name || ''
return nameA.localeCompare(nameB)
@@ -225,11 +284,20 @@ export function useTemplateFiltering(
typeof a.size === 'number' ? a.size : Number.POSITIVE_INFINITY
const sizeB =
typeof b.size === 'number' ? b.size : Number.POSITIVE_INFINITY
if (sizeA === sizeB) return 0
if (sizeA === sizeB) {
// Use search relevance as tiebreaker when searching
if (hasActiveSearch.value) {
const searchA = getSearchRelevance(a)
const searchB = getSearchRelevance(b)
if (searchA !== searchB) return searchB - searchA
}
return 0
}
return sizeA - sizeB
})
case 'default':
default:
// 'default' preserves Fuse's search order (already sorted by relevance)
return templates
}
})

View File

@@ -6,7 +6,9 @@ import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { MtlObjBridge, OBJLoader2Parallel } from 'wwobjloader2'
import OBJLoader2WorkerUrl from 'wwobjloader2/worker?url'
// Use pre-bundled worker module (has all dependencies included)
// The unbundled 'wwobjloader2/worker' has ES imports that fail in production builds
import OBJLoader2WorkerUrl from 'wwobjloader2/bundle/worker/module?url'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'

View File

@@ -28,6 +28,7 @@ import type {
} from '@/lib/litegraph/src/types/serialisation'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { AssetWidget } from '@/lib/litegraph/src/widgets/AssetWidget'
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
@@ -333,6 +334,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const promotedWidget = toConcreteWidget(widget, this).createCopyForNode(
this
)
if (widget instanceof AssetWidget)
promotedWidget.options.nodeType ??= widget.node.type
Object.assign(promotedWidget, {
get name() {

View File

@@ -27,6 +27,8 @@ export interface IWidgetOptions<TValues = unknown[]> {
socketless?: boolean
/** If `true`, the widget will not be rendered by the Vue renderer. */
canvasOnly?: boolean
/** Used as a temporary override for determining the asset type in vue mode*/
nodeType?: string
values?: TValues
/** Optional function to format values for display (e.g., hash → human-readable name) */

View File

@@ -378,6 +378,10 @@
"warningTooltip": "This package may have compatibility issues with your current environment"
}
},
"importFailed": {
"title": "Import Failed",
"copyError": "Copy Error"
},
"issueReport": {
"helpFix": "Help Fix This"
},

View File

@@ -99,7 +99,10 @@
</span>
</div>
<!-- Multiple Images Navigation -->
<div v-if="hasMultipleImages" class="flex justify-center gap-1 pt-4">
<div
v-if="hasMultipleImages"
class="flex flex-wrap justify-center gap-1 pt-4"
>
<button
v-for="(_, index) in imageUrls"
:key="index"

View File

@@ -60,8 +60,9 @@ const combinedProps = computed(() => ({
}))
const getAssetData = () => {
if (props.isAssetMode && props.nodeType) {
return useAssetWidgetData(toRef(() => props.nodeType))
const nodeType = props.widget.options?.nodeType ?? props.nodeType
if (props.isAssetMode && nodeType) {
return useAssetWidgetData(toRef(nodeType))
}
return null
}

View File

@@ -1,9 +1,9 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import ToggleSwitch from 'primevue/toggleswitch'
import type { ToggleSwitchProps } from 'primevue/toggleswitch'
import { describe, expect, it } from 'vitest'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetToggleSwitch from './WidgetToggleSwitch.vue'
@@ -11,9 +11,9 @@ import WidgetToggleSwitch from './WidgetToggleSwitch.vue'
describe('WidgetToggleSwitch Value Binding', () => {
const createMockWidget = (
value: boolean = false,
options: Partial<ToggleSwitchProps> = {},
options: IWidgetOptions = {},
callback?: (value: boolean) => void
): SimplifiedWidget<boolean> => ({
): SimplifiedWidget<boolean, IWidgetOptions> => ({
name: 'test_toggle',
type: 'boolean',
value,
@@ -149,4 +149,47 @@ describe('WidgetToggleSwitch Value Binding', () => {
expect(emitted![3]).toContain(false)
})
})
describe('Label Display (label_on/label_off)', () => {
it('displays label_on when value is true', () => {
const widget = createMockWidget(true, { on: 'inside', off: 'outside' })
const wrapper = mountComponent(widget, true)
expect(wrapper.text()).toContain('inside')
})
it('displays label_off when value is false', () => {
const widget = createMockWidget(false, { on: 'inside', off: 'outside' })
const wrapper = mountComponent(widget, false)
expect(wrapper.text()).toContain('outside')
})
it('does not display label when no on/off options provided', () => {
const widget = createMockWidget(false, {})
const wrapper = mountComponent(widget, false)
expect(wrapper.find('span').exists()).toBe(false)
})
it('updates label when value changes', async () => {
const widget = createMockWidget(false, { on: 'enabled', off: 'disabled' })
const wrapper = mountComponent(widget, false)
expect(wrapper.text()).toContain('disabled')
await wrapper.setProps({ modelValue: true })
expect(wrapper.text()).toContain('enabled')
})
it('falls back to true/false when only partial options provided', () => {
const widgetOnOnly = createMockWidget(true, { on: 'active' })
const wrapperOn = mountComponent(widgetOnOnly, true)
expect(wrapperOn.text()).toContain('active')
const widgetOffOnly = createMockWidget(false, { off: 'inactive' })
const wrapperOff = mountComponent(widgetOffOnly, false)
expect(wrapperOff.text()).toContain('inactive')
})
})
})

View File

@@ -1,11 +1,25 @@
<template>
<WidgetLayoutField :widget>
<ToggleSwitch
v-model="modelValue"
v-bind="filteredProps"
class="ml-auto block"
:aria-label="widget.name"
/>
<div class="ml-auto flex w-fit items-center gap-2">
<span
v-if="stateLabel"
:class="
cn(
'text-sm transition-colors',
modelValue
? 'text-node-component-slot-text'
: 'text-node-component-slot-text/50'
)
"
>
{{ stateLabel }}
</span>
<ToggleSwitch
v-model="modelValue"
v-bind="filteredProps"
:aria-label="widget.name"
/>
</div>
</WidgetLayoutField>
</template>
@@ -13,7 +27,9 @@
import ToggleSwitch from 'primevue/toggleswitch'
import { computed } from 'vue'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
@@ -22,7 +38,7 @@ import {
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const { widget } = defineProps<{
widget: SimplifiedWidget<boolean>
widget: SimplifiedWidget<boolean, IWidgetOptions>
}>()
const modelValue = defineModel<boolean>()
@@ -30,4 +46,10 @@ const modelValue = defineModel<boolean>()
const filteredProps = computed(() =>
filterWidgetProps(widget.options, STANDARD_EXCLUDED_PROPS)
)
const stateLabel = computed(() => {
const options = widget.options
if (!options?.on && !options?.off) return null
return modelValue.value ? (options.on ?? 'true') : (options.off ?? 'false')
})
</script>

View File

@@ -46,7 +46,6 @@ import {
ComponentWidgetImpl,
DOMWidgetImpl
} from '@/scripts/domWidget'
import { useMissingNodesDialog } from '@/composables/useMissingNodesDialog'
import { useDialogService } from '@/services/dialogService'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useExtensionService } from '@/services/extensionService'
@@ -1030,7 +1029,7 @@ export class ComfyApp {
private showMissingNodesError(missingNodeTypes: MissingNodeType[]) {
if (useSettingStore().get('Comfy.Workflow.ShowMissingNodesWarning')) {
useMissingNodesDialog().show({ missingNodeTypes })
useDialogService().showLoadWorkflowWarning({ missingNodeTypes })
}
}

View File

@@ -2,6 +2,9 @@ import { merge } from 'es-toolkit/compat'
import type { Component } from 'vue'
import ApiNodesSignInContent from '@/components/dialog/content/ApiNodesSignInContent.vue'
import MissingNodesContent from '@/components/dialog/content/MissingNodesContent.vue'
import MissingNodesFooter from '@/components/dialog/content/MissingNodesFooter.vue'
import MissingNodesHeader from '@/components/dialog/content/MissingNodesHeader.vue'
import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue'
import ErrorDialogContent from '@/components/dialog/content/ErrorDialogContent.vue'
import MissingModelsWarning from '@/components/dialog/content/MissingModelsWarning.vue'
@@ -27,7 +30,14 @@ import ManagerProgressFooter from '@/workbench/extensions/manager/components/Man
import ManagerProgressHeader from '@/workbench/extensions/manager/components/ManagerProgressHeader.vue'
import ManagerDialogContent from '@/workbench/extensions/manager/components/manager/ManagerDialogContent.vue'
import ManagerHeader from '@/workbench/extensions/manager/components/manager/ManagerHeader.vue'
import type { ComponentAttrs, ComponentProps } from 'vue-component-type-helpers'
import ImportFailedNodeContent from '@/workbench/extensions/manager/components/manager/ImportFailedNodeContent.vue'
import ImportFailedNodeFooter from '@/workbench/extensions/manager/components/manager/ImportFailedNodeFooter.vue'
import ImportFailedNodeHeader from '@/workbench/extensions/manager/components/manager/ImportFailedNodeHeader.vue'
import NodeConflictDialogContent from '@/workbench/extensions/manager/components/manager/NodeConflictDialogContent.vue'
import NodeConflictFooter from '@/workbench/extensions/manager/components/manager/NodeConflictFooter.vue'
import NodeConflictHeader from '@/workbench/extensions/manager/components/manager/NodeConflictHeader.vue'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import type { ComponentAttrs } from 'vue-component-type-helpers'
export type ConfirmationDialogType =
| 'default'
@@ -40,6 +50,32 @@ export type ConfirmationDialogType =
export const useDialogService = () => {
const dialogStore = useDialogStore()
function showLoadWorkflowWarning(
props: ComponentAttrs<typeof MissingNodesContent>
) {
dialogStore.showDialog({
key: 'global-missing-nodes',
headerComponent: MissingNodesHeader,
footerComponent: MissingNodesFooter,
component: MissingNodesContent,
dialogComponentProps: {
closable: true,
pt: {
root: { class: 'bg-base-background border-border-default' },
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'
}
}
}
},
props
})
}
function showMissingModelsWarning(
props: ComponentAttrs<typeof MissingModelsWarning>
) {
@@ -417,44 +453,6 @@ export const useDialogService = () => {
}
}
function showSmallDialog<T extends Component>(options: {
key: string
component: T
headerComponent?: Component
footerComponent?: Component
props?: ComponentProps<T>
footerProps?: Record<string, unknown>
dialogComponentProps?: DialogComponentProps
}) {
const smallDialogDefaultProps: DialogComponentProps = {
closable: true,
pt: {
root: { class: 'bg-base-background border-border-default' },
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'
}
}
}
}
return dialogStore.showDialog({
key: options.key,
component: options.component,
headerComponent: options.headerComponent,
footerComponent: options.footerComponent,
props: options.props,
footerProps: options.footerProps,
dialogComponentProps: merge(
smallDialogDefaultProps,
options.dialogComponentProps || {}
)
})
}
function showLayoutDialog(options: {
key: string
component: Component
@@ -487,6 +485,91 @@ export const useDialogService = () => {
})
}
function showImportFailedNodeDialog(
options: {
conflictedPackages?: ConflictDetectionResult[]
dialogComponentProps?: DialogComponentProps
} = {}
) {
const { dialogComponentProps, conflictedPackages } = options
return dialogStore.showDialog({
key: 'global-import-failed',
headerComponent: ImportFailedNodeHeader,
footerComponent: ImportFailedNodeFooter,
component: ImportFailedNodeContent,
dialogComponentProps: {
closable: true,
pt: {
root: { class: 'bg-base-background border-border-default' },
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'
}
}
},
...dialogComponentProps
},
props: {
conflictedPackages: conflictedPackages ?? []
},
footerProps: {
conflictedPackages: conflictedPackages ?? []
}
})
}
function showNodeConflictDialog(
options: {
showAfterWhatsNew?: boolean
conflictedPackages?: ConflictDetectionResult[]
dialogComponentProps?: DialogComponentProps
buttonText?: string
onButtonClick?: () => void
} = {}
) {
const {
dialogComponentProps,
buttonText,
onButtonClick,
showAfterWhatsNew,
conflictedPackages
} = 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-dialog-surface text-white'
}
}
},
...dialogComponentProps
},
props: {
showAfterWhatsNew,
conflictedPackages
},
footerProps: {
buttonText,
onButtonClick
}
})
}
async function showSubscriptionRequiredDialog() {
if (!isCloud || !window.__CONFIG__?.subscription_required) {
return
@@ -499,6 +582,7 @@ export const useDialogService = () => {
}
return {
showLoadWorkflowWarning,
showMissingModelsWarning,
showSettingsDialog,
showAboutDialog,
@@ -516,7 +600,8 @@ export const useDialogService = () => {
confirm,
toggleManagerDialog,
toggleManagerProgressDialog,
showSmallDialog,
showLayoutDialog
showLayoutDialog,
showImportFailedNodeDialog,
showNodeConflictDialog
}
}

View File

@@ -215,19 +215,12 @@ export const useDialogStore = defineStore('dialog', () => {
F extends Component = Component
>(options: ShowDialogOptions<H, B, F>) {
const dialogKey = options.key || genDialogKey()
const existingIndex = dialogStack.value.findIndex(
(d) => d.key === dialogKey
)
let dialog =
existingIndex !== -1 ? dialogStack.value[existingIndex] : undefined
let dialog = dialogStack.value.find((d) => d.key === dialogKey)
if (dialog) {
if (!dialog.visible) {
dialogStack.value.splice(existingIndex, 1)
dialog = createDialog({ ...options, key: dialogKey })
} else {
dialog.visible = true
riseDialog(dialog)
}
dialog.visible = true
riseDialog(dialog)
} else {
dialog = createDialog({ ...options, key: dialogKey })
}

View File

@@ -0,0 +1,70 @@
<template>
<div class="flex w-[490px] flex-col border-t-1 border-border-default">
<div class="flex h-full w-full flex-col gap-4 p-4">
<!-- Error Details -->
<div v-if="importFailedPackages.length > 0" class="flex flex-col gap-3">
<div
v-for="pkg in importFailedPackages"
:key="pkg.packageId"
class="flex flex-col gap-2 max-h-60 overflow-x-hidden overflow-y-auto scrollbar-custom"
role="region"
:aria-label="`Error traceback for ${pkg.packageId}`"
tabindex="0"
>
<!-- Error Message -->
<div
v-if="pkg.traceback || pkg.errorMessage"
class="text-xs p-4 rounded-md bg-secondary-background font-mono"
>
{{ pkg.traceback || pkg.errorMessage }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
const { conflictedPackages } = defineProps<{
conflictedPackages: ConflictDetectionResult[]
}>()
interface ImportFailedPackage {
packageId: string
packageName: string
errorMessage: string
traceback: string
}
const importFailedPackages = computed((): ImportFailedPackage[] => {
return conflictedPackages
.filter((pkg) =>
pkg.conflicts.some((conflict) => conflict.type === 'import_failed')
)
.map((pkg) => {
const importFailedConflict = pkg.conflicts.find(
(conflict) => conflict.type === 'import_failed'
)
if (!importFailedConflict) {
return {
packageId: pkg.package_id,
packageName: pkg.package_name,
errorMessage: 'Unknown import error',
traceback: ''
}
}
return {
packageId: pkg.package_id,
packageName: pkg.package_name,
errorMessage:
importFailedConflict.current_value || 'Unknown import error',
traceback: importFailedConflict.required_value || ''
}
})
})
</script>

View File

@@ -0,0 +1,43 @@
<template>
<div class="flex w-full items-center justify-between px-3 pb-4">
<div class="flex w-full items-start justify-end gap-2 pr-1">
<Button variant="secondary" @click="handleCopyError">
{{ $t('importFailed.copyError') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
const { conflictedPackages = [] } = defineProps<{
conflictedPackages?: ConflictDetectionResult[]
}>()
const { copyToClipboard } = useCopyToClipboard()
const formatErrorText = computed(() => {
const errorParts: string[] = []
conflictedPackages.forEach((pkg) => {
const importFailedConflict = pkg.conflicts.find(
(conflict) => conflict.type === 'import_failed'
)
if (importFailedConflict?.required_value) {
errorParts.push(importFailedConflict.required_value)
}
})
return errorParts.join('\n\n')
})
const handleCopyError = () => {
copyToClipboard(formatErrorText.value)
}
</script>

View File

@@ -0,0 +1,12 @@
<template>
<div class="flex w-full items-center justify-between p-4">
<div class="flex items-center gap-2">
<i class="icon-[lucide--triangle-alert] text-gold-600"></i>
<p class="m-0 text-sm">
{{ $t('importFailed.title') }}
</p>
</div>
</div>
</template>
<script setup lang="ts"></script>

View File

@@ -185,25 +185,18 @@ describe('NodeConflictDialogContent', () => {
const wrapper = createWrapper()
// Import Failed Extensions section should show 1 package
const sections = wrapper.findAll('.bg-secondary-background')
expect(sections.length).toBeGreaterThan(0)
const importFailedSection = sections.find((section) =>
section.text().includes('Import Failed Extensions')
)
expect(importFailedSection).toBeDefined()
expect(importFailedSection!.text()).toContain('1')
expect(importFailedSection!.text()).toContain('Import Failed Extensions')
const importFailedSection = wrapper.findAll(
'.w-full.flex.flex-col.bg-base-background'
)[0]
expect(importFailedSection.text()).toContain('1')
expect(importFailedSection.text()).toContain('Import Failed Extensions')
// Conflicts section should show 3 conflicts (excluding import_failed)
const conflictsSection = sections.find(
(section) =>
section.text().includes('Conflicts') &&
!section.text().includes('Import Failed')
)
expect(conflictsSection).toBeDefined()
expect(conflictsSection!.text()).toContain('3')
expect(conflictsSection!.text()).toContain('Conflicts')
const conflictsSection = wrapper.findAll(
'.w-full.flex.flex-col.bg-base-background'
)[1]
expect(conflictsSection.text()).toContain('3')
expect(conflictsSection.text()).toContain('Conflicts')
})
})
@@ -215,14 +208,10 @@ describe('NodeConflictDialogContent', () => {
it('should toggle import failed panel', async () => {
const wrapper = createWrapper()
// Find import failed panel header - look for the one containing "Import Failed Extensions"
const headers = wrapper.findAll(
// Find import failed panel header (first one)
const importFailedHeader = wrapper.find(
'[data-testid="conflict-dialog-panel-toggle"]'
)
const importFailedHeader = headers.find((header) =>
header.text().includes('Import Failed Extensions')
)
expect(importFailedHeader).toBeDefined()
// Initially collapsed
expect(
@@ -230,7 +219,7 @@ describe('NodeConflictDialogContent', () => {
).toBe(false)
// Click to expand import failed panel
await importFailedHeader!.trigger('click')
await importFailedHeader.trigger('click')
// Should be expanded now and show package name
const expandedContent = wrapper.find(
@@ -247,46 +236,34 @@ describe('NodeConflictDialogContent', () => {
it('should toggle conflicts panel', async () => {
const wrapper = createWrapper()
// Find conflicts panel header - look for the one containing "Conflicts"
const headers = wrapper.findAll(
// Find conflicts panel header (second one)
const conflictsHeader = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)
const conflictsHeader = headers.find(
(header) =>
header.text().includes('Conflicts') &&
!header.text().includes('Import Failed')
)
expect(conflictsHeader).toBeDefined()
)[1]
// Click to expand conflicts panel
await conflictsHeader!.trigger('click')
await conflictsHeader.trigger('click')
// Should be expanded now - look for the expanded content
const expandedPanels = wrapper.findAll(
'[data-testid="conflict-dialog-panel-expanded"]'
)
expect(expandedPanels.length).toBeGreaterThan(0)
// Should be expanded now
const conflictItems = wrapper.findAll('[aria-label*="Conflict:"]')
expect(conflictItems.length).toBeGreaterThan(0)
})
it('should toggle extensions panel', async () => {
const wrapper = createWrapper()
// Find extensions panel header - look for the one containing "Extensions at Risk"
const headers = wrapper.findAll(
// Find extensions panel header (third one)
const extensionsHeader = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)
const extensionsHeader = headers.find((header) =>
header.text().includes('Extensions at Risk')
)
expect(extensionsHeader).toBeDefined()
)[2]
// Click to expand extensions panel
await extensionsHeader!.trigger('click')
await extensionsHeader.trigger('click')
// Should be expanded now and show all package names
const expandedContent = wrapper.find(
const expandedContent = wrapper.findAll(
'[data-testid="conflict-dialog-panel-expanded"]'
)
)[0]
expect(expandedContent.exists()).toBe(true)
expect(expandedContent.text()).toContain('Test Package 1')
expect(expandedContent.text()).toContain('Test Package 2')
@@ -296,27 +273,18 @@ describe('NodeConflictDialogContent', () => {
it('should collapse other panels when opening one', async () => {
const wrapper = createWrapper()
const headers = wrapper.findAll(
const importFailedHeader = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)
const importFailedHeader = headers.find((header) =>
header.text().includes('Import Failed Extensions')
)
const conflictsHeader = headers.find(
(header) =>
header.text().includes('Conflicts') &&
!header.text().includes('Import Failed')
)
const extensionsHeader = headers.find((header) =>
header.text().includes('Extensions at Risk')
)
expect(importFailedHeader).toBeDefined()
expect(conflictsHeader).toBeDefined()
expect(extensionsHeader).toBeDefined()
)[0]
const conflictsHeader = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)[1]
const extensionsHeader = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)[2]
// Open import failed panel first
await importFailedHeader!.trigger('click')
await importFailedHeader.trigger('click')
// Verify import failed panel is open
expect((wrapper.vm as any).importFailedExpanded).toBe(true)
@@ -324,7 +292,7 @@ describe('NodeConflictDialogContent', () => {
expect((wrapper.vm as any).extensionsExpanded).toBe(false)
// Open conflicts panel
await conflictsHeader!.trigger('click')
await conflictsHeader.trigger('click')
// Verify conflicts panel is open and others are closed
expect((wrapper.vm as any).importFailedExpanded).toBe(false)
@@ -332,7 +300,7 @@ describe('NodeConflictDialogContent', () => {
expect((wrapper.vm as any).extensionsExpanded).toBe(false)
// Open extensions panel
await extensionsHeader!.trigger('click')
await extensionsHeader.trigger('click')
// Verify extensions panel is open and others are closed
expect((wrapper.vm as any).importFailedExpanded).toBe(false)
@@ -349,50 +317,30 @@ describe('NodeConflictDialogContent', () => {
it('should display individual conflict details excluding import_failed', async () => {
const wrapper = createWrapper()
// Expand conflicts panel - find the one containing "Conflicts"
const headers = wrapper.findAll(
// Expand conflicts panel (second header)
const conflictsHeader = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)
const conflictsHeader = headers.find(
(header) =>
header.text().includes('Conflicts') &&
!header.text().includes('Import Failed')
)
expect(conflictsHeader).toBeDefined()
await conflictsHeader!.trigger('click')
)[1]
await conflictsHeader.trigger('click')
// Should display conflict messages (excluding import_failed)
// Look for the expanded panel content that contains conflict items
const expandedPanel = wrapper.find(
'[data-testid="conflict-dialog-panel-expanded"]'
)
expect(expandedPanel.exists()).toBe(true)
// Check that it contains the expected conflict details
const conflictElements = expandedPanel.findAll('.flex.h-6')
expect(conflictElements).toHaveLength(3) // 2 from Package1 + 1 from Package2
const conflictItems = wrapper.findAll('[aria-label*="Conflict:"]')
expect(conflictItems).toHaveLength(3) // 2 from Package1 + 1 from Package2
})
it('should display import failed packages separately', async () => {
const wrapper = createWrapper()
// Expand import failed panel - find the one containing "Import Failed Extensions"
const headers = wrapper.findAll(
// Expand import failed panel (first header)
const importFailedHeader = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)
const importFailedHeader = headers.find((header) =>
header.text().includes('Import Failed Extensions')
)
expect(importFailedHeader).toBeDefined()
await importFailedHeader!.trigger('click')
)[0]
await importFailedHeader.trigger('click')
// Should display only import failed package
const expandedPanel = wrapper.find(
'[data-testid="conflict-dialog-panel-expanded"]'
const importFailedItems = wrapper.findAll(
'[aria-label*="Import failed package:"]'
)
expect(expandedPanel.exists()).toBe(true)
const importFailedItems = expandedPanel.findAll('.flex.h-6')
expect(importFailedItems).toHaveLength(1)
expect(importFailedItems[0].text()).toContain('Test Package 3')
})
@@ -400,24 +348,16 @@ describe('NodeConflictDialogContent', () => {
it('should display all package names in extensions list', async () => {
const wrapper = createWrapper()
// Expand extensions panel - find the one containing "Extensions at Risk"
const headers = wrapper.findAll(
// Expand extensions panel (third header)
const extensionsHeader = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)
const extensionsHeader = headers.find((header) =>
header.text().includes('Extensions at Risk')
)
expect(extensionsHeader).toBeDefined()
await extensionsHeader!.trigger('click')
)[2]
await extensionsHeader.trigger('click')
// Should display all package names
const expandedPanel = wrapper.find(
'[data-testid="conflict-dialog-panel-expanded"]'
)
expect(expandedPanel.exists()).toBe(true)
expect(expandedPanel.text()).toContain('Test Package 1')
expect(expandedPanel.text()).toContain('Test Package 2')
expect(expandedPanel.text()).toContain('Test Package 3')
expect(wrapper.text()).toContain('Test Package 1')
expect(wrapper.text()).toContain('Test Package 2')
expect(wrapper.text()).toContain('Test Package 3')
})
})

View File

@@ -14,7 +14,7 @@
<!-- Import Failed List Wrapper -->
<div
v-if="importFailedConflicts.length > 0"
class="flex min-h-8 w-full flex-col rounded-lg bg-secondary-background"
class="flex min-h-8 w-full flex-col rounded-lg bg-base-background"
>
<div
data-testid="conflict-dialog-panel-toggle"
@@ -50,7 +50,8 @@
<div
v-for="(packageName, i) in importFailedConflicts"
:key="i"
class="hover:bg-alpha-azure-600-30 flex h-6 flex-shrink-0 items-center justify-between px-4"
:aria-label="`Import failed package: ${packageName}`"
class="flex min-h-6 shrink-0 hover:bg-node-component-surface-hovered items-center justify-between px-4 py-1"
>
<span class="text-xs text-muted">
{{ packageName }}
@@ -62,7 +63,7 @@
<!-- Conflict List Wrapper -->
<div
v-if="allConflictDetails.length > 0"
class="flex min-h-8 w-full flex-col rounded-lg bg-secondary-background"
class="flex min-h-8 w-full flex-col rounded-lg bg-base-background"
>
<div
data-testid="conflict-dialog-panel-toggle"
@@ -98,7 +99,8 @@
<div
v-for="(conflict, i) in allConflictDetails"
:key="i"
class="hover:bg-alpha-azure-600-30 flex h-6 flex-shrink-0 items-center justify-between px-4"
:aria-label="`Conflict: ${getConflictMessage(conflict, t)}`"
class="flex min-h-6 shrink-0 hover:bg-node-component-surface-hovered items-center justify-between px-4 py-1"
>
<span class="text-xs text-muted">{{
getConflictMessage(conflict, t)
@@ -110,7 +112,7 @@
<!-- Extension List Wrapper -->
<div
v-if="conflictData.length > 0"
class="flex min-h-8 w-full flex-col rounded-lg bg-secondary-background"
class="flex min-h-8 w-full flex-col rounded-lg bg-base-background"
>
<div
data-testid="conflict-dialog-panel-toggle"
@@ -146,7 +148,7 @@
<div
v-for="conflictResult in conflictData"
:key="conflictResult.package_id"
class="hover:bg-alpha-azure-600-30 flex h-6 flex-shrink-0 items-center justify-between px-4"
class="flex min-h-6 shrink-0 hover:bg-node-component-surface-hovered items-center justify-between px-4 py-1"
>
<span class="text-xs text-muted">
{{ conflictResult.package_name }}

View File

@@ -1,8 +1,10 @@
<template>
<div class="flex w-full items-center justify-between p-4">
<div class="flex h-12 w-full items-center justify-between pl-6">
<div class="flex items-center gap-2">
<!-- Warning Icon -->
<i class="icon-[lucide--triangle-alert] text-gold-600"></i>
<p class="m-0 text-sm">
<!-- Title -->
<p class="text-base font-bold">
{{ $t('manager.conflicts.title') }}
</p>
</div>

View File

@@ -34,13 +34,15 @@ import ToggleSwitch from 'primevue/toggleswitch'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useNodeConflictDialog } from '@/composables/useNodeConflictDialog'
import { useDialogService } from '@/services/dialogService'
import type { components } from '@/types/comfyRegistryTypes'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
import type { components as ManagerComponents } from '@/workbench/extensions/manager/types/generatedManagerTypes'
import { useImportFailedDetection } from '../../../composables/useImportFailedDetection'
const TOGGLE_DEBOUNCE_MS = 256
const { nodePack } = defineProps<{
@@ -51,8 +53,9 @@ const { t } = useI18n()
const { isPackEnabled, enablePack, disablePack, installedPacks } =
useComfyManagerStore()
const { getConflictsForPackageByID } = useConflictDetectionStore()
const { show: showNodeConflictDialog } = useNodeConflictDialog()
const { showNodeConflictDialog } = useDialogService()
const { acknowledgmentState, markConflictsAsSeen } = useConflictAcknowledgment()
const { showImportFailedDialog } = useImportFailedDetection(nodePack.id || '')
const isLoading = ref(false)
@@ -81,23 +84,36 @@ const canToggleDirectly = computed(() => {
const showConflictModal = (skipModalDismissed: boolean) => {
let modal_dismissed = acknowledgmentState.value.modal_dismissed
if (skipModalDismissed) modal_dismissed = false
if (packageConflict.value && !modal_dismissed) {
showNodeConflictDialog({
conflictedPackages: [packageConflict.value],
buttonText: !isEnabled.value
? t('manager.conflicts.enableAnyway')
: t('manager.conflicts.understood'),
onButtonClick: async () => {
if (!isEnabled.value) {
await handleEnable()
// Check if there's an import failed conflict first
const hasImportFailed = packageConflict.value.conflicts.some(
(conflict) => conflict.type === 'import_failed'
)
if (hasImportFailed) {
// Show import failed dialog instead of general conflict dialog
showImportFailedDialog(() => {
markConflictsAsSeen()
})
} else {
// Show general conflict dialog for other types of conflicts
showNodeConflictDialog({
conflictedPackages: [packageConflict.value],
buttonText: !isEnabled.value
? t('manager.conflicts.enableAnyway')
: t('manager.conflicts.understood'),
onButtonClick: async () => {
if (!isEnabled.value) {
await handleEnable()
}
},
dialogComponentProps: {
onClose: () => {
markConflictsAsSeen()
}
}
},
dialogComponentProps: {
onClose: () => {
markConflictsAsSeen()
}
}
})
})
}
}
}

View File

@@ -20,12 +20,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import DotSpinner from '@/components/common/DotSpinner.vue'
import Button from '@/components/ui/button/Button.vue'
import type { ButtonVariants } from '@/components/ui/button/button.variants'
import { useNodeConflictDialog } from '@/composables/useNodeConflictDialog'
import { t } from '@/i18n'
import { useDialogService } from '@/services/dialogService'
import type { components } from '@/types/comfyRegistryTypes'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
@@ -54,7 +54,8 @@ const {
}>()
const managerStore = useComfyManagerStore()
const { show: showNodeConflictDialog } = useNodeConflictDialog()
const { showNodeConflictDialog } = useDialogService()
const { t } = useI18n()
// Check if any of the packs are currently being installed
const isInstalling = computed(() => {

View File

@@ -1,43 +1,37 @@
<template>
<div class="flex flex-col gap-3">
<button
v-if="importFailedInfo"
class="inline-flex cursor-pointer items-center justify-end gap-1 border-none bg-transparent outline-none"
@click="showImportFailedDialog"
>
<i class="pi pi-code text-base"></i>
<span class="text-sm text-base-foreground">{{
t('serverStart.openLogs')
}}</span>
</button>
<div
v-for="(conflict, index) in conflictResult?.conflicts || []"
:key="index"
class="rounded-md bg-yellow-800/20 p-3"
class="rounded-md bg-secondary-background/60 px-2 py-1"
>
<div class="flex items-center justify-between">
<div class="flex-1 text-sm break-words">
<!-- Import failed conflicts show detailed error message -->
<template v-if="conflict.type === 'import_failed'">
<div
v-if="conflict.required_value"
class="max-h-64 overflow-x-hidden scrollbar-custom overflow-y-auto rounded px-2"
>
<p class="text-xs text-muted-foreground break-all font-mono">
{{ conflict.required_value }}
</p>
</div>
</template>
<!-- Other conflict types use standard message -->
<template v-else>
<div class="text-sm break-words">
{{ getConflictMessage(conflict, $t) }}
</div>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { t } from '@/i18n'
import type { components } from '@/types/comfyRegistryTypes'
import { useImportFailedDetection } from '@/workbench/extensions/manager/composables/useImportFailedDetection'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import { getConflictMessage } from '@/workbench/extensions/manager/utils/conflictMessageUtil'
const { nodePack, conflictResult } = defineProps<{
nodePack: components['schemas']['Node']
const { conflictResult } = defineProps<{
conflictResult: ConflictDetectionResult | null | undefined
}>()
const packageId = computed(() => nodePack?.id || '')
const { importFailedInfo, showImportFailedDialog } =
useImportFailedDetection(packageId)
</script>

View File

@@ -410,7 +410,7 @@ describe('useConflictDetection', () => {
mockComfyManagerService.getImportFailInfoBulk
).mockResolvedValue({
'fail-pack': {
msg: 'Import error',
error: 'Import error',
name: 'fail-pack',
path: '/path/to/pack'
} as any // The actual API returns different structure than types
@@ -428,7 +428,7 @@ describe('useConflictDetection', () => {
// Import failure should match the actual implementation
expect(result.results[0].conflicts).toContainEqual({
type: 'import_failed',
current_value: 'installed',
current_value: 'Import error',
required_value: 'Import error'
})
})

View File

@@ -389,7 +389,10 @@ export function useConflictDetection() {
* @returns Array of conflict detection results for failed imports
*/
function detectImportFailConflicts(
importFailInfo: Record<string, { msg: string; name: string; path: string }>
importFailInfo: Record<
string,
{ error?: string; traceback?: string } | null
>
): ConflictDetectionResult[] {
const results: ConflictDetectionResult[] = []
if (!importFailInfo || typeof importFailInfo !== 'object') {
@@ -400,8 +403,11 @@ export function useConflictDetection() {
for (const [packageId, failureInfo] of Object.entries(importFailInfo)) {
if (failureInfo && typeof failureInfo === 'object') {
// Extract error information from Manager API response
const errorMsg = failureInfo.msg || 'Unknown import error'
const modulePath = failureInfo.path || ''
const errorMsg = failureInfo.error || 'Unknown import error'
const traceback = failureInfo.traceback || ''
// Combine error and traceback for display
const fullErrorInfo = traceback || errorMsg
results.push({
package_id: packageId,
@@ -410,8 +416,8 @@ export function useConflictDetection() {
conflicts: [
{
type: 'import_failed',
current_value: 'installed',
required_value: failureInfo.msg
current_value: errorMsg,
required_value: fullErrorInfo
}
],
is_compatible: false
@@ -420,8 +426,8 @@ export function useConflictDetection() {
console.warn(
`[ConflictDetection] Python import failure detected for ${packageId}:`,
{
path: modulePath,
error: errorMsg
error: errorMsg,
hasTraceback: !!traceback
}
)
}

View File

@@ -44,7 +44,8 @@ describe('useImportFailedDetection', () => {
>
mockDialogService = {
showErrorDialog: vi.fn()
showErrorDialog: vi.fn(),
showImportFailedNodeDialog: vi.fn()
} as unknown as ReturnType<typeof dialogService.useDialogService>
vi.mocked(comfyManagerStore.useComfyManagerStore).mockReturnValue(
@@ -226,13 +227,22 @@ describe('useImportFailedDetection', () => {
showImportFailedDialog()
expect(mockDialogService.showErrorDialog).toHaveBeenCalledWith(
expect.any(Error),
{
title: 'manager.failedToInstall',
reportType: 'importFailedError'
expect(mockDialogService.showImportFailedNodeDialog).toHaveBeenCalledWith({
conflictedPackages: expect.arrayContaining([
expect.objectContaining({
package_id: 'test-package',
package_name: 'Test Package',
conflicts: expect.arrayContaining([
expect.objectContaining({
type: 'import_failed'
})
])
})
]),
dialogComponentProps: {
onClose: undefined
}
)
})
})
it('should handle null packageId', () => {

View File

@@ -1,11 +1,13 @@
import { computed, unref } from 'vue'
import type { ComputedRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDialogService } from '@/services/dialogService'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
import type { ConflictDetail } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import type {
ConflictDetail,
ConflictDetectionResult
} from '@/workbench/extensions/manager/types/conflictDetectionTypes'
/**
* Extracting import failed conflicts from conflict list
@@ -24,22 +26,18 @@ function extractImportFailedConflicts(conflicts?: ConflictDetail[] | null) {
* Creating import failed dialog
*/
function createImportFailedDialog() {
const { t } = useI18n()
const { showErrorDialog } = useDialogService()
const { showImportFailedNodeDialog } = useDialogService()
return (importFailedInfo: ConflictDetail[] | null) => {
if (importFailedInfo) {
const errorMessage =
importFailedInfo
.map((conflict) => conflict.required_value)
.filter(Boolean)
.join('\n') || t('manager.importFailedGenericError')
const error = new Error(errorMessage)
showErrorDialog(error, {
title: t('manager.failedToInstall'),
reportType: 'importFailedError'
return (
conflictedPackages: ConflictDetectionResult[] | null,
onClose?: () => void
) => {
if (conflictedPackages && conflictedPackages.length > 0) {
showImportFailedNodeDialog({
conflictedPackages,
dialogComponentProps: {
onClose
}
})
}
}
@@ -74,13 +72,16 @@ export function useImportFailedDetection(
return importFailedInfo.value !== null
})
const showImportFailedDialog = createImportFailedDialog()
const openDialog = createImportFailedDialog()
return {
importFailedInfo,
importFailed,
showImportFailedDialog: () =>
showImportFailedDialog(importFailedInfo.value),
showImportFailedDialog: (onClose?: () => void) => {
if (conflicts.value) {
openDialog([conflicts.value], onClose)
}
},
isInstalled
}
}