mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-11 08:20:53 +00:00
## Summary Add asset browser dialog integration for combo widgets with full animation support and proper state management. (Thank you Claude from saving me me from merge conflict hell on this one.) ## Changes - Widget integration: combo widgets now use AssetBrowserModal for eligible asset types - Dialog animations: added animateHide() for smooth close transitions - Async operations: proper sequencing of widget updates and dialog animations - Service layer: added getAssetsForNodeType() and getAssetDetails() methods - Type safety: comprehensive TypeScript types and error handling - Test coverage: unit tests for all new functionality - Bonus: fixed the hardcoded labels in AssetFilterBar Widget behavior: - Shows asset browser button for eligible widgets when asset API enabled - Handles asset selection with proper callback sequencing - Maintains widget value updates and litegraph notification ## Review Focus I will call out some stuff inline. ## Screenshots https://github.com/user-attachments/assets/9d3a72cf-d2b0-445f-8022-4c49daa04637 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5629-feat-integrate-asset-browser-with-widget-system-2726d73d365081a9a98be9a2307aee0b) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: GitHub Action <action@github.com>
218 lines
5.7 KiB
TypeScript
218 lines
5.7 KiB
TypeScript
import { computed, ref } from 'vue'
|
|
|
|
import { d, t } from '@/i18n'
|
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
|
import { assetFilenameSchema } from '@/platform/assets/schemas/assetSchema'
|
|
import { assetService } from '@/platform/assets/services/assetService'
|
|
import {
|
|
getAssetBaseModel,
|
|
getAssetDescription
|
|
} from '@/platform/assets/utils/assetMetadataUtils'
|
|
import { formatSize } from '@/utils/formatUtil'
|
|
|
|
type AssetBadge = {
|
|
label: string
|
|
type: 'type' | 'base' | 'size'
|
|
}
|
|
|
|
// Display properties for transformed assets
|
|
export interface AssetDisplayItem extends AssetItem {
|
|
description: string
|
|
formattedSize: string
|
|
badges: AssetBadge[]
|
|
stats: {
|
|
formattedDate?: string
|
|
downloadCount?: string
|
|
stars?: string
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Asset Browser composable
|
|
* Manages search, filtering, asset transformation and selection logic
|
|
*/
|
|
export function useAssetBrowser(assets: AssetItem[] = []) {
|
|
// State
|
|
const searchQuery = ref('')
|
|
const selectedCategory = ref('all')
|
|
const sortBy = ref('name')
|
|
|
|
// Transform API asset to display asset
|
|
function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
|
|
// Extract description from metadata or create from tags
|
|
const typeTag = asset.tags.find((tag) => tag !== 'models')
|
|
const description =
|
|
getAssetDescription(asset) ||
|
|
`${typeTag || t('assetBrowser.unknown')} model`
|
|
|
|
// Format file size
|
|
const formattedSize = formatSize(asset.size)
|
|
|
|
// Create badges from tags and metadata
|
|
const badges: AssetBadge[] = []
|
|
|
|
// Type badge from non-root tag
|
|
if (typeTag) {
|
|
badges.push({ label: typeTag, type: 'type' })
|
|
}
|
|
|
|
// Base model badge from metadata
|
|
const baseModel = getAssetBaseModel(asset)
|
|
if (baseModel) {
|
|
badges.push({
|
|
label: baseModel,
|
|
type: 'base'
|
|
})
|
|
}
|
|
|
|
// Size badge
|
|
badges.push({ label: formattedSize, type: 'size' })
|
|
|
|
// Create display stats from API data
|
|
const stats = {
|
|
formattedDate: d(new Date(asset.created_at), { dateStyle: 'short' }),
|
|
downloadCount: undefined, // Not available in API
|
|
stars: undefined // Not available in API
|
|
}
|
|
|
|
return {
|
|
...asset,
|
|
description,
|
|
formattedSize,
|
|
badges,
|
|
stats
|
|
}
|
|
}
|
|
|
|
// Extract available categories from assets
|
|
const availableCategories = computed(() => {
|
|
const categorySet = new Set<string>()
|
|
|
|
assets.forEach((asset) => {
|
|
// Second tag is the category (after 'models' root tag)
|
|
if (asset.tags.length > 1 && asset.tags[0] === 'models') {
|
|
categorySet.add(asset.tags[1])
|
|
}
|
|
})
|
|
|
|
return [
|
|
{
|
|
id: 'all',
|
|
label: t('assetBrowser.allModels'),
|
|
icon: 'icon-[lucide--folder]'
|
|
},
|
|
...Array.from(categorySet)
|
|
.sort()
|
|
.map((category) => ({
|
|
id: category,
|
|
label: category.charAt(0).toUpperCase() + category.slice(1),
|
|
icon: 'icon-[lucide--package]'
|
|
}))
|
|
]
|
|
})
|
|
|
|
// Compute content title from selected category
|
|
const contentTitle = computed(() => {
|
|
if (selectedCategory.value === 'all') {
|
|
return t('assetBrowser.allModels')
|
|
}
|
|
|
|
const category = availableCategories.value.find(
|
|
(cat) => cat.id === selectedCategory.value
|
|
)
|
|
return category?.label || t('assetBrowser.assets')
|
|
})
|
|
|
|
// Filter functions
|
|
const filterByCategory = (category: string) => (asset: AssetItem) => {
|
|
if (category === 'all') return true
|
|
return asset.tags.includes(category)
|
|
}
|
|
|
|
const filterByQuery = (query: string) => (asset: AssetItem) => {
|
|
if (!query) return true
|
|
const lowerQuery = query.toLowerCase()
|
|
const description = getAssetDescription(asset)
|
|
return (
|
|
asset.name.toLowerCase().includes(lowerQuery) ||
|
|
(description && description.toLowerCase().includes(lowerQuery)) ||
|
|
asset.tags.some((tag) => tag.toLowerCase().includes(lowerQuery))
|
|
)
|
|
}
|
|
|
|
// Computed filtered and transformed assets
|
|
const filteredAssets = computed(() => {
|
|
const filtered = assets
|
|
.filter(filterByCategory(selectedCategory.value))
|
|
.filter(filterByQuery(searchQuery.value))
|
|
|
|
// Sort assets
|
|
filtered.sort((a, b) => {
|
|
switch (sortBy.value) {
|
|
case 'date':
|
|
return (
|
|
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
|
)
|
|
case 'name':
|
|
default:
|
|
return a.name.localeCompare(b.name)
|
|
}
|
|
})
|
|
|
|
// Transform to display format
|
|
return filtered.map(transformAssetForDisplay)
|
|
})
|
|
|
|
/**
|
|
* Asset selection that fetches full details and executes callback with filename
|
|
* @param assetId - The asset ID to select and fetch details for
|
|
* @param onSelect - Optional callback to execute with the asset filename
|
|
*/
|
|
async function selectAssetWithCallback(
|
|
assetId: string,
|
|
onSelect?: (filename: string) => void
|
|
): Promise<void> {
|
|
if (import.meta.env.DEV) {
|
|
console.debug('Asset selected:', assetId)
|
|
}
|
|
|
|
if (!onSelect) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
const detailAsset = await assetService.getAssetDetails(assetId)
|
|
const filename = detailAsset.user_metadata?.filename
|
|
const validatedFilename = assetFilenameSchema.safeParse(filename)
|
|
if (!validatedFilename.success) {
|
|
console.error(
|
|
'Invalid asset filename:',
|
|
validatedFilename.error.errors,
|
|
'for asset:',
|
|
assetId
|
|
)
|
|
return
|
|
}
|
|
|
|
onSelect(validatedFilename.data)
|
|
} catch (error) {
|
|
console.error(`Failed to fetch asset details for ${assetId}:`, error)
|
|
}
|
|
}
|
|
|
|
return {
|
|
// State
|
|
searchQuery,
|
|
selectedCategory,
|
|
sortBy,
|
|
|
|
// Computed
|
|
availableCategories,
|
|
contentTitle,
|
|
filteredAssets,
|
|
|
|
// Actions
|
|
selectAssetWithCallback
|
|
}
|
|
}
|