load assets browser before fetch completes and show loading state (#6189)

## Summary

Moves the fetch and post-fetch logic associated with the asset browser
into the component and shows a loading state while fetching.

To test, use this branch:
https://github.com/comfyanonymous/ComfyUI/pull/10045



https://github.com/user-attachments/assets/718974d5-efc7-46a0-bcd6-e82596d4c389

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6189-load-assets-browser-before-fetch-completes-and-show-loading-state-2946d73d365081879d1bd05d86e8c036)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Christian Byrne
2025-10-23 13:43:34 -07:00
committed by GitHub
parent 89ff8255bd
commit 8120ed9dfa
9 changed files with 271 additions and 282 deletions

View File

@@ -40,6 +40,7 @@
<template #content>
<AssetGrid
:assets="filteredAssets"
:loading="isLoading"
@asset-select="handleAssetSelectAndEmit"
/>
</template>
@@ -47,7 +48,9 @@
</template>
<script setup lang="ts">
import { computed, provide } from 'vue'
import { useAsyncState } from '@vueuse/core'
import { computed, provide, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/input/SearchBox.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
@@ -57,6 +60,9 @@ import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { OnCloseKey } from '@/types/widgetTypes'
const props = defineProps<{
@@ -65,10 +71,12 @@ const props = defineProps<{
onSelect?: (asset: AssetItem) => void
onClose?: () => void
showLeftPanel?: boolean
assets?: AssetItem[]
title?: string
assetType?: string
}>()
const { t } = useI18n()
const emit = defineEmits<{
'asset-select': [asset: AssetDisplayItem]
close: []
@@ -76,18 +84,73 @@ const emit = defineEmits<{
provide(OnCloseKey, props.onClose ?? (() => {}))
const fetchAssets = async () => {
if (props.nodeType) {
return (await assetService.getAssetsForNodeType(props.nodeType)) ?? []
}
if (props.assetType) {
return (await assetService.getAssetsByTag(props.assetType)) ?? []
}
return []
}
const {
state: fetchedAssets,
isLoading,
execute
} = useAsyncState<AssetItem[]>(fetchAssets, [], { immediate: false })
watch(
() => [props.nodeType, props.assetType],
async () => {
await execute()
},
{ immediate: true }
)
const {
searchQuery,
selectedCategory,
availableCategories,
contentTitle,
categoryFilteredAssets,
filteredAssets,
updateFilters
} = useAssetBrowser(props.assets)
} = useAssetBrowser(fetchedAssets)
const modelToNodeStore = useModelToNodeStore()
const primaryCategoryTag = computed(() => {
const assets = fetchedAssets.value ?? []
const tagFromAssets = assets
.map((asset) => asset.tags?.find((tag) => tag !== 'models'))
.find((tag): tag is string => typeof tag === 'string' && tag.length > 0)
if (tagFromAssets) return tagFromAssets
if (props.nodeType) {
const mapped = modelToNodeStore.getCategoryForNodeType(props.nodeType)
if (mapped) return mapped
}
if (props.assetType) return props.assetType
return 'models'
})
const activeCategoryTag = computed(() => {
if (selectedCategory.value !== 'all') {
return selectedCategory.value
}
return primaryCategoryTag.value
})
const displayTitle = computed(() => {
return props.title ?? contentTitle.value
if (props.title) return props.title
const label = formatCategoryLabel(activeCategoryTag.value)
return t('assetBrowser.allCategory', { category: label })
})
const shouldShowLeftPanel = computed(() => {

View File

@@ -37,12 +37,12 @@
<!-- Loading state -->
<div
v-if="loading"
class="col-span-full flex items-center justify-center py-16"
class="col-span-full flex items-center justify-center py-20"
>
<i
class="icon-[lucide--loader]"
:class="
cn('size-6 animate-spin', 'text-stone-300 dark-theme:text-stone-200')
cn('size-12 animate-spin', 'text-stone-300 dark-theme:text-stone-200')
"
/>
</div>

View File

@@ -1,4 +1,5 @@
import { computed, ref } from 'vue'
import type { Ref } from 'vue'
import { d, t } from '@/i18n'
import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vue'
@@ -65,7 +66,10 @@ export interface AssetDisplayItem extends AssetItem {
* Asset Browser composable
* Manages search, filtering, asset transformation and selection logic
*/
export function useAssetBrowser(assets: AssetItem[] = []) {
export function useAssetBrowser(
assetsSource: Ref<AssetItem[] | undefined> = ref<AssetItem[] | undefined>([])
) {
const assets = computed<AssetItem[]>(() => assetsSource.value ?? [])
// State
const searchQuery = ref('')
const selectedCategory = ref('all')
@@ -116,9 +120,10 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
}
const availableCategories = computed(() => {
const categories = assets
.filter((asset) => asset.tags[0] === 'models' && asset.tags[1])
const categories = assets.value
.filter((asset) => asset.tags[0] === 'models')
.map((asset) => asset.tags[1])
.filter((tag): tag is string => typeof tag === 'string' && tag.length > 0)
const uniqueCategories = Array.from(new Set(categories))
.sort()
@@ -152,7 +157,7 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
// Category-filtered assets for filter options (before search/format/base model filters)
const categoryFilteredAssets = computed(() => {
return assets.filter(filterByCategory(selectedCategory.value))
return assets.value.filter(filterByCategory(selectedCategory.value))
})
const filteredAssets = computed(() => {
@@ -161,8 +166,8 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
.filter(filterByFileFormats(filters.value.fileFormats))
.filter(filterByBaseModels(filters.value.baseModels))
// Sort assets
filtered.sort((a, b) => {
const sortedAssets = [...filtered]
sortedAssets.sort((a, b) => {
switch (filters.value.sortBy) {
case 'name-desc':
return b.name.localeCompare(a.name)
@@ -179,7 +184,7 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
})
// Transform to display format
return filtered.map(transformAssetForDisplay)
return sortedAssets.map(transformAssetForDisplay)
})
function updateFilters(newFilters: FilterState) {

View File

@@ -1,7 +1,5 @@
import { t } from '@/i18n'
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { useDialogStore } from '@/stores/dialogStore'
import type { DialogComponentProps } from '@/stores/dialogStore'
@@ -51,33 +49,6 @@ export const useAssetBrowserDialog = () => {
dialogStore.closeDialog({ key: dialogKey })
}
const assets: AssetItem[] = await assetService
.getAssetsForNodeType(props.nodeType)
.catch((error) => {
console.error(
'Failed to fetch assets for node type:',
props.nodeType,
error
)
return []
})
// Extract node type category from first asset's tags (e.g., "loras", "checkpoints")
// Tags are ordered: ["models", "loras"] so take the second tag
const nodeTypeCategory =
assets[0]?.tags?.find((tag) => tag !== 'models') ?? 'models'
const acronyms = new Set(['VAE', 'CLIP', 'GLIGEN'])
const categoryLabel = nodeTypeCategory
.split('_')
.map((word) => {
const uc = word.toUpperCase()
return acronyms.has(uc) ? uc : word
})
.join(' ')
const title = t('assetBrowser.allCategory', { category: categoryLabel })
dialogStore.showDialog({
key: dialogKey,
component: AssetBrowserModal,
@@ -85,8 +56,6 @@ export const useAssetBrowserDialog = () => {
nodeType: props.nodeType,
inputName: props.inputName,
currentValue: props.currentValue,
assets,
title,
onSelect: handleAssetSelected,
onClose: () => dialogStore.closeDialog({ key: dialogKey })
},
@@ -100,25 +69,12 @@ export const useAssetBrowserDialog = () => {
dialogStore.closeDialog({ key: dialogKey })
}
const assets = await assetService
.getAssetsByTag(options.assetType)
.catch((error) => {
console.error(
'Failed to fetch assets for tag:',
options.assetType,
error
)
return []
})
dialogStore.showDialog({
key: dialogKey,
component: AssetBrowserModal,
props: {
nodeType: undefined,
inputName: undefined,
assets,
showLeftPanel: true,
assetType: options.assetType,
title: options.title,
onSelect: handleAssetSelected,
onClose: () => dialogStore.closeDialog({ key: dialogKey })

View File

@@ -4,9 +4,9 @@ import { z } from 'zod'
const zAsset = z.object({
id: z.string(),
name: z.string(),
asset_hash: z.string().optional(),
asset_hash: z.string().nullish(),
size: z.number(),
mime_type: z.string().optional(),
mime_type: z.string().nullish(),
tags: z.array(z.string()).optional().default([]),
preview_id: z.string().nullable().optional(),
preview_url: z.string().optional(),

View File

@@ -0,0 +1,16 @@
const ACRONYM_TAGS = new Set(['VAE', 'CLIP', 'GLIGEN'])
export function formatCategoryLabel(raw?: string): string {
if (!raw) return 'Models'
return raw
.split('_')
.map((segment) => {
const upper = segment.toUpperCase()
if (ACRONYM_TAGS.has(upper)) return upper
const lower = segment.toLowerCase()
return lower.charAt(0).toUpperCase() + lower.slice(1)
})
.join(' ')
}