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:
Arjan Singh
2025-10-03 20:34:59 -07:00
committed by GitHub
parent 661885f5e5
commit abf2b3b980
22 changed files with 1452 additions and 554 deletions

View File

@@ -38,6 +38,7 @@ import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useKeybindingStore } from '@/stores/keybindingStore'
import { useUserStore } from '@/stores/userStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -51,6 +52,7 @@ import SidebarTemplatesButton from './SidebarTemplatesButton.vue'
const workspaceStore = useWorkspaceStore()
const settingStore = useSettingStore()
const userStore = useUserStore()
const commandStore = useCommandStore()
const teleportTarget = computed(() =>
settingStore.get('Comfy.Sidebar.Location') === 'left'
@@ -64,9 +66,12 @@ const isSmall = computed(
const tabs = computed(() => workspaceStore.getSidebarTabs())
const selectedTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
const onTabClick = (item: SidebarTabExtension) => {
workspaceStore.sidebarTab.toggleSidebarTab(item.id)
}
const onTabClick = async (item: SidebarTabExtension) =>
await commandStore.commands
.find((cmd) => cmd.id === `Workspace.ToggleSidebarTab.${item.id}`)
?.function?.()
const keybindingStore = useKeybindingStore()
const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
const keybinding = keybindingStore.getKeybindingByCommandId(

View File

@@ -48,7 +48,10 @@
<main class="flex flex-col flex-1 min-h-0">
<!-- Fallback title bar when no leftPanel is provided -->
<slot name="contentFilter"></slot>
<h2 v-if="!$slots.leftPanel" class="text-xxl px-6 pt-2 pb-6 m-0">
<h2
v-if="!$slots.leftPanel"
class="text-xxl px-6 pt-2 pb-6 m-0 capitalize"
>
{{ contentTitle }}
</h2>
<div :class="contentContainerClasses">

View File

@@ -15,6 +15,8 @@ import {
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { Point } from '@/lib/litegraph/src/litegraph'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
@@ -1062,6 +1064,30 @@ export function useCoreCommands(): ComfyCommand[] {
}
await api.freeMemory({ freeExecutionCache: true })
}
},
{
id: 'Comfy.BrowseModelAssets',
icon: 'pi pi-folder-open',
label: 'Browse Model Assets',
versionAdded: '1.28.3',
function: async () => {
const assetBrowserDialog = useAssetBrowserDialog()
await assetBrowserDialog.browse({
assetType: 'models',
title: t('sideToolbar.modelLibrary'),
onAssetSelected: (asset) => {
const result = createModelNodeFromAsset(asset)
if (!result.success) {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('assetBrowser.failedToCreateNode')
})
console.error('Node creation failed:', result.error)
}
}
})
}
}
]

View File

@@ -1942,9 +1942,11 @@
"tryAdjustingFilters": "Try adjusting your search or filters",
"loadingModels": "Loading {type}...",
"connectionError": "Please check your connection and try again",
"failedToCreateNode": "Failed to create node. Please try again or check console for details.",
"noModelsInFolder": "No {type} available in this folder",
"searchAssetsPlaceholder": "Search assets...",
"allModels": "All Models",
"allCategory": "All {category}",
"unknown": "Unknown",
"fileFormats": "File formats",
"baseModels": "Base models",

View File

@@ -2,7 +2,7 @@
<BaseModalLayout
data-component-id="AssetBrowserModal"
class="size-full max-h-full max-w-full min-w-0"
:content-title="contentTitle"
:content-title="displayTitle"
@close="handleClose"
>
<template v-if="shouldShowLeftPanel" #leftPanel>
@@ -14,7 +14,9 @@
<template #header-icon>
<div class="icon-[lucide--folder] size-4" />
</template>
<template #header-title>{{ $t('assetBrowser.browseAssets') }}</template>
<template #header-title>
<span class="capitalize">{{ displayTitle }}</span>
</template>
</LeftSidePanel>
</template>
@@ -28,7 +30,10 @@
</template>
<template #contentFilter>
<AssetFilterBar :assets="assets" @filter-change="updateFilters" />
<AssetFilterBar
:assets="categoryFilteredAssets"
@filter-change="updateFilters"
/>
</template>
<template #content>
@@ -56,10 +61,11 @@ import { OnCloseKey } from '@/types/widgetTypes'
const props = defineProps<{
nodeType?: string
inputName?: string
onSelect?: (assetPath: string) => void
onSelect?: (asset: AssetItem) => void
onClose?: () => void
showLeftPanel?: boolean
assets?: AssetItem[]
title?: string
}>()
const emit = defineEmits<{
@@ -74,11 +80,15 @@ const {
selectedCategory,
availableCategories,
contentTitle,
categoryFilteredAssets,
filteredAssets,
selectAssetWithCallback,
updateFilters
} = useAssetBrowser(props.assets)
const displayTitle = computed(() => {
return props.title ?? contentTitle.value
})
const shouldShowLeftPanel = computed(() => {
return props.showLeftPanel ?? true
})
@@ -88,8 +98,10 @@ function handleClose() {
emit('close')
}
async function handleAssetSelectAndEmit(asset: AssetDisplayItem) {
function handleAssetSelectAndEmit(asset: AssetDisplayItem) {
emit('asset-select', asset)
await selectAssetWithCallback(asset.id, props.onSelect)
// onSelect callback is provided by dialog composable layer
// It handles the appropriate transformation (filename extraction or full asset)
props.onSelect?.(asset)
}
</script>

View File

@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import {
createAssetWithSpecificBaseModel,
@@ -6,6 +7,7 @@ import {
createAssetWithoutBaseModel,
createAssetWithoutExtension
} from '@/platform/assets/fixtures/ui-mock-assets'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import AssetFilterBar from './AssetFilterBar.vue'
@@ -46,14 +48,19 @@ export default meta
type Story = StoryObj<typeof meta>
export const BothFiltersVisible: Story = {
args: {
assets: [
createAssetWithSpecificExtension('safetensors'),
createAssetWithSpecificExtension('ckpt'),
createAssetWithSpecificBaseModel('sd15'),
createAssetWithSpecificBaseModel('sdxl')
]
},
render: () => ({
components: { AssetFilterBar },
setup() {
const assets = ref<AssetItem[]>([
createAssetWithSpecificExtension('safetensors'),
createAssetWithSpecificExtension('ckpt'),
createAssetWithSpecificBaseModel('sd15'),
createAssetWithSpecificBaseModel('sdxl')
])
return { assets }
},
template: '<AssetFilterBar :assets="assets" />'
}),
parameters: {
docs: {
description: {
@@ -65,16 +72,23 @@ export const BothFiltersVisible: Story = {
}
export const OnlyFileFormatFilter: Story = {
args: {
assets: [
// Assets with extensions but explicitly NO base models
{
...createAssetWithSpecificExtension('safetensors'),
user_metadata: undefined
},
{ ...createAssetWithSpecificExtension('ckpt'), user_metadata: undefined }
]
},
render: () => ({
components: { AssetFilterBar },
setup() {
const assets = ref<AssetItem[]>([
{
...createAssetWithSpecificExtension('safetensors'),
user_metadata: undefined
},
{
...createAssetWithSpecificExtension('ckpt'),
user_metadata: undefined
}
])
return { assets }
},
template: '<AssetFilterBar :assets="assets" />'
}),
parameters: {
docs: {
description: {
@@ -86,16 +100,20 @@ export const OnlyFileFormatFilter: Story = {
}
export const OnlyBaseModelFilter: Story = {
args: {
assets: [
// Assets with base models but no recognizable extensions
{
...createAssetWithSpecificBaseModel('sd15'),
name: 'model_without_extension'
},
{ ...createAssetWithSpecificBaseModel('sdxl'), name: 'another_model' }
]
},
render: () => ({
components: { AssetFilterBar },
setup() {
const assets = ref<AssetItem[]>([
{
...createAssetWithSpecificBaseModel('sd15'),
name: 'model_without_extension'
},
{ ...createAssetWithSpecificBaseModel('sdxl'), name: 'another_model' }
])
return { assets }
},
template: '<AssetFilterBar :assets="assets" />'
}),
parameters: {
docs: {
description: {
@@ -107,9 +125,14 @@ export const OnlyBaseModelFilter: Story = {
}
export const NoFiltersVisible: Story = {
args: {
assets: []
},
render: () => ({
components: { AssetFilterBar },
setup() {
const assets = ref<AssetItem[]>([])
return { assets }
},
template: '<AssetFilterBar :assets="assets" />'
}),
parameters: {
docs: {
description: {
@@ -121,9 +144,17 @@ export const NoFiltersVisible: Story = {
}
export const NoFiltersFromAssetsWithoutOptions: Story = {
args: {
assets: [createAssetWithoutExtension(), createAssetWithoutBaseModel()]
},
render: () => ({
components: { AssetFilterBar },
setup() {
const assets = ref<AssetItem[]>([
createAssetWithoutExtension(),
createAssetWithoutBaseModel()
])
return { assets }
},
template: '<AssetFilterBar :assets="assets" />'
}),
parameters: {
docs: {
description: {
@@ -133,3 +164,102 @@ export const NoFiltersFromAssetsWithoutOptions: Story = {
}
}
}
export const CategorySwitchingReactivity: Story = {
render: () => ({
components: { AssetFilterBar },
setup() {
const selectedCategory = ref('all')
const checkpointAssets: AssetItem[] = [
{
...createAssetWithSpecificExtension('safetensors'),
tags: ['models', 'checkpoints'],
user_metadata: { base_model: 'sd15' }
},
{
...createAssetWithSpecificExtension('safetensors'),
tags: ['models', 'checkpoints'],
user_metadata: { base_model: 'sdxl' }
}
]
const loraAssets: AssetItem[] = [
{
...createAssetWithSpecificExtension('pt'),
tags: ['models', 'loras'],
user_metadata: { base_model: 'sd15' }
},
{
...createAssetWithSpecificExtension('pt'),
tags: ['models', 'loras'],
user_metadata: undefined
}
]
const allAssets = [...checkpointAssets, ...loraAssets]
const categoryFilteredAssets = ref<AssetItem[]>(allAssets)
const switchCategory = (category: string) => {
selectedCategory.value = category
categoryFilteredAssets.value =
category === 'all'
? allAssets
: category === 'checkpoints'
? checkpointAssets
: loraAssets
}
return { categoryFilteredAssets, selectedCategory, switchCategory }
},
template: `
<div class="space-y-4 p-4">
<div class="flex gap-2">
<button
@click="switchCategory('all')"
:class="[
'px-4 py-2 rounded border',
selectedCategory === 'all'
? 'bg-blue-500 text-white border-blue-600'
: 'bg-white dark-theme:bg-charcoal-700 border-gray-300 dark-theme:border-charcoal-600'
]"
>
All (.safetensors + .pt, sd15 + sdxl)
</button>
<button
@click="switchCategory('checkpoints')"
:class="[
'px-4 py-2 rounded border',
selectedCategory === 'checkpoints'
? 'bg-blue-500 text-white border-blue-600'
: 'bg-white dark-theme:bg-charcoal-700 border-gray-300 dark-theme:border-charcoal-600'
]"
>
Checkpoints (.safetensors, sd15 + sdxl)
</button>
<button
@click="switchCategory('loras')"
:class="[
'px-4 py-2 rounded border',
selectedCategory === 'loras'
? 'bg-blue-500 text-white border-blue-600'
: 'bg-white dark-theme:bg-charcoal-700 border-gray-300 dark-theme:border-charcoal-600'
]"
>
LoRAs (.pt, sd15 only)
</button>
</div>
<AssetFilterBar :assets="categoryFilteredAssets" />
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Interactive demo showing filter options updating reactively when category changes. Click buttons to see filters adapt to the selected category.'
}
}
}
}

View File

@@ -67,13 +67,10 @@ const sortBy = ref('name-asc')
const { availableFileFormats, availableBaseModels } =
useAssetFilterOptions(assets)
// TODO: Make sortOptions configurable via props
// Different asset types might need different sorting options
const sortOptions = [
{ name: t('assetBrowser.sortAZ'), value: 'name-asc' },
{ name: t('assetBrowser.sortZA'), value: 'name-desc' },
{ name: t('assetBrowser.sortRecent'), value: 'recent' },
{ name: t('assetBrowser.sortPopular'), value: 'popular' }
{ name: t('assetBrowser.sortRecent'), value: 'recent' }
]
const emit = defineEmits<{

View File

@@ -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
}
}

View File

@@ -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 }
}

View File

@@ -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 =>

View File

@@ -42,6 +42,7 @@ export const assetFilenameSchema = z
.trim()
// Export schemas following repository patterns
export const assetItemSchema = zAsset
export const assetResponseSchema = zAssetResponse
// Export types derived from Zod schemas

View File

@@ -11,14 +11,11 @@ import { api } from '@/scripts/api'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
const ASSETS_ENDPOINT = '/assets'
const MODELS_TAG = 'models'
const MISSING_TAG = 'missing'
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
const DEFAULT_LIMIT = 300
/**
* Input names that are eligible for asset browser
*/
const WHITELISTED_INPUTS = new Set(['ckpt_name', 'lora_name', 'vae_name'])
export const MODELS_TAG = 'models'
export const MISSING_TAG = 'missing'
/**
* Validates asset response data using Zod schema
@@ -66,7 +63,7 @@ function createAssetService() {
*/
async function getAssetModelFolders(): Promise<ModelFolder[]> {
const data = await handleAssetRequest(
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG}`,
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG}&limit=${DEFAULT_LIMIT}`,
'model folders'
)
@@ -95,7 +92,7 @@ function createAssetService() {
*/
async function getAssetModels(folder: string): Promise<ModelFile[]> {
const data = await handleAssetRequest(
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${folder}`,
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${folder}&limit=${DEFAULT_LIMIT}`,
`models for ${folder}`
)
@@ -115,19 +112,12 @@ function createAssetService() {
/**
* Checks if a widget input should use the asset browser based on both input name and node comfyClass
*
* @param inputName - The input name (e.g., 'ckpt_name', 'lora_name')
* @param nodeType - The ComfyUI node comfyClass (e.g., 'CheckpointLoaderSimple', 'LoraLoader')
* @returns true if this input should use asset browser
*/
function isAssetBrowserEligible(
inputName: string,
nodeType: string
): boolean {
function isAssetBrowserEligible(nodeType: string = ''): boolean {
return (
// Must be an approved input name
WHITELISTED_INPUTS.has(inputName) &&
// Must be a registered node type
useModelToNodeStore().getRegisteredNodeTypes().has(nodeType)
!!nodeType && useModelToNodeStore().getRegisteredNodeTypes().has(nodeType)
)
}
@@ -153,7 +143,7 @@ function createAssetService() {
// Fetch assets for this category using same API pattern as getAssetModels
const data = await handleAssetRequest(
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${category}`,
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${category}&limit=${DEFAULT_LIMIT}`,
`assets for ${nodeType}`
)
@@ -196,12 +186,30 @@ function createAssetService() {
)
}
/**
* Gets assets filtered by a specific tag
*
* @param tag - The tag to filter by (e.g., 'models')
* @returns Promise<AssetItem[]> - Full asset objects filtered by tag, excluding missing assets
*/
async function getAssetsByTag(tag: string): Promise<AssetItem[]> {
const data = await handleAssetRequest(
`${ASSETS_ENDPOINT}?include_tags=${tag}&limit=${DEFAULT_LIMIT}`,
`assets for tag ${tag}`
)
return (
data?.assets?.filter((asset) => !asset.tags.includes(MISSING_TAG)) ?? []
)
}
return {
getAssetModelFolders,
getAssetModels,
isAssetBrowserEligible,
getAssetsForNodeType,
getAssetDetails
getAssetDetails,
getAssetsByTag
}
}

View File

@@ -0,0 +1,211 @@
import {
type LGraphNode,
LiteGraph,
type Point
} from '@/lib/litegraph/src/litegraph'
import {
type AssetItem,
assetItemSchema
} from '@/platform/assets/schemas/assetSchema'
import {
MISSING_TAG,
MODELS_TAG
} from '@/platform/assets/services/assetService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
interface CreateNodeOptions {
position?: Point
}
type NodeCreationErrorCode =
| 'INVALID_ASSET'
| 'NO_PROVIDER'
| 'NODE_CREATION_FAILED'
| 'MISSING_WIDGET'
| 'NO_GRAPH'
interface NodeCreationError {
code: NodeCreationErrorCode
message: string
assetId: string
details?: Record<string, unknown>
}
type Result<T, E> = { success: true; value: T } | { success: false; error: E }
/**
* Creates a LiteGraph node from an asset item.
*
* **Boundary Function**: Bridges Vue reactive domain with LiteGraph canvas domain.
*
* @param asset - Asset item to create node from (Vue domain)
* @param options - Optional position and configuration
* @returns Result with LiteGraph node (Canvas domain) or error details
*
* @remarks
* This function performs side effects on the canvas graph. Validation failures
* return error results rather than throwing to allow graceful degradation in UI contexts.
* Widget validation occurs before graph mutation to prevent orphaned nodes.
*/
export function createModelNodeFromAsset(
asset: AssetItem,
options?: CreateNodeOptions
): Result<LGraphNode, NodeCreationError> {
const validatedAsset = assetItemSchema.safeParse(asset)
if (!validatedAsset.success) {
const errorMessage = validatedAsset.error.errors
.map((e) => `${e.path.join('.')}: ${e.message}`)
.join(', ')
console.error('Invalid asset item:', errorMessage)
return {
success: false,
error: {
code: 'INVALID_ASSET',
message: 'Asset schema validation failed',
assetId: asset.id,
details: { validationErrors: errorMessage }
}
}
}
const validAsset = validatedAsset.data
const userMetadata = validAsset.user_metadata
if (!userMetadata) {
console.error(`Asset ${validAsset.id} missing required user_metadata`)
return {
success: false,
error: {
code: 'INVALID_ASSET',
message: 'Asset missing required user_metadata',
assetId: validAsset.id
}
}
}
const filename = userMetadata.filename
if (typeof filename !== 'string' || filename.length === 0) {
console.error(
`Asset ${validAsset.id} has invalid user_metadata.filename (expected non-empty string, got ${typeof filename})`
)
return {
success: false,
error: {
code: 'INVALID_ASSET',
message: `Invalid filename (expected non-empty string, got ${typeof filename})`,
assetId: validAsset.id
}
}
}
if (validAsset.tags.length === 0) {
console.error(
`Asset ${validAsset.id} has no tags defined (expected at least one category tag)`
)
return {
success: false,
error: {
code: 'INVALID_ASSET',
message: 'Asset has no tags defined',
assetId: validAsset.id
}
}
}
const category = validAsset.tags.find(
(tag) => tag !== MODELS_TAG && tag !== MISSING_TAG
)
if (!category) {
console.error(
`Asset ${validAsset.id} has no valid category tag. Available tags: ${validAsset.tags.join(', ')} (expected tag other than '${MODELS_TAG}' or '${MISSING_TAG}')`
)
return {
success: false,
error: {
code: 'INVALID_ASSET',
message: 'Asset has no valid category tag',
assetId: validAsset.id,
details: { availableTags: validAsset.tags }
}
}
}
const modelToNodeStore = useModelToNodeStore()
const provider = modelToNodeStore.getNodeProvider(category)
if (!provider) {
console.error(`No node provider registered for category: ${category}`)
return {
success: false,
error: {
code: 'NO_PROVIDER',
message: `No node provider registered for category: ${category}`,
assetId: validAsset.id,
details: { category }
}
}
}
const litegraphService = useLitegraphService()
const pos = options?.position ?? litegraphService.getCanvasCenter()
const node = LiteGraph.createNode(
provider.nodeDef.name,
provider.nodeDef.display_name,
{ pos }
)
if (!node) {
console.error(`Failed to create node for type: ${provider.nodeDef.name}`)
return {
success: false,
error: {
code: 'NODE_CREATION_FAILED',
message: `Failed to create node for type: ${provider.nodeDef.name}`,
assetId: validAsset.id,
details: { nodeType: provider.nodeDef.name }
}
}
}
const workflowStore = useWorkflowStore()
const targetGraph = workflowStore.isSubgraphActive
? workflowStore.activeSubgraph
: app.canvas.graph
if (!targetGraph) {
console.error('No active graph available')
return {
success: false,
error: {
code: 'NO_GRAPH',
message: 'No active graph available',
assetId: validAsset.id
}
}
}
const widget = node.widgets?.find((w) => w.name === provider.key)
if (!widget) {
console.error(
`Widget ${provider.key} not found on node ${provider.nodeDef.name}`
)
return {
success: false,
error: {
code: 'MISSING_WIDGET',
message: `Widget ${provider.key} not found on node ${provider.nodeDef.name}`,
assetId: validAsset.id,
details: { widgetName: provider.key, nodeType: provider.nodeDef.name }
}
}
}
targetGraph.add(node)
widget.value = filename
return { success: true, value: node }
}

View File

@@ -6,6 +6,10 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isAssetWidget, isComboWidget } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import {
assetFilenameSchema,
assetItemSchema
} from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { useSettingStore } from '@/platform/settings/settingStore'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
@@ -62,13 +66,9 @@ const addComboWidget = (
): IBaseWidget => {
const settingStore = useSettingStore()
const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI')
const isEligible = assetService.isAssetBrowserEligible(
inputSpec.name,
node.comfyClass || ''
)
const isEligible = assetService.isAssetBrowserEligible(node.comfyClass)
if (isUsingAssetAPI && isEligible) {
// Get the default value for the button text (currently selected model)
const currentValue = getDefaultValue(inputSpec)
const displayLabel = currentValue ?? t('widgets.selectModel')
@@ -86,11 +86,40 @@ const addComboWidget = (
nodeType: node.comfyClass || '',
inputName: inputSpec.name,
currentValue: widget.value,
onAssetSelected: (filename: string) => {
onAssetSelected: (asset) => {
const validatedAsset = assetItemSchema.safeParse(asset)
if (!validatedAsset.success) {
console.error(
'Invalid asset item:',
validatedAsset.error.errors,
'Received:',
asset
)
return
}
const filename = validatedAsset.data.user_metadata?.filename
const validatedFilename = assetFilenameSchema.safeParse(filename)
if (!validatedFilename.success) {
console.error(
'Invalid asset filename:',
validatedFilename.error.errors,
'for asset:',
validatedAsset.data.id
)
return
}
const oldValue = widget.value
widget.value = filename
// Using onWidgetChanged prevents a callback race where asset selection could reopen the dialog
node.onWidgetChanged?.(widget.name, filename, oldValue, widget)
widget.value = validatedFilename.data
node.onWidgetChanged?.(
widget.name,
validatedFilename.data,
oldValue,
widget
)
}
})
}

View File

@@ -5,6 +5,7 @@ import { useModelLibrarySidebarTab } from '@/composables/sidebarTabs/useModelLib
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
import { useQueueSidebarTab } from '@/composables/sidebarTabs/useQueueSidebarTab'
import { t, te } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowsSidebarTab } from '@/platform/workflow/management/composables/useWorkflowsSidebarTab'
import { useCommandStore } from '@/stores/commandStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
@@ -63,7 +64,20 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
tooltip: tooltipFunction,
versionAdded: '1.3.9',
category: 'view-controls' as const,
function: () => {
function: async () => {
const settingStore = useSettingStore()
const commandStore = useCommandStore()
if (
tab.id === 'model-library' &&
settingStore.get('Comfy.Assets.UseAssetAPI')
) {
await commandStore.commands
.find((cmd) => cmd.id === 'Comfy.BrowseModelAssets')
?.function?.()
return
}
toggleSidebarTab(tab.id)
},
active: () => activeSidebarTab.value?.id === tab.id,