mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-25 16:59:45 +00:00
Full Asset Selection Experience (Assets API) (#5900)
## Summary Full Integration of Asset Browsing and Selection when Assets API is enabled. ## Changes 1. Replace Model Left Side Tab with experience 2. Configurable titles for the Asset Browser Modal 3. Refactors to simplify callback code 4. Refactor to make modal filters reactive (they change their values based on assets displayed) 5. Add `browse()` mode with ability to create node directly from the Asset Browser Modal (in `browse()` mode) ## Screenshots Demo of many different types of Nodes getting configured by the Modal https://github.com/user-attachments/assets/34f9c964-cdf2-4c5d-86a9-a8e7126a7de9 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5900-Feat-asset-selection-cloud-integration-2816d73d365081ccb4aeecdc14b0e5d3) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -3,8 +3,6 @@ import { computed, ref } from 'vue'
|
||||
import { d, t } from '@/i18n'
|
||||
import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vue'
|
||||
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
|
||||
@@ -161,9 +159,13 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
|
||||
return category?.label || t('assetBrowser.assets')
|
||||
})
|
||||
|
||||
// Category-filtered assets for filter options (before search/format/base model filters)
|
||||
const categoryFilteredAssets = computed(() => {
|
||||
return assets.filter(filterByCategory(selectedCategory.value))
|
||||
})
|
||||
|
||||
const filteredAssets = computed(() => {
|
||||
const filtered = assets
|
||||
.filter(filterByCategory(selectedCategory.value))
|
||||
const filtered = categoryFilteredAssets.value
|
||||
.filter(filterByQuery(searchQuery.value))
|
||||
.filter(filterByFileFormats(filters.value.fileFormats))
|
||||
.filter(filterByBaseModels(filters.value.baseModels))
|
||||
@@ -189,39 +191,6 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
|
||||
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 (!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)
|
||||
}
|
||||
}
|
||||
|
||||
function updateFilters(newFilters: FilterState) {
|
||||
filters.value = { ...newFilters }
|
||||
}
|
||||
@@ -231,8 +200,8 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
|
||||
selectedCategory,
|
||||
availableCategories,
|
||||
contentTitle,
|
||||
categoryFilteredAssets,
|
||||
filteredAssets,
|
||||
selectAssetWithCallback,
|
||||
updateFilters
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,54 @@
|
||||
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 { type DialogComponentProps, useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
interface AssetBrowserDialogProps {
|
||||
interface ShowOptions {
|
||||
/** ComfyUI node type for context (e.g., 'CheckpointLoaderSimple') */
|
||||
nodeType: string
|
||||
/** Widget input name (e.g., 'ckpt_name') */
|
||||
inputName: string
|
||||
/** Current selected asset value */
|
||||
currentValue?: string
|
||||
/**
|
||||
* Callback for when an asset is selected
|
||||
* @param {string} filename - The validated filename from user_metadata.filename
|
||||
*/
|
||||
onAssetSelected?: (filename: string) => void
|
||||
onAssetSelected?: (asset: AssetItem) => void
|
||||
}
|
||||
|
||||
interface BrowseOptions {
|
||||
/** Asset type tag to filter by (e.g., 'models') */
|
||||
assetType: string
|
||||
/** Custom modal title (optional) */
|
||||
title?: string
|
||||
/** Called when asset selected */
|
||||
onAssetSelected?: (asset: AssetItem) => void
|
||||
}
|
||||
|
||||
const dialogComponentProps: DialogComponentProps = {
|
||||
headless: true,
|
||||
modal: true,
|
||||
closable: true,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl overflow-hidden asset-browser-dialog'
|
||||
},
|
||||
header: {
|
||||
class: '!p-0 hidden'
|
||||
},
|
||||
content: {
|
||||
class: '!p-0 !m-0 h-full w-full'
|
||||
}
|
||||
}
|
||||
} as const
|
||||
|
||||
export const useAssetBrowserDialog = () => {
|
||||
const dialogStore = useDialogStore()
|
||||
const dialogKey = 'global-asset-browser'
|
||||
|
||||
async function show(props: AssetBrowserDialogProps) {
|
||||
const handleAssetSelected = (filename: string) => {
|
||||
props.onAssetSelected?.(filename)
|
||||
async function show(props: ShowOptions) {
|
||||
const handleAssetSelected = (asset: AssetItem) => {
|
||||
props.onAssetSelected?.(asset)
|
||||
dialogStore.closeDialog({ key: dialogKey })
|
||||
}
|
||||
const dialogComponentProps: DialogComponentProps = {
|
||||
headless: true,
|
||||
modal: true,
|
||||
closable: true,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl overflow-hidden asset-browser-dialog'
|
||||
},
|
||||
header: {
|
||||
class: '!p-0 hidden'
|
||||
},
|
||||
content: {
|
||||
class: '!p-0 !m-0 h-full w-full'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const assets: AssetItem[] = await assetService
|
||||
.getAssetsForNodeType(props.nodeType)
|
||||
@@ -54,6 +61,22 @@ export const useAssetBrowserDialog = () => {
|
||||
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,
|
||||
@@ -62,6 +85,7 @@ export const useAssetBrowserDialog = () => {
|
||||
inputName: props.inputName,
|
||||
currentValue: props.currentValue,
|
||||
assets,
|
||||
title,
|
||||
onSelect: handleAssetSelected,
|
||||
onClose: () => dialogStore.closeDialog({ key: dialogKey })
|
||||
},
|
||||
@@ -69,5 +93,38 @@ export const useAssetBrowserDialog = () => {
|
||||
})
|
||||
}
|
||||
|
||||
return { show }
|
||||
async function browse(options: BrowseOptions): Promise<void> {
|
||||
const handleAssetSelected = (asset: AssetItem) => {
|
||||
options.onAssetSelected?.(asset)
|
||||
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,
|
||||
title: options.title,
|
||||
onSelect: handleAssetSelected,
|
||||
onClose: () => dialogStore.closeDialog({ key: dialogKey })
|
||||
},
|
||||
dialogComponentProps
|
||||
})
|
||||
}
|
||||
|
||||
return { show, browse }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { uniqWith } from 'es-toolkit'
|
||||
import { computed } from 'vue'
|
||||
import { type MaybeRefOrGetter, computed, toValue } from 'vue'
|
||||
|
||||
import type { SelectOption } from '@/components/input/types'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
@@ -8,13 +8,14 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
* Composable that extracts available filter options from asset data
|
||||
* Provides reactive computed properties for file formats and base models
|
||||
*/
|
||||
export function useAssetFilterOptions(assets: AssetItem[] = []) {
|
||||
export function useAssetFilterOptions(assets: MaybeRefOrGetter<AssetItem[]>) {
|
||||
/**
|
||||
* Extract unique file formats from asset names
|
||||
* Returns sorted SelectOption array with extensions
|
||||
*/
|
||||
const availableFileFormats = computed<SelectOption[]>(() => {
|
||||
const extensions = assets
|
||||
const assetList = toValue(assets)
|
||||
const extensions = assetList
|
||||
.map((asset) => {
|
||||
const extension = asset.name.split('.').pop()
|
||||
return extension && extension !== asset.name ? extension : null
|
||||
@@ -34,7 +35,8 @@ export function useAssetFilterOptions(assets: AssetItem[] = []) {
|
||||
* Returns sorted SelectOption array with base model names
|
||||
*/
|
||||
const availableBaseModels = computed<SelectOption[]>(() => {
|
||||
const models = assets
|
||||
const assetList = toValue(assets)
|
||||
const models = assetList
|
||||
.map((asset) => asset.user_metadata?.base_model)
|
||||
.filter(
|
||||
(baseModel): baseModel is string =>
|
||||
|
||||
Reference in New Issue
Block a user