mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-22 23:39:45 +00:00
Compare commits
10 Commits
pysssss/fi
...
claude/sla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d930514bea | ||
|
|
99cb7a2da1 | ||
|
|
b3d87673ec | ||
|
|
6a733918a7 | ||
|
|
a87d2cf1bd | ||
|
|
a1d689d3b3 | ||
|
|
dc64e16f7c | ||
|
|
c19a004f0d | ||
|
|
626d8dac70 | ||
|
|
b6a12ddae1 |
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user