Compare commits

...

10 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
Jin Yi
dc64e16f7c feature: media asset card design changes (#7858) 2026-01-07 17:21:26 -08:00
Jin Yi
c19a004f0d Prevent nav item shrink (#7869) 2026-01-08 00:10:37 +00:00
Alexander Brown
626d8dac70 feat: Stale-while-revalidate pattern for AssetBrowserModal (#7880)
## Summary

Implements stale-while-revalidate pattern for AssetBrowserModal to show
cached assets immediately while refreshing in background.

## Changes

### AssetBrowserModal.vue
- Reads assets directly from store cache via computed properties
- Shows loading spinner only when loading AND no cached data exists
- Simplified refresh logic: single `refreshAssets()` call on mount

### assetsStore.ts
- Added `updateModelsForTag(tag)` for tag-based fetching
- Added `updateModelsForKey()` internal helper to unify node type and
tag fetching
- Cache key convention: node types as-is, tags prefixed with `tag:`
- Added `isEqual` check before cache updates to prevent unnecessary
re-renders

### useModelUpload.ts
- Simplified signature from `UseAsyncStateReturn<...>['execute']` to `()
=> Promise<unknown> | void`

## UX Improvement

| Scenario | Before | After |
|----------|--------|-------|
| First open | Spinner → Assets | Spinner → Assets |
| Re-open same type | Spinner → Assets | Instant + silent refresh |
| Re-open after download | Spinner → Assets | Cached + auto-update |

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7880-feat-Stale-while-revalidate-pattern-for-AssetBrowserModal-2e16d73d365081ba93f4d6e0415ebfae)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-01-07 15:10:03 -08:00
Alexander Brown
b6a12ddae1 Cleanup: Remove test block from vite.config.ts since we already have a vitest.config.ts (#7873)
## Summary

Just removing the extra configuration.
https://vitest.dev/config/

We _could_ go the other direction and move the vitest configuration
options into the main vite config file.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7873-Cleanup-Remove-test-block-from-vite-config-ts-since-we-already-have-a-vitest-config-ts-2e16d73d3650819ea07bd3bf3416bf11)
by [Unito](https://www.unito.io)
2026-01-07 13:07:05 -08:00
39 changed files with 860 additions and 528 deletions

View File

@@ -2,7 +2,8 @@
<div
:class="
cn(
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg bg-secondary-background shadow-sm transition-all duration-200 cursor-pointer'
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg shadow-sm transition-all duration-200 cursor-pointer',
backgroundClass || 'bg-secondary-background'
)
"
>
@@ -12,4 +13,8 @@
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
const { backgroundClass } = defineProps<{
backgroundClass?: string
}>()
</script>

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

@@ -18,7 +18,8 @@ export const buttonVariants = cva({
'muted-textonly':
'text-muted-foreground bg-transparent hover:bg-secondary-background-hover',
'destructive-textonly':
'text-destructive-background bg-transparent hover:bg-destructive-background/10'
'text-destructive-background bg-transparent hover:bg-destructive-background/10',
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90'
},
size: {
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
@@ -44,7 +45,8 @@ const variants = [
'destructive',
'textonly',
'muted-textonly',
'destructive-textonly'
'destructive-textonly',
'overlay-white'
] as const satisfies Array<ButtonVariants['variant']>
const sizes = ['sm', 'md', 'lg', 'icon', 'icon-sm'] as const satisfies Array<
ButtonVariants['size']

View File

@@ -1,5 +1,5 @@
<template>
<i :class="icon" class="text-neutral text-sm" />
<i :class="icon" class="text-neutral text-sm shrink-0" />
</template>
<script setup lang="ts">

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex cursor-pointer items-center gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
class="flex cursor-pointer items-start gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
:class="
active
? 'bg-interface-menu-component-surface-selected'
@@ -9,9 +9,11 @@
role="button"
@click="onClick"
>
<NavIcon v-if="icon" :icon="icon" />
<i v-else class="text-neutral icon-[lucide--folder] text-xs" />
<span class="flex items-center">
<div v-if="icon" class="py-0.5">
<NavIcon :icon="icon" />
</div>
<i v-else class="text-neutral icon-[lucide--folder] text-xs shrink-0" />
<span class="flex items-center break-all">
<slot></slot>
</span>
</div>

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"
},
@@ -2373,6 +2377,8 @@
"actions": {
"inspect": "Inspect asset",
"more": "More options",
"zoom": "Zoom in",
"moreOptions": "More options",
"seeMoreOutputs": "See more outputs",
"addToWorkflow": "Add to current workflow",
"download": "Download",

View File

@@ -4,20 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
const mockAssetService = vi.hoisted(() => ({
getAssetsForNodeType: vi.fn(),
getAssetsByTag: vi.fn(),
getAssetDetails: vi.fn((id: string) =>
Promise.resolve({
id,
name: 'Test Model',
user_metadata: {
filename: 'Test Model'
}
})
)
}))
import { useAssetsStore } from '@/stores/assetsStore'
vi.mock('@/i18n', () => ({
t: (key: string, params?: Record<string, string>) =>
@@ -25,9 +12,15 @@ vi.mock('@/i18n', () => ({
d: (date: Date) => date.toLocaleDateString()
}))
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: mockAssetService
}))
vi.mock('@/stores/assetsStore', () => {
const store = {
modelAssetsByNodeType: new Map<string, AssetItem[]>(),
modelLoadingByNodeType: new Map<string, boolean>(),
updateModelsForNodeType: vi.fn(),
updateModelsForTag: vi.fn()
}
return { useAssetsStore: () => store }
})
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({
@@ -190,9 +183,12 @@ describe('AssetBrowserModal', () => {
})
}
const mockStore = useAssetsStore()
beforeEach(() => {
mockAssetService.getAssetsForNodeType.mockReset()
mockAssetService.getAssetsByTag.mockReset()
vi.resetAllMocks()
mockStore.modelAssetsByNodeType.clear()
mockStore.modelLoadingByNodeType.clear()
})
describe('Integration with useAssetBrowser', () => {
@@ -201,7 +197,7 @@ describe('AssetBrowserModal', () => {
createTestAsset('asset1', 'Model A', 'checkpoints'),
createTestAsset('asset2', 'Model B', 'loras')
]
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
await flushPromises()
@@ -218,7 +214,7 @@ describe('AssetBrowserModal', () => {
createTestAsset('c1', 'model.safetensors', 'checkpoints'),
createTestAsset('l1', 'lora.pt', 'loras')
]
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
const wrapper = createWrapper({
nodeType: 'CheckpointLoaderSimple',
@@ -234,31 +230,54 @@ describe('AssetBrowserModal', () => {
})
describe('Data fetching', () => {
it('fetches assets for node type', async () => {
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce([])
it('triggers store refresh for node type on mount', async () => {
createWrapper({ nodeType: 'CheckpointLoaderSimple' })
await flushPromises()
expect(mockAssetService.getAssetsForNodeType).toHaveBeenCalledWith(
expect(mockStore.updateModelsForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple'
)
})
it('fetches assets for tag when node type not provided', async () => {
mockAssetService.getAssetsByTag.mockResolvedValueOnce([])
it('displays cached assets immediately from store', async () => {
const assets = [createTestAsset('asset1', 'Cached Model', 'checkpoints')]
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
createWrapper({ assetType: 'loras' })
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
const gridAssets = assetGrid.props('assets') as AssetItem[]
expect(gridAssets).toHaveLength(1)
expect(gridAssets[0].name).toBe('Cached Model')
})
it('triggers store refresh for asset type (tag) on mount', async () => {
createWrapper({ assetType: 'models' })
await flushPromises()
expect(mockAssetService.getAssetsByTag).toHaveBeenCalledWith('loras')
expect(mockStore.updateModelsForTag).toHaveBeenCalledWith('models')
})
it('uses tag: prefix for cache key when assetType is provided', async () => {
const assets = [createTestAsset('asset1', 'Tagged Model', 'models')]
mockStore.modelAssetsByNodeType.set('tag:models', assets)
const wrapper = createWrapper({ assetType: 'models' })
await flushPromises()
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
const gridAssets = assetGrid.props('assets') as AssetItem[]
expect(gridAssets).toHaveLength(1)
expect(gridAssets[0].name).toBe('Tagged Model')
})
})
describe('Asset Selection', () => {
it('emits asset-select event when asset is selected', async () => {
const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')]
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
await flushPromises()
@@ -271,7 +290,7 @@ describe('AssetBrowserModal', () => {
it('executes onSelect callback when provided', async () => {
const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')]
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
const onSelect = vi.fn()
const wrapper = createWrapper({
@@ -289,8 +308,6 @@ describe('AssetBrowserModal', () => {
describe('Left Panel Conditional Logic', () => {
it('hides left panel by default when showLeftPanel is undefined', async () => {
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce([])
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
await flushPromises()
@@ -299,8 +316,6 @@ describe('AssetBrowserModal', () => {
})
it('shows left panel when showLeftPanel prop is explicitly true', async () => {
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce([])
const wrapper = createWrapper({
nodeType: 'CheckpointLoaderSimple',
showLeftPanel: true
@@ -318,7 +333,7 @@ describe('AssetBrowserModal', () => {
createTestAsset('asset1', 'Model A', 'checkpoints'),
createTestAsset('asset2', 'Model B', 'loras')
]
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
const wrapper = createWrapper({
nodeType: 'CheckpointLoaderSimple',
@@ -339,8 +354,6 @@ describe('AssetBrowserModal', () => {
describe('Title Management', () => {
it('passes custom title to BaseModalLayout when title prop provided', async () => {
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce([])
const wrapper = createWrapper({
nodeType: 'CheckpointLoaderSimple',
title: 'Custom Title'
@@ -353,7 +366,7 @@ describe('AssetBrowserModal', () => {
it('passes computed contentTitle to BaseModalLayout when no title prop', async () => {
const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')]
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
await flushPromises()

View File

@@ -63,12 +63,8 @@
</template>
<script setup lang="ts">
import {
breakpointsTailwind,
useAsyncState,
useBreakpoints
} from '@vueuse/core'
import { computed, provide, watch } from 'vue'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { computed, provide } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
@@ -81,68 +77,68 @@ import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBro
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useAssetsStore } from '@/stores/assetsStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { OnCloseKey } from '@/types/widgetTypes'
const { t } = useI18n()
const assetStore = useAssetsStore()
const modelToNodeStore = useModelToNodeStore()
const breakpoints = useBreakpoints(breakpointsTailwind)
const props = defineProps<{
nodeType?: string
assetType?: string
onSelect?: (asset: AssetItem) => void
onClose?: () => void
showLeftPanel?: boolean
title?: string
assetType?: string
}>()
const { t } = useI18n()
const emit = defineEmits<{
'asset-select': [asset: AssetDisplayItem]
close: []
}>()
const breakpoints = useBreakpoints(breakpointsTailwind)
provide(OnCloseKey, props.onClose ?? (() => {}))
const fetchAssets = async () => {
// Compute the cache key based on nodeType or assetType
const cacheKey = computed(() => {
if (props.nodeType) return props.nodeType
if (props.assetType) return `tag:${props.assetType}`
return ''
})
// Read directly from store cache - reactive to any store updates
const fetchedAssets = computed(
() => assetStore.modelAssetsByNodeType.get(cacheKey.value) ?? []
)
const isStoreLoading = computed(
() => assetStore.modelLoadingByNodeType.get(cacheKey.value) ?? false
)
// Only show loading spinner when loading AND no cached data
const isLoading = computed(
() => isStoreLoading.value && fetchedAssets.value.length === 0
)
async function refreshAssets(): Promise<AssetItem[]> {
if (props.nodeType) {
return (await assetService.getAssetsForNodeType(props.nodeType)) ?? []
return await assetStore.updateModelsForNodeType(props.nodeType)
}
if (props.assetType) {
return (await assetService.getAssetsByTag(props.assetType)) ?? []
return await assetStore.updateModelsForTag(props.assetType)
}
return []
}
const {
state: fetchedAssets,
isLoading,
execute
} = useAsyncState<AssetItem[]>(fetchAssets, [], { immediate: false })
// Trigger background refresh on mount
void refreshAssets()
watch(
() => [props.nodeType, props.assetType],
async () => {
await execute()
},
{ immediate: true }
)
const assetDownloadStore = useAssetDownloadStore()
watch(
() => assetDownloadStore.hasActiveDownloads,
async (currentlyActive, previouslyActive) => {
if (previouslyActive && !currentlyActive) {
await execute()
}
}
)
const { isUploadButtonEnabled, showUploadDialog } =
useModelUpload(refreshAssets)
const {
searchQuery,
@@ -153,8 +149,6 @@ const {
updateFilters
} = useAssetBrowser(fetchedAssets)
const modelToNodeStore = useModelToNodeStore()
const primaryCategoryTag = computed(() => {
const assets = fetchedAssets.value ?? []
const tagFromAssets = assets
@@ -202,6 +196,4 @@ function handleAssetSelectAndEmit(asset: AssetDisplayItem) {
// It handles the appropriate transformation (filename extraction or full asset)
props.onSelect?.(asset)
}
const { isUploadButtonEnabled, showUploadDialog } = useModelUpload(execute)
</script>

View File

@@ -1,29 +0,0 @@
<template>
<div class="flex flex-col items-center gap-1">
<MediaTitle :file-name="fileName" />
<!-- TBD: File size will be provided by backend history API -->
<div
v-if="asset.size"
class="flex items-center gap-2 text-xs text-zinc-400"
>
<span>{{ formatSize(asset.size) }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta
}>()
const fileName = computed(() => {
return getFilenameDetails(asset.name).filename
})
</script>

View File

@@ -1,5 +1,5 @@
<template>
<CardContainer
<div
ref="cardContainerRef"
role="button"
:aria-label="
@@ -11,112 +11,114 @@
: $t('assetBrowser.ariaLabel.loadingAsset')
"
:tabindex="loading ? -1 : 0"
size="mini"
variant="ghost"
rounded="lg"
:class="containerClasses"
:class="
cn(
'flex flex-col overflow-hidden cursor-pointer p-2 transition-colors duration-200 rounded-lg',
'gap-2 select-none group',
selected
? 'ring-3 ring-inset ring-modal-card-border-highlighted'
: 'hover:bg-modal-card-background-hovered'
)
"
:data-selected="selected"
@click.stop="$emit('click')"
@contextmenu.prevent="handleContextMenu"
>
<template #top>
<CardTop
ratio="square"
:bottom-left-class="durationChipClasses"
:bottom-right-class="durationChipClasses"
<!-- Top Area: Media Preview -->
<div class="relative aspect-square overflow-hidden p-0">
<!-- Loading State -->
<div
v-if="loading"
class="size-full animate-pulse rounded-lg bg-modal-card-placeholder-background"
/>
<!-- Content based on asset type -->
<component
:is="getTopComponent(fileKind)"
v-else-if="asset && adaptedAsset"
:asset="adaptedAsset"
:context="{ type: assetType }"
class="absolute inset-0"
@view="handleZoomClick"
@download="actions.downloadAsset()"
@video-playing-state-changed="isVideoPlaying = $event"
@video-controls-changed="showVideoControls = $event"
@image-loaded="handleImageLoaded"
/>
<!-- Action buttons overlay (top-left) -->
<div
v-if="showActionsOverlay"
class="absolute top-2 left-2 flex flex-wrap justify-start gap-2"
>
<!-- Loading State -->
<template v-if="loading">
<div
class="size-full animate-pulse rounded-lg bg-modal-card-placeholder-background"
/>
</template>
<!-- Content based on asset type -->
<template v-else-if="asset && adaptedAsset">
<component
:is="getTopComponent(fileKind)"
:asset="adaptedAsset"
:context="{ type: assetType }"
@view="handleZoomClick"
@download="actions.downloadAsset()"
@video-playing-state-changed="isVideoPlaying = $event"
@video-controls-changed="showVideoControls = $event"
@image-loaded="handleImageLoaded"
/>
</template>
<!-- Top-left slot: Duration/Format chips OR Media actions -->
<template #top-left>
<!-- Duration/Format chips - show when not hovered and not playing -->
<div v-if="showStaticChips" class="flex flex-wrap items-center gap-1">
<SquareChip
v-if="formattedDuration"
variant="gray"
:label="formattedDuration"
/>
</div>
<!-- Media actions - show on hover or when playing -->
<IconGroup v-else-if="showActionsOverlay">
<Button size="icon" @click.stop="handleZoomClick">
<i class="icon-[lucide--zoom-in] size-4" />
</Button>
<Button size="icon" @click.stop="handleContextMenu">
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</IconGroup>
</template>
<!-- Output count or duration chip (top-right) -->
<template v-if="showOutputCount || showTouchDurationChip" #top-right>
<IconGroup background-class="bg-white">
<Button
variant="overlay-white"
size="icon"
:aria-label="$t('mediaAsset.actions.zoom')"
@click.stop="handleZoomClick"
>
<i class="icon-[lucide--zoom-in] size-4" />
</Button>
<Button
variant="overlay-white"
size="icon"
:aria-label="$t('mediaAsset.actions.moreOptions')"
@click.stop="handleContextMenu"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</IconGroup>
</div>
</div>
<!-- Bottom Area: Media Info -->
<div class="flex-1">
<!-- Loading State -->
<div v-if="loading" class="flex justify-between items-start">
<div class="flex flex-col gap-1">
<div
class="h-4 w-24 animate-pulse rounded bg-modal-card-background"
/>
<div
class="h-3 w-20 animate-pulse rounded bg-modal-card-background"
/>
</div>
<div class="h-6 w-12 animate-pulse rounded bg-modal-card-background" />
</div>
<!-- Content -->
<div
v-else-if="asset && adaptedAsset"
class="flex justify-between items-end gap-1.5"
>
<!-- Left side: Media name and metadata -->
<div class="flex flex-col gap-1">
<!-- Title -->
<MediaTitle :file-name="fileName" />
<!-- Metadata -->
<div class="flex gap-1.5 text-xs text-muted-foreground">
<span v-if="formattedDuration">{{ formattedDuration }}</span>
<span v-if="metaInfo">{{ metaInfo }}</span>
</div>
</div>
<!-- Right side: Output count -->
<div v-if="showOutputCount" class="flex-shrink-0">
<Button
v-if="showOutputCount"
v-tooltip.top.pt:pointer-events-none="
$t('mediaAsset.actions.seeMoreOutputs')
"
variant="secondary"
size="sm"
@click.stop="handleOutputCountClick"
>
<i class="icon-[lucide--layers] size-4" />
<span>{{ outputCount }}</span>
</Button>
<!-- Duration chip on touch devices (far right) -->
<SquareChip
v-else-if="showTouchDurationChip"
variant="gray"
:label="formattedDuration"
/>
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom>
<!-- Loading State -->
<template v-if="loading">
<div class="flex flex-col items-center justify-between gap-1">
<div
class="h-4 w-2/3 animate-pulse rounded bg-modal-card-background"
/>
<div
class="h-3 w-1/2 animate-pulse rounded bg-modal-card-background"
/>
</div>
</template>
<!-- Content based on asset type -->
<template v-else-if="asset && adaptedAsset">
<component
:is="getBottomComponent(fileKind)"
:asset="adaptedAsset"
:context="{ type: assetType }"
/>
</template>
</CardBottom>
</template>
</CardContainer>
</div>
</div>
</div>
</div>
<MediaAssetContextMenu
v-if="asset"
@@ -131,16 +133,17 @@
</template>
<script setup lang="ts">
import { useElementHover, useMediaQuery, whenever } from '@vueuse/core'
import { useElementHover, whenever } from '@vueuse/core'
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
import IconGroup from '@/components/button/IconGroup.vue'
import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import Button from '@/components/ui/button/Button.vue'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import {
formatDuration,
formatSize,
getFilenameDetails,
getMediaTypeFromFilename
} from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
import { getAssetType } from '../composables/media/assetMappers'
@@ -149,6 +152,7 @@ import type { AssetItem } from '../schemas/assetSchema'
import type { MediaKind } from '../schemas/mediaAssetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetContextMenu from './MediaAssetContextMenu.vue'
import MediaTitle from './MediaTitle.vue'
const mediaComponents = {
top: {
@@ -156,12 +160,6 @@ const mediaComponents = {
audio: defineAsyncComponent(() => import('./MediaAudioTop.vue')),
image: defineAsyncComponent(() => import('./MediaImageTop.vue')),
'3D': defineAsyncComponent(() => import('./Media3DTop.vue'))
},
bottom: {
video: defineAsyncComponent(() => import('./MediaVideoBottom.vue')),
audio: defineAsyncComponent(() => import('./MediaAudioBottom.vue')),
image: defineAsyncComponent(() => import('./MediaImageBottom.vue')),
'3D': defineAsyncComponent(() => import('./Media3DBottom.vue'))
}
}
@@ -169,10 +167,6 @@ function getTopComponent(kind: MediaKind) {
return mediaComponents.top[kind] || mediaComponents.top.image
}
function getBottomComponent(kind: MediaKind) {
return mediaComponents.bottom[kind] || mediaComponents.bottom.image
}
const {
asset,
loading,
@@ -209,7 +203,6 @@ const showVideoControls = ref(false)
const imageDimensions = ref<{ width: number; height: number } | undefined>()
const isHovered = useElementHover(cardContainerRef)
const isTouch = useMediaQuery('(hover: none)')
const actions = useMediaAssetActions()
@@ -223,6 +216,11 @@ const fileKind = computed((): MediaKind => {
return getMediaTypeFromFilename(asset?.name || '') as MediaKind
})
// Get filename without extension
const fileName = computed(() => {
return getFilenameDetails(asset?.name || '').filename
})
// Adapt AssetItem to legacy AssetMeta format for existing components
const adaptedAsset = computed(() => {
if (!asset) return undefined
@@ -248,15 +246,6 @@ provide(MediaAssetKey, {
showVideoControls
})
const containerClasses = computed(() =>
cn(
'gap-1 select-none group',
selected
? 'ring-3 ring-inset ring-modal-card-border-highlighted'
: 'hover:bg-modal-card-background-hovered'
)
)
const formattedDuration = computed(() => {
// Check for execution time first (from history API)
const executionTime = asset?.user_metadata?.executionTimeInSeconds
@@ -270,39 +259,22 @@ const formattedDuration = computed(() => {
return formatDuration(Number(duration))
})
const durationChipClasses = computed(() => {
if (fileKind.value === 'audio') {
return '-translate-y-11'
// Get metadata info based on file kind
const metaInfo = computed(() => {
if (!asset) return ''
if (fileKind.value === 'image' && imageDimensions.value) {
return `${imageDimensions.value.width}x${imageDimensions.value.height}`
}
if (fileKind.value === 'video' && showVideoControls.value) {
return '-translate-y-16'
if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {
return formatSize(asset.size)
}
return ''
})
// Show static chips when NOT hovered and NOT playing (normal state on non-touch)
const showStaticChips = computed(
() =>
!loading &&
!!asset &&
!isHovered.value &&
!isVideoPlaying.value &&
!isTouch.value &&
formattedDuration.value
)
// Show duration chip in top-right on touch devices
const showTouchDurationChip = computed(
() => !loading && !!asset && isTouch.value && formattedDuration.value
)
// Show action overlay when hovered, playing, or on touch device
const showActionsOverlay = computed(
() =>
!loading &&
!!asset &&
(isHovered.value || isVideoPlaying.value || isTouch.value)
)
const showActionsOverlay = computed(() => {
if (loading || !asset) return false
return isHovered.value || selected || isVideoPlaying.value
})
const handleZoomClick = () => {
if (asset) {

View File

@@ -1,29 +0,0 @@
<template>
<div class="flex flex-col items-center gap-1">
<MediaTitle :file-name="fileName" />
<!-- TBD: File size will be provided by backend history API -->
<div
v-if="asset.size"
class="flex items-center gap-2 text-xs text-zinc-400"
>
<span>{{ formatSize(asset.size) }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta
}>()
const fileName = computed(() => {
return getFilenameDetails(asset.name).filename
})
</script>

View File

@@ -1,27 +0,0 @@
<template>
<div class="flex flex-col items-center gap-1">
<MediaTitle :file-name="fileName" />
<div class="flex items-center text-xs text-zinc-400">
<span v-if="asset.dimensions"
>{{ asset.dimensions?.width }}x{{ asset.dimensions?.height }}</span
>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { getFilenameDetails } from '@/utils/formatUtil'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta
}>()
const fileName = computed(() => {
return getFilenameDetails(asset.name).filename
})
</script>

View File

@@ -1,21 +1,14 @@
<template>
<h3
class="m-0 line-clamp-1 text-sm font-bold text-base-foreground"
:title="fullName"
<p
class="m-0 line-clamp-2 text-sm text-base-foreground leading-tight break-all"
:title="fileName"
>
{{ displayName }}
</h3>
{{ fileName }}
</p>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { truncateFilename } from '@/utils/formatUtil'
const props = defineProps<{
defineProps<{
fileName: string
}>()
const fullName = computed(() => props.fileName)
const displayName = computed(() => truncateFilename(props.fileName))
</script>

View File

@@ -1,26 +0,0 @@
<template>
<div class="flex flex-col items-center gap-1">
<MediaTitle :file-name="fileName" />
<!-- TBD: File size will be provided by backend history API -->
<div v-if="asset.size" class="flex items-center text-xs text-zinc-400">
<span>{{ formatSize(asset.size) }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta
}>()
const fileName = computed(() => {
return getFilenameDetails(asset.name).filename
})
</script>

View File

@@ -3,13 +3,11 @@ import UploadModelDialog from '@/platform/assets/components/UploadModelDialog.vu
import UploadModelDialogHeader from '@/platform/assets/components/UploadModelDialogHeader.vue'
import UploadModelUpgradeModal from '@/platform/assets/components/UploadModelUpgradeModal.vue'
import UploadModelUpgradeModalHeader from '@/platform/assets/components/UploadModelUpgradeModalHeader.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useDialogStore } from '@/stores/dialogStore'
import type { UseAsyncStateReturn } from '@vueuse/core'
import { computed } from 'vue'
export function useModelUpload(
execute?: UseAsyncStateReturn<AssetItem[], [], true>['execute']
onUploadSuccess?: () => Promise<unknown> | void
) {
const dialogStore = useDialogStore()
const { flags } = useFeatureFlags()
@@ -37,7 +35,7 @@ export function useModelUpload(
component: UploadModelDialog,
props: {
onUploadSuccess: async () => {
await execute?.()
await onUploadSuccess?.()
}
},
dialogComponentProps: {

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

@@ -30,6 +30,9 @@ 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 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'
@@ -482,6 +485,43 @@ 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
@@ -561,6 +601,7 @@ export const useDialogService = () => {
toggleManagerDialog,
toggleManagerProgressDialog,
showLayoutDialog,
showImportFailedNodeDialog,
showNodeConflictDialog
}
}

View File

@@ -1,4 +1,5 @@
import { useAsyncState } from '@vueuse/core'
import { isEqual } from 'es-toolkit'
import { defineStore } from 'pinia'
import { computed, shallowReactive, ref, watch } from 'vue'
import {
@@ -279,59 +280,81 @@ export const useAssetsStore = defineStore('assets', () => {
new Map<string, ReturnType<typeof useAsyncState<AssetItem[]>>>()
)
/**
* Internal helper to fetch and cache assets with a given key and fetcher
*/
async function updateModelsForKey(
key: string,
fetcher: () => Promise<AssetItem[]>
): Promise<AssetItem[]> {
if (!stateByNodeType.has(key)) {
stateByNodeType.set(
key,
useAsyncState(fetcher, [], {
immediate: false,
resetOnExecute: false,
onError: (err) => {
console.error(`Error fetching model assets for ${key}:`, err)
}
})
)
}
const state = stateByNodeType.get(key)!
modelLoadingByNodeType.set(key, true)
modelErrorByNodeType.set(key, null)
try {
await state.execute()
} finally {
modelLoadingByNodeType.set(key, state.isLoading.value)
}
const assets = state.state.value
const existingAssets = modelAssetsByNodeType.get(key)
if (!isEqual(existingAssets, assets)) {
modelAssetsByNodeType.set(key, assets)
}
modelErrorByNodeType.set(
key,
state.error.value instanceof Error ? state.error.value : null
)
return assets
}
/**
* Fetch and cache model assets for a specific node type
* Uses VueUse's useAsyncState for automatic loading/error tracking
* @param nodeType The node type to fetch assets for (e.g., 'CheckpointLoaderSimple')
* @returns Promise resolving to the fetched assets
*/
async function updateModelsForNodeType(
nodeType: string
): Promise<AssetItem[]> {
if (!stateByNodeType.has(nodeType)) {
stateByNodeType.set(
nodeType,
useAsyncState(
() => assetService.getAssetsForNodeType(nodeType),
[],
{
immediate: false,
resetOnExecute: false,
onError: (err) => {
console.error(
`Error fetching model assets for ${nodeType}:`,
err
)
}
}
)
)
}
return updateModelsForKey(nodeType, () =>
assetService.getAssetsForNodeType(nodeType)
)
}
const state = stateByNodeType.get(nodeType)!
modelLoadingByNodeType.set(nodeType, true)
modelErrorByNodeType.set(nodeType, null)
try {
await state.execute()
const assets = state.state.value
modelAssetsByNodeType.set(nodeType, assets)
modelErrorByNodeType.set(
nodeType,
state.error.value instanceof Error ? state.error.value : null
)
return assets
} finally {
modelLoadingByNodeType.set(nodeType, state.isLoading.value)
}
/**
* Fetch and cache model assets for a specific tag
* @param tag The tag to fetch assets for (e.g., 'models')
* @returns Promise resolving to the fetched assets
*/
async function updateModelsForTag(tag: string): Promise<AssetItem[]> {
const key = `tag:${tag}`
return updateModelsForKey(key, () => assetService.getAssetsByTag(tag))
}
return {
modelAssetsByNodeType,
modelLoadingByNodeType,
modelErrorByNodeType,
updateModelsForNodeType
updateModelsForNodeType,
updateModelsForTag
}
}
@@ -339,7 +362,8 @@ export const useAssetsStore = defineStore('assets', () => {
modelAssetsByNodeType: shallowReactive(new Map<string, AssetItem[]>()),
modelLoadingByNodeType: shallowReactive(new Map<string, boolean>()),
modelErrorByNodeType: shallowReactive(new Map<string, Error | null>()),
updateModelsForNodeType: async () => []
updateModelsForNodeType: async () => [],
updateModelsForTag: async () => []
}
}
@@ -347,7 +371,8 @@ export const useAssetsStore = defineStore('assets', () => {
modelAssetsByNodeType,
modelLoadingByNodeType,
modelErrorByNodeType,
updateModelsForNodeType
updateModelsForNodeType,
updateModelsForTag
} = getModelState()
// Watch for completed downloads and refresh model caches
@@ -403,6 +428,7 @@ export const useAssetsStore = defineStore('assets', () => {
modelAssetsByNodeType,
modelLoadingByNodeType,
modelErrorByNodeType,
updateModelsForNodeType
updateModelsForNodeType,
updateModelsForTag
}
})

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

@@ -245,7 +245,7 @@ describe('NodeConflictDialogContent', () => {
await conflictsHeader.trigger('click')
// Should be expanded now
const conflictItems = wrapper.findAll('.conflict-list-item')
const conflictItems = wrapper.findAll('[aria-label*="Conflict:"]')
expect(conflictItems.length).toBeGreaterThan(0)
})
@@ -324,7 +324,7 @@ describe('NodeConflictDialogContent', () => {
await conflictsHeader.trigger('click')
// Should display conflict messages (excluding import_failed)
const conflictItems = wrapper.findAll('.conflict-list-item')
const conflictItems = wrapper.findAll('[aria-label*="Conflict:"]')
expect(conflictItems).toHaveLength(3) // 2 from Package1 + 1 from Package2
})
@@ -338,7 +338,9 @@ describe('NodeConflictDialogContent', () => {
await importFailedHeader.trigger('click')
// Should display only import failed package
const importFailedItems = wrapper.findAll('.conflict-list-item')
const importFailedItems = wrapper.findAll(
'[aria-label*="Import failed package:"]'
)
expect(importFailedItems).toHaveLength(1)
expect(importFailedItems[0].text()).toContain('Test Package 3')
})

View File

@@ -50,7 +50,8 @@
<div
v-for="(packageName, i) in importFailedConflicts"
:key="i"
class="conflict-list-item flex h-6 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 }}
@@ -98,7 +99,8 @@
<div
v-for="(conflict, i) in allConflictDetails"
:key="i"
class="conflict-list-item flex h-6 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)
@@ -146,7 +148,7 @@
<div
v-for="conflictResult in conflictData"
:key="conflictResult.package_id"
class="conflict-list-item flex h-6 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 }}
@@ -236,8 +238,3 @@ const toggleExtensionsPanel = () => {
importFailedExpanded.value = false
}
</script>
<style scoped>
.conflict-list-item:hover {
background-color: rgb(0 122 255 / 0.2);
}
</style>

View File

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

View File

@@ -41,6 +41,8 @@ import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comf
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<{
@@ -53,6 +55,7 @@ const { isPackEnabled, enablePack, disablePack, installedPacks } =
const { getConflictsForPackageByID } = useConflictDetectionStore()
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

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

View File

@@ -482,12 +482,6 @@ export default defineConfig({
: []
},
test: {
globals: true,
environment: 'happy-dom',
setupFiles: ['./vitest.setup.ts']
},
define: {
__COMFYUI_FRONTEND_VERSION__: JSON.stringify(
process.env.npm_package_version