Compare commits

..

25 Commits

Author SHA1 Message Date
Csongor Czezar
9592e0b0c6 fix: update pnpm-lock.yaml to match main and resolve knip issues 2026-01-07 13:14:49 -08:00
Csongor Czezar
c33b30d4a3 chore: merge latest main to sync dependencies 2026-01-07 12:37:07 -08:00
Csongor Czezar
e4f87a96a4 fix: label aligned with button group 2026-01-06 16:36:05 -08:00
Csongor Czezar
f13e35c89b Merge remote-tracking branch 'origin/main' into feat-widget-label-props 2026-01-06 15:26:59 -08:00
Csongor Czezar
b6ef7b8034 fix: resolve coding guideline violations 2026-01-06 14:47:10 -08:00
Csongor Czezar
43fce00e54 Merge remote-tracking branch 'origin/main' into feat-widget-label-props 2026-01-06 13:55:15 -08:00
Csongor Czezar
fd60f4edb6 chore: trigger CI re-run after dependency cleanup 2026-01-06 13:36:08 -08:00
Csongor Czezar
b6c77706d6 add conflict metadata to node requirements system 2026-01-06 12:33:00 -08:00
Csongor Czezar
e67d1bde5d fix: downgrade pinia to 2.2.2 to match main and fix test failures 2026-01-06 12:09:25 -08:00
Csongor Czezar
03186ceaf5 fix: default variant changed and truncation added 2026-01-06 12:09:25 -08:00
Csongor Czezar
d94476a7df feat: added secondary and inverted styles 2026-01-06 12:09:24 -08:00
GitHub Action
9bb16f6d99 [automated] Apply ESLint and Prettier fixes 2026-01-06 12:09:23 -08:00
Csongor Czezar
c8f52cbcb4 fix: removing unused types 2026-01-06 12:09:23 -08:00
Csongor Czezar
dbf4a4c64c feat: added primary togglegroup buttton styles 2026-01-06 12:09:22 -08:00
Csongor Czezar
5d94466d40 fix: use cva from catalog instead of class-variance-authority 2026-01-06 12:09:21 -08:00
GitHub Action
a1d4e62b87 [automated] Apply ESLint and Prettier fixes 2026-01-06 12:09:21 -08:00
Csongor Czezar
81e4f3cb77 fix: removed unused imports and dependencies 2026-01-06 12:09:20 -08:00
Csongor Czezar
9614107e82 fix: disable vue/no-unused-properties for shadcn-vue forwarded props 2026-01-06 12:09:19 -08:00
Csongor Czezar
0e25a2f79b fix: adding i18n keys for the fallback strings 2026-01-06 12:09:18 -08:00
Csongor Czezar
dd435f86f5 fix: aligned colors and positions 2026-01-06 12:09:18 -08:00
Csongor Czezar
035a0f250c feat: add ToggleGroup support for labeled boolean widgets 2026-01-06 12:09:17 -08:00
Csongor Czezar
795962f3c3 fix: added fallback labels 2026-01-06 12:09:16 -08:00
Csongor Czezar
5fb3fc5646 fix: used generic typing 2026-01-06 12:09:15 -08:00
Csongor Czezar
b16202e3ea fix: render wrapper dynamically 2026-01-06 12:09:15 -08:00
Csongor Czezar
4fa21b5f0f feat: add label support to boolean toggle widgets 2026-01-06 12:09:14 -08:00
43 changed files with 792 additions and 841 deletions

View File

@@ -2,8 +2,7 @@
<div
:class="
cn(
'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'
'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'
)
"
>
@@ -13,8 +12,4 @@
<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" @click="openManager">{{
<Button variant="textonly" size="sm" @click="openManager">{{
$t('g.openManager')
}}</Button>
<PackInstallButton

View File

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

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import { reactiveOmit } from '@vueuse/core'
import type { ToggleGroupRootEmits, ToggleGroupRootProps } from 'reka-ui'
import { ToggleGroupRoot, useForwardPropsEmits } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { provide } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { toggleGroupVariants } from './toggleGroup.variants'
import type { ToggleGroupItemVariants } from './toggleGroup.variants'
const props = defineProps<
ToggleGroupRootProps & {
class?: HTMLAttributes['class']
variant?: ToggleGroupItemVariants['variant']
}
>()
const emits = defineEmits<ToggleGroupRootEmits>()
provide('toggleGroup', {
variant: props.variant
})
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ToggleGroupRoot
v-slot="slotProps"
v-bind="forwarded"
:class="cn(toggleGroupVariants(), props.class)"
>
<slot v-bind="slotProps" />
</ToggleGroupRoot>
</template>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import { reactiveOmit } from '@vueuse/core'
import type { ToggleGroupItemProps } from 'reka-ui'
import { ToggleGroupItem, useForwardProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { inject } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { toggleGroupItemVariants } from './toggleGroup.variants'
import type { ToggleGroupItemVariants } from './toggleGroup.variants'
const props = defineProps<
ToggleGroupItemProps & {
class?: HTMLAttributes['class']
variant?: ToggleGroupItemVariants['variant']
}
>()
const context = inject<{ variant?: ToggleGroupItemVariants['variant'] }>(
'toggleGroup'
)
const delegatedProps = reactiveOmit(props, 'class', 'variant')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<ToggleGroupItem
v-slot="slotProps"
v-bind="forwardedProps"
:class="
cn(
toggleGroupItemVariants({
variant: context?.variant || variant
}),
props.class
)
"
>
<span class="truncate min-w-0">
<slot v-bind="slotProps" />
</span>
</ToggleGroupItem>
</template>

View File

@@ -0,0 +1,36 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const toggleGroupVariants = cva({
base: 'flex gap-[var(--primitive-padding-padding-1,4px)] p-[var(--primitive-padding-padding-1,4px)] rounded-[var(--primitive-border-radius-rounded-sm,4px)] bg-component-node-widget-background'
})
export const toggleGroupItemVariants = cva({
base: 'flex-1 inline-flex items-center justify-center border-0 rounded-[var(--primitive-border-radius-rounded-sm,4px)] px-[var(--primitive-padding-padding-2,8px)] py-[var(--primitive-padding-padding-1,4px)] text-xs font-inter font-normal transition-colors cursor-pointer overflow-hidden',
variants: {
variant: {
primary: [
'data-[state=off]:bg-transparent data-[state=off]:text-muted-foreground',
'data-[state=off]:hover:bg-component-node-widget-background-hovered data-[state=off]:hover:text-white',
'data-[state=on]:bg-primary-background data-[state=on]:text-white'
],
secondary: [
'data-[state=off]:bg-transparent data-[state=off]:text-muted-foreground',
'data-[state=off]:hover:bg-component-node-widget-background-hovered data-[state=off]:hover:text-white',
'data-[state=on]:bg-component-node-widget-background-selected data-[state=on]:text-base-foreground'
],
inverted: [
'data-[state=off]:bg-transparent data-[state=off]:text-muted-foreground',
'data-[state=off]:hover:bg-component-node-widget-background-hovered data-[state=off]:hover:text-white',
'data-[state=on]:bg-white data-[state=on]:text-base-background'
]
}
},
defaultVariants: {
variant: 'secondary'
}
})
export type ToggleGroupItemVariants = VariantProps<
typeof toggleGroupItemVariants
>

View File

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

View File

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

View File

@@ -272,108 +272,4 @@ 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,31 +82,13 @@ export function useTemplateFiltering(
const debouncedSearchQuery = refDebounced(searchQuery, 50)
// 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) {
if (!debouncedSearchQuery.value.trim()) {
return templatesArray.value
}
return fuseSearchResults.value.map((result) => result.item)
const results = fuse.value.search(debouncedSearchQuery.value)
return results.map((result) => result.item)
})
const filteredByModels = computed(() => {
@@ -183,66 +165,31 @@ 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':
// 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
// Curated: usage × 0.5 + internal × 0.3 + freshness × 0.2
return templates.sort((a, b) => {
const baseScoreA = rankingStore.computeDefaultScore(
const scoreA = rankingStore.computeDefaultScore(
a.date,
a.searchRank,
a.usage
)
const baseScoreB = rankingStore.computeDefaultScore(
const scoreB = rankingStore.computeDefaultScore(
b.date,
b.searchRank,
b.usage
)
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
return scoreB - scoreA
})
case 'popular':
// 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
// User-driven: usage × 0.9 + freshness × 0.1
return templates.sort((a, b) => {
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
const scoreA = rankingStore.computePopularScore(a.date, a.usage)
const scoreB = rankingStore.computePopularScore(b.date, b.usage)
return scoreB - scoreA
})
case 'alphabetical':
return templates.sort((a, b) => {
@@ -262,12 +209,6 @@ 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)
@@ -284,20 +225,11 @@ 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) {
// 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
}
if (sizeA === sizeB) return 0
return sizeA - sizeB
})
case 'default':
default:
// 'default' preserves Fuse's search order (already sorted by relevance)
return templates
}
})

View File

@@ -6,9 +6,7 @@ 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'
// 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 OBJLoader2WorkerUrl from 'wwobjloader2/worker?url'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'

View File

@@ -28,7 +28,6 @@ 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'
@@ -334,8 +333,6 @@ 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,8 +27,6 @@ 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,10 +378,6 @@
"warningTooltip": "This package may have compatibility issues with your current environment"
}
},
"importFailed": {
"title": "Import Failed",
"copyError": "Copy Error"
},
"issueReport": {
"helpFix": "Help Fix This"
},
@@ -2055,6 +2051,10 @@
"Set Group Nodes to Always": "Set Group Nodes to Always"
},
"widgets": {
"boolean": {
"true": "true",
"false": "false"
},
"selectModel": "Select model",
"uploadSelect": {
"placeholder": "Select...",
@@ -2377,8 +2377,6 @@
"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,7 +4,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetsStore } from '@/stores/assetsStore'
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'
}
})
)
}))
vi.mock('@/i18n', () => ({
t: (key: string, params?: Record<string, string>) =>
@@ -12,15 +25,9 @@ vi.mock('@/i18n', () => ({
d: (date: Date) => date.toLocaleDateString()
}))
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('@/platform/assets/services/assetService', () => ({
assetService: mockAssetService
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({
@@ -183,12 +190,9 @@ describe('AssetBrowserModal', () => {
})
}
const mockStore = useAssetsStore()
beforeEach(() => {
vi.resetAllMocks()
mockStore.modelAssetsByNodeType.clear()
mockStore.modelLoadingByNodeType.clear()
mockAssetService.getAssetsForNodeType.mockReset()
mockAssetService.getAssetsByTag.mockReset()
})
describe('Integration with useAssetBrowser', () => {
@@ -197,7 +201,7 @@ describe('AssetBrowserModal', () => {
createTestAsset('asset1', 'Model A', 'checkpoints'),
createTestAsset('asset2', 'Model B', 'loras')
]
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
await flushPromises()
@@ -214,7 +218,7 @@ describe('AssetBrowserModal', () => {
createTestAsset('c1', 'model.safetensors', 'checkpoints'),
createTestAsset('l1', 'lora.pt', 'loras')
]
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
const wrapper = createWrapper({
nodeType: 'CheckpointLoaderSimple',
@@ -230,54 +234,31 @@ describe('AssetBrowserModal', () => {
})
describe('Data fetching', () => {
it('triggers store refresh for node type on mount', async () => {
it('fetches assets for node type', async () => {
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce([])
createWrapper({ nodeType: 'CheckpointLoaderSimple' })
await flushPromises()
expect(mockStore.updateModelsForNodeType).toHaveBeenCalledWith(
expect(mockAssetService.getAssetsForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple'
)
})
it('displays cached assets immediately from store', async () => {
const assets = [createTestAsset('asset1', 'Cached Model', 'checkpoints')]
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
it('fetches assets for tag when node type not provided', async () => {
mockAssetService.getAssetsByTag.mockResolvedValueOnce([])
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' })
createWrapper({ assetType: 'loras' })
await flushPromises()
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')
expect(mockAssetService.getAssetsByTag).toHaveBeenCalledWith('loras')
})
})
describe('Asset Selection', () => {
it('emits asset-select event when asset is selected', async () => {
const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')]
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
await flushPromises()
@@ -290,7 +271,7 @@ describe('AssetBrowserModal', () => {
it('executes onSelect callback when provided', async () => {
const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')]
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
const onSelect = vi.fn()
const wrapper = createWrapper({
@@ -308,6 +289,8 @@ 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()
@@ -316,6 +299,8 @@ describe('AssetBrowserModal', () => {
})
it('shows left panel when showLeftPanel prop is explicitly true', async () => {
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce([])
const wrapper = createWrapper({
nodeType: 'CheckpointLoaderSimple',
showLeftPanel: true
@@ -333,7 +318,7 @@ describe('AssetBrowserModal', () => {
createTestAsset('asset1', 'Model A', 'checkpoints'),
createTestAsset('asset2', 'Model B', 'loras')
]
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
const wrapper = createWrapper({
nodeType: 'CheckpointLoaderSimple',
@@ -354,6 +339,8 @@ 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'
@@ -366,7 +353,7 @@ describe('AssetBrowserModal', () => {
it('passes computed contentTitle to BaseModalLayout when no title prop', async () => {
const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')]
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
await flushPromises()

View File

@@ -63,8 +63,12 @@
</template>
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { computed, provide } from 'vue'
import {
breakpointsTailwind,
useAsyncState,
useBreakpoints
} from '@vueuse/core'
import { computed, provide, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
@@ -77,68 +81,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 { useAssetsStore } from '@/stores/assetsStore'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
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 ?? (() => {}))
// 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[]> {
const fetchAssets = async () => {
if (props.nodeType) {
return await assetStore.updateModelsForNodeType(props.nodeType)
return (await assetService.getAssetsForNodeType(props.nodeType)) ?? []
}
if (props.assetType) {
return await assetStore.updateModelsForTag(props.assetType)
return (await assetService.getAssetsByTag(props.assetType)) ?? []
}
return []
}
// Trigger background refresh on mount
void refreshAssets()
const {
state: fetchedAssets,
isLoading,
execute
} = useAsyncState<AssetItem[]>(fetchAssets, [], { immediate: false })
const { isUploadButtonEnabled, showUploadDialog } =
useModelUpload(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 {
searchQuery,
@@ -149,6 +153,8 @@ const {
updateFilters
} = useAssetBrowser(fetchedAssets)
const modelToNodeStore = useModelToNodeStore()
const primaryCategoryTag = computed(() => {
const assets = fetchedAssets.value ?? []
const tagFromAssets = assets
@@ -196,4 +202,6 @@ 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

@@ -0,0 +1,29 @@
<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>
<div
<CardContainer
ref="cardContainerRef"
role="button"
:aria-label="
@@ -11,114 +11,112 @@
: $t('assetBrowser.ariaLabel.loadingAsset')
"
:tabindex="loading ? -1 : 0"
: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'
)
"
size="mini"
variant="ghost"
rounded="lg"
:class="containerClasses"
:data-selected="selected"
@click.stop="$emit('click')"
@contextmenu.prevent="handleContextMenu"
>
<!-- 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"
<template #top>
<CardTop
ratio="square"
:bottom-left-class="durationChipClasses"
:bottom-right-class="durationChipClasses"
>
<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">
<!-- Loading State -->
<template v-if="loading">
<div
class="h-4 w-24 animate-pulse rounded bg-modal-card-background"
class="size-full animate-pulse rounded-lg bg-modal-card-placeholder-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>
</template>
<!-- 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>
<!-- 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>
</div>
<!-- Right side: Output count -->
<div v-if="showOutputCount" class="flex-shrink-0">
<!-- 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>
<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>
</div>
</div>
</div>
</div>
<!-- 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>
<MediaAssetContextMenu
v-if="asset"
@@ -133,17 +131,16 @@
</template>
<script setup lang="ts">
import { useElementHover, whenever } from '@vueuse/core'
import { useElementHover, useMediaQuery, 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,
formatSize,
getFilenameDetails,
getMediaTypeFromFilename
} from '@/utils/formatUtil'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
import { getAssetType } from '../composables/media/assetMappers'
@@ -152,7 +149,6 @@ 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: {
@@ -160,6 +156,12 @@ 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'))
}
}
@@ -167,6 +169,10 @@ 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,
@@ -203,6 +209,7 @@ const showVideoControls = ref(false)
const imageDimensions = ref<{ width: number; height: number } | undefined>()
const isHovered = useElementHover(cardContainerRef)
const isTouch = useMediaQuery('(hover: none)')
const actions = useMediaAssetActions()
@@ -216,11 +223,6 @@ 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
@@ -246,6 +248,15 @@ 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
@@ -259,22 +270,39 @@ const formattedDuration = computed(() => {
return formatDuration(Number(duration))
})
// 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}`
const durationChipClasses = computed(() => {
if (fileKind.value === 'audio') {
return '-translate-y-11'
}
if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {
return formatSize(asset.size)
if (fileKind.value === 'video' && showVideoControls.value) {
return '-translate-y-16'
}
return ''
})
const showActionsOverlay = computed(() => {
if (loading || !asset) return false
return isHovered.value || selected || isVideoPlaying.value
})
// 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 handleZoomClick = () => {
if (asset) {

View File

@@ -0,0 +1,29 @@
<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

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

View File

@@ -0,0 +1,26 @@
<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,11 +3,13 @@ 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(
onUploadSuccess?: () => Promise<unknown> | void
execute?: UseAsyncStateReturn<AssetItem[], [], true>['execute']
) {
const dialogStore = useDialogStore()
const { flags } = useFeatureFlags()
@@ -35,7 +37,7 @@ export function useModelUpload(
component: UploadModelDialog,
props: {
onUploadSuccess: async () => {
await onUploadSuccess?.()
await execute?.()
}
},
dialogComponentProps: {

View File

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

View File

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

View File

@@ -1,19 +1,32 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import ToggleSwitch from 'primevue/toggleswitch'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import ToggleGroup from '@/components/ui/toggle-group/ToggleGroup.vue'
import ToggleGroupItem from '@/components/ui/toggle-group/ToggleGroupItem.vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetToggleSwitch from './WidgetToggleSwitch.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'widgets.boolean.true': 'true',
'widgets.boolean.false': 'false'
}
return translations[key] || key
}
})
}))
describe('WidgetToggleSwitch Value Binding', () => {
const createMockWidget = (
value: boolean = false,
options: IWidgetOptions = {},
options: Record<string, unknown> = {},
callback?: (value: boolean) => void
): SimplifiedWidget<boolean, IWidgetOptions> => ({
): SimplifiedWidget<boolean> => ({
name: 'test_toggle',
type: 'boolean',
value,
@@ -34,7 +47,7 @@ describe('WidgetToggleSwitch Value Binding', () => {
},
global: {
plugins: [PrimeVue],
components: { ToggleSwitch }
components: { ToggleSwitch, ToggleGroup, ToggleGroupItem }
}
})
}
@@ -150,46 +163,81 @@ describe('WidgetToggleSwitch Value Binding', () => {
})
})
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' })
describe('Label Display', () => {
it('uses ToggleGroup when labels are provided', () => {
const widget = createMockWidget(false, { on: 'Enabled', off: 'Disabled' })
const wrapper = mountComponent(widget, false)
expect(wrapper.text()).toContain('outside')
expect(wrapper.findComponent({ name: 'ToggleGroup' }).exists()).toBe(true)
expect(wrapper.findComponent({ name: 'ToggleSwitch' }).exists()).toBe(
false
)
})
it('does not display label when no on/off options provided', () => {
it('uses ToggleSwitch when no labels are provided', () => {
const widget = createMockWidget(false, {})
const wrapper = mountComponent(widget, false)
expect(wrapper.find('span').exists()).toBe(false)
expect(wrapper.findComponent({ name: 'ToggleSwitch' }).exists()).toBe(
true
)
expect(wrapper.findComponent({ name: 'ToggleGroup' }).exists()).toBe(
false
)
})
it('updates label when value changes', async () => {
const widget = createMockWidget(false, { on: 'enabled', off: 'disabled' })
it('displays both label_on and label_off in ToggleGroup', () => {
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')
expect(wrapper.text()).toContain('Enabled')
expect(wrapper.text()).toContain('Disabled')
})
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')
it('displays correct active state for false', () => {
const widget = createMockWidget(false, { on: 'Enabled', off: 'Disabled' })
const wrapper = mountComponent(widget, false)
const widgetOffOnly = createMockWidget(false, { off: 'inactive' })
const wrapperOff = mountComponent(widgetOffOnly, false)
expect(wrapperOff.text()).toContain('inactive')
const toggleGroup = wrapper.findComponent({ name: 'ToggleGroup' })
expect(toggleGroup.props('modelValue')).toBe('off')
})
it('displays correct active state for true', () => {
const widget = createMockWidget(true, { on: 'Enabled', off: 'Disabled' })
const wrapper = mountComponent(widget, true)
const toggleGroup = wrapper.findComponent({ name: 'ToggleGroup' })
expect(toggleGroup.props('modelValue')).toBe('on')
})
it('updates active state when toggled', async () => {
const widget = createMockWidget(false, {
on: 'Markdown',
off: 'Plaintext'
})
const wrapper = mountComponent(widget, false)
const toggleGroup = wrapper.findComponent({ name: 'ToggleGroup' })
expect(toggleGroup.props('modelValue')).toBe('off')
await wrapper.setProps({ modelValue: true })
expect(toggleGroup.props('modelValue')).toBe('on')
})
it('emits update:modelValue when ToggleGroup item is clicked', async () => {
const widget = createMockWidget(false, {
on: 'Markdown',
off: 'Plaintext'
})
const wrapper = mountComponent(widget, false)
const toggleGroup = wrapper.findComponent({ name: 'ToggleGroup' })
await toggleGroup.vm.$emit('update:modelValue', 'on')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain(true)
})
})
})

View File

@@ -1,35 +1,43 @@
<template>
<WidgetLayoutField :widget>
<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 :widget="widgetWithStyle">
<!-- Use ToggleGroup when explicit labels are provided -->
<!-- The variant attribute is not necessary here because the default is secondary -->
<!-- It was still added to show that a variant (3) can be explicitly set -->
<ToggleGroup
v-if="hasLabels"
type="single"
variant="secondary"
:model-value="toggleGroupValue"
class="flex justify-end w-full mb-[-0.5rem]"
@update:model-value="handleToggleGroupChange"
>
<ToggleGroupItem value="off" :aria-label="`${widget.name}: ${labelOff}`">
{{ labelOff }}
</ToggleGroupItem>
<ToggleGroupItem value="on" :aria-label="`${widget.name}: ${labelOn}`">
{{ labelOn }}
</ToggleGroupItem>
</ToggleGroup>
<!-- Use ToggleSwitch for implicit boolean states -->
<ToggleSwitch
v-else
v-model="modelValue"
v-bind="filteredProps"
class="ml-auto block"
:aria-label="widget.name"
/>
</WidgetLayoutField>
</template>
<script setup lang="ts">
import ToggleSwitch from 'primevue/toggleswitch'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import ToggleGroup from '@/components/ui/toggle-group/ToggleGroup.vue'
import ToggleGroupItem from '@/components/ui/toggle-group/ToggleGroupItem.vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
@@ -37,19 +45,50 @@ import {
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
interface BooleanWidgetOptions {
on?: string
off?: string
}
const { widget } = defineProps<{
widget: SimplifiedWidget<boolean, IWidgetOptions>
widget: SimplifiedWidget<boolean, BooleanWidgetOptions>
}>()
const modelValue = defineModel<boolean>()
const { t } = useI18n()
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')
const hasLabels = computed(() => {
return !!(widget.options?.on || widget.options?.off)
})
const labelOn = computed(() => widget.options?.on ?? t('widgets.boolean.true'))
const labelOff = computed(
() => widget.options?.off ?? t('widgets.boolean.false')
)
const toggleGroupValue = computed(() => {
return modelValue.value ? 'on' : 'off'
})
function handleToggleGroupChange(value: unknown) {
if (value === 'on') {
modelValue.value = true
} else if (value === 'off') {
modelValue.value = false
}
}
// Override WidgetLayoutField styling when using ToggleGroup
const widgetWithStyle = computed(() => ({
...widget,
borderStyle: hasLabels.value
? 'focus-within:ring-0 bg-transparent rounded-none focus-within:outline-none'
: undefined,
labelStyle: hasLabels.value ? 'mb-[-0.5rem]' : undefined
}))
</script>

View File

@@ -8,7 +8,9 @@ defineProps<{
widget: Pick<
SimplifiedWidget<string | number | undefined>,
'name' | 'label' | 'borderStyle'
>
> & {
labelStyle?: string
}
}>()
const hideLayoutField = inject<boolean>('hideLayoutField', false)
@@ -18,7 +20,10 @@ const hideLayoutField = inject<boolean>('hideLayoutField', false)
<div
class="grid grid-cols-subgrid min-w-0 justify-between gap-1 text-node-component-slot-text"
>
<div v-if="!hideLayoutField" class="truncate content-center-safe">
<div
v-if="!hideLayoutField"
:class="cn('truncate content-center-safe', widget.labelStyle)"
>
<template v-if="widget.name">
{{ widget.label || widget.name }}
</template>

View File

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

View File

@@ -1,5 +1,4 @@
import { useAsyncState } from '@vueuse/core'
import { isEqual } from 'es-toolkit'
import { defineStore } from 'pinia'
import { computed, shallowReactive, ref, watch } from 'vue'
import {
@@ -280,81 +279,59 @@ 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[]> {
return updateModelsForKey(nodeType, () =>
assetService.getAssetsForNodeType(nodeType)
)
}
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
)
}
}
)
)
}
/**
* 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))
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)
}
}
return {
modelAssetsByNodeType,
modelLoadingByNodeType,
modelErrorByNodeType,
updateModelsForNodeType,
updateModelsForTag
updateModelsForNodeType
}
}
@@ -362,8 +339,7 @@ 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 () => [],
updateModelsForTag: async () => []
updateModelsForNodeType: async () => []
}
}
@@ -371,8 +347,7 @@ export const useAssetsStore = defineStore('assets', () => {
modelAssetsByNodeType,
modelLoadingByNodeType,
modelErrorByNodeType,
updateModelsForNodeType,
updateModelsForTag
updateModelsForNodeType
} = getModelState()
// Watch for completed downloads and refresh model caches
@@ -428,7 +403,6 @@ export const useAssetsStore = defineStore('assets', () => {
modelAssetsByNodeType,
modelLoadingByNodeType,
modelErrorByNodeType,
updateModelsForNodeType,
updateModelsForTag
updateModelsForNodeType
}
})

View File

@@ -1,70 +0,0 @@
<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

@@ -1,43 +0,0 @@
<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

@@ -1,12 +0,0 @@
<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('[aria-label*="Conflict:"]')
const conflictItems = wrapper.findAll('.conflict-list-item')
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('[aria-label*="Conflict:"]')
const conflictItems = wrapper.findAll('.conflict-list-item')
expect(conflictItems).toHaveLength(3) // 2 from Package1 + 1 from Package2
})
@@ -338,9 +338,7 @@ describe('NodeConflictDialogContent', () => {
await importFailedHeader.trigger('click')
// Should display only import failed package
const importFailedItems = wrapper.findAll(
'[aria-label*="Import failed package:"]'
)
const importFailedItems = wrapper.findAll('.conflict-list-item')
expect(importFailedItems).toHaveLength(1)
expect(importFailedItems[0].text()).toContain('Test Package 3')
})

View File

@@ -50,8 +50,7 @@
<div
v-for="(packageName, i) in importFailedConflicts"
:key="i"
: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"
class="conflict-list-item flex h-6 shrink-0 items-center justify-between px-4"
>
<span class="text-xs text-muted">
{{ packageName }}
@@ -99,8 +98,7 @@
<div
v-for="(conflict, i) in allConflictDetails"
:key="i"
: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"
class="conflict-list-item flex h-6 shrink-0 items-center justify-between px-4"
>
<span class="text-xs text-muted">{{
getConflictMessage(conflict, t)
@@ -148,7 +146,7 @@
<div
v-for="conflictResult in conflictData"
:key="conflictResult.package_id"
class="flex min-h-6 shrink-0 hover:bg-node-component-surface-hovered items-center justify-between px-4 py-1"
class="conflict-list-item flex h-6 shrink-0 items-center justify-between px-4"
>
<span class="text-xs text-muted">
{{ conflictResult.package_name }}
@@ -238,3 +236,8 @@ 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="icon-[lucide--triangle-alert] text-gold-600"></i>
<i class="pi pi-exclamation-triangle text-lg"></i>
<!-- Title -->
<p class="text-base font-bold">
{{ $t('manager.conflicts.title') }}

View File

@@ -41,8 +41,6 @@ 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<{
@@ -55,7 +53,6 @@ const { isPackEnabled, enablePack, disablePack, installedPacks } =
const { getConflictsForPackageByID } = useConflictDetectionStore()
const { showNodeConflictDialog } = useDialogService()
const { acknowledgmentState, markConflictsAsSeen } = useConflictAcknowledgment()
const { showImportFailedDialog } = useImportFailedDetection(nodePack.id || '')
const isLoading = ref(false)
@@ -84,36 +81,23 @@ const canToggleDirectly = computed(() => {
const showConflictModal = (skipModalDismissed: boolean) => {
let modal_dismissed = acknowledgmentState.value.modal_dismissed
if (skipModalDismissed) modal_dismissed = false
if (packageConflict.value && !modal_dismissed) {
// 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()
}
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()
}
}
})
}
}

View File

@@ -1,37 +1,43 @@
<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-secondary-background/60 px-2 py-1"
class="rounded-md bg-yellow-800/20 p-3"
>
<!-- 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">
<div class="flex items-center justify-between">
<div class="flex-1 text-sm break-words">
{{ getConflictMessage(conflict, $t) }}
</div>
</template>
</div>
</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 { conflictResult } = defineProps<{
const { nodePack, conflictResult } = defineProps<{
nodePack: components['schemas']['Node']
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': {
error: 'Import error',
msg: '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: 'Import error',
current_value: 'installed',
required_value: 'Import error'
})
})

View File

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

View File

@@ -44,8 +44,7 @@ describe('useImportFailedDetection', () => {
>
mockDialogService = {
showErrorDialog: vi.fn(),
showImportFailedNodeDialog: vi.fn()
showErrorDialog: vi.fn()
} as unknown as ReturnType<typeof dialogService.useDialogService>
vi.mocked(comfyManagerStore.useComfyManagerStore).mockReturnValue(
@@ -227,22 +226,13 @@ describe('useImportFailedDetection', () => {
showImportFailedDialog()
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
expect(mockDialogService.showErrorDialog).toHaveBeenCalledWith(
expect.any(Error),
{
title: 'manager.failedToInstall',
reportType: 'importFailedError'
}
})
)
})
it('should handle null packageId', () => {

View File

@@ -1,13 +1,11 @@
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,
ConflictDetectionResult
} from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import type { ConflictDetail } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
/**
* Extracting import failed conflicts from conflict list
@@ -26,18 +24,22 @@ function extractImportFailedConflicts(conflicts?: ConflictDetail[] | null) {
* Creating import failed dialog
*/
function createImportFailedDialog() {
const { showImportFailedNodeDialog } = useDialogService()
const { t } = useI18n()
const { showErrorDialog } = useDialogService()
return (
conflictedPackages: ConflictDetectionResult[] | null,
onClose?: () => void
) => {
if (conflictedPackages && conflictedPackages.length > 0) {
showImportFailedNodeDialog({
conflictedPackages,
dialogComponentProps: {
onClose
}
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'
})
}
}
@@ -72,16 +74,13 @@ export function useImportFailedDetection(
return importFailedInfo.value !== null
})
const openDialog = createImportFailedDialog()
const showImportFailedDialog = createImportFailedDialog()
return {
importFailedInfo,
importFailed,
showImportFailedDialog: (onClose?: () => void) => {
if (conflicts.value) {
openDialog([conflicts.value], onClose)
}
},
showImportFailedDialog: () =>
showImportFailedDialog(importFailedInfo.value),
isInstalled
}
}

View File

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