Merge main (as of 10-06-2025) into rh-test (#5965)

## Summary

Merges latest changes from `main` as of 10-06-2025.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5965-Merge-main-as-of-10-06-2025-into-rh-test-2856d73d3650812cb95fd8917278a770)
by [Unito](https://www.unito.io)

---------

Signed-off-by: Marcel Petrick <mail@marcelpetrick.it>
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Benjamin Lu <benceruleanlu@proton.me>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: snomiao <snomiao@gmail.com>
Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
Co-authored-by: Jake Schroeder <jake.schroeder@isophex.com>
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
Co-authored-by: AustinMroz <4284322+AustinMroz@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: Marcel Petrick <mail@marcelpetrick.it>
Co-authored-by: Alexander Brown <DrJKL0424@gmail.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
Co-authored-by: JakeSchroeder <jake@axiom.co>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
Co-authored-by: ComfyUI Wiki <contact@comfyui-wiki.com>
This commit is contained in:
Arjan Singh
2025-10-08 19:06:40 -07:00
committed by GitHub
parent 529a4de583
commit 5869b04e57
454 changed files with 32333 additions and 37002 deletions

View File

@@ -57,12 +57,10 @@ export const Default: Story = {
render: (args) => ({
components: { AssetBrowserModal },
setup() {
const onAssetSelect = (asset: AssetDisplayItem) => {
console.log('Selected asset:', asset)
}
const onClose = () => {
console.log('Modal closed')
const onAssetSelect = (_asset: AssetDisplayItem) => {
// Asset selection handler for story
}
const onClose = () => {}
return {
...args,
@@ -97,11 +95,11 @@ export const SingleAssetType: Story = {
render: (args) => ({
components: { AssetBrowserModal },
setup() {
const onAssetSelect = (asset: AssetDisplayItem) => {
console.log('Selected asset:', asset)
const onAssetSelect = (_asset: AssetDisplayItem) => {
// Asset selection handler for story
}
const onClose = () => {
console.log('Modal closed')
// Modal close handler for story
}
// Create assets with only one type (checkpoints)
@@ -146,11 +144,11 @@ export const NoLeftPanel: Story = {
render: (args) => ({
components: { AssetBrowserModal },
setup() {
const onAssetSelect = (asset: AssetDisplayItem) => {
console.log('Selected asset:', asset)
const onAssetSelect = (_asset: AssetDisplayItem) => {
// Asset selection handler for story
}
const onClose = () => {
console.log('Modal closed')
// Modal close handler for story
}
return { ...args, onAssetSelect, onClose, assets: mockAssets }

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,13 +14,16 @@
<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>
<template #header>
<SearchBox
v-model="searchQuery"
:autofocus="true"
size="lg"
:placeholder="$t('assetBrowser.searchAssetsPlaceholder')"
class="max-w-96"
@@ -28,7 +31,10 @@
</template>
<template #contentFilter>
<AssetFilterBar :assets="assets" @filter-change="updateFilters" />
<AssetFilterBar
:assets="categoryFilteredAssets"
@filter-change="updateFilters"
/>
</template>
<template #content>
@@ -56,10 +62,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 +81,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 +99,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,43 +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 (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)
}
}
function updateFilters(newFilters: FilterState) {
filters.value = { ...newFilters }
}
@@ -235,8 +200,8 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
selectedCategory,
availableCategories,
contentTitle,
categoryFilteredAssets,
filteredAssets,
selectAssetWithCallback,
updateFilters
}
}

View File

@@ -29,7 +29,6 @@ const DialogDemoComponent = {
}
const handleAssetSelected = (assetPath: string) => {
console.log('Asset selected:', assetPath)
alert(`Selected asset: ${assetPath}`)
isDialogOpen.value = false // Auto-close like the real composable
}

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

@@ -4,16 +4,16 @@ import { z } from 'zod'
const zAsset = z.object({
id: z.string(),
name: z.string(),
asset_hash: z.string().nullable(),
asset_hash: z.string().optional(),
size: z.number(),
mime_type: z.string().nullable(),
tags: z.array(z.string()),
mime_type: z.string().optional(),
tags: z.array(z.string()).optional().default([]),
preview_id: z.string().nullable().optional(),
preview_url: z.string().optional(),
created_at: z.string(),
updated_at: z.string().optional(),
last_access_time: z.string(),
user_metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
preview_id: z.string().nullable().optional()
last_access_time: z.string().optional(),
user_metadata: z.record(z.unknown()).optional() // API allows arbitrary key-value pairs
})
const zAssetResponse = z.object({
@@ -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,13 +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
@@ -27,7 +25,9 @@ function validateAssetResponse(data: unknown): AssetResponse {
if (result.success) return result.data
const error = fromZodError(result.error)
throw new Error(`Invalid asset response against zod schema:\n${error}`)
throw new Error(
`${EXPERIMENTAL_WARNING}Invalid asset response against zod schema:\n${error}`
)
}
/**
@@ -45,7 +45,7 @@ function createAssetService() {
const res = await api.fetchApi(url)
if (!res.ok) {
throw new Error(
`Unable to load ${context}: Server returned ${res.status}. Please try again.`
`${EXPERIMENTAL_WARNING}Unable to load ${context}: Server returned ${res.status}. Please try again.`
)
}
const data = await res.json()
@@ -63,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'
)
@@ -92,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}`
)
@@ -112,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)
)
}
@@ -150,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}`
)
@@ -174,7 +167,7 @@ function createAssetService() {
const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`)
if (!res.ok) {
throw new Error(
`Unable to load asset details for ${id}: Server returned ${res.status}. Please try again.`
`${EXPERIMENTAL_WARNING}Unable to load asset details for ${id}: Server returned ${res.status}. Please try again.`
)
}
const data = await res.json()
@@ -188,7 +181,26 @@ function createAssetService() {
const error = result.error
? fromZodError(result.error)
: 'Unknown validation error'
throw new Error(`Invalid asset response against zod schema:\n${error}`)
throw new Error(
`${EXPERIMENTAL_WARNING}Invalid asset response against zod schema:\n${error}`
)
}
/**
* 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 {
@@ -196,7 +208,8 @@ function createAssetService() {
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

@@ -986,7 +986,7 @@ export const CORE_SETTINGS: SettingParams[] = [
name: 'Auto Save',
type: 'combo',
options: ['off', 'after delay'], // Room for other options like on focus change, tab change, window change
defaultValue: 'off', // Popular requst by users (https://github.com/Comfy-Org/ComfyUI_frontend/issues/1584#issuecomment-2536610154)
defaultValue: 'off', // Popular request by users (https://github.com/Comfy-Org/ComfyUI_frontend/issues/1584#issuecomment-2536610154)
versionAdded: '1.16.0'
},
{
@@ -1052,7 +1052,7 @@ export const CORE_SETTINGS: SettingParams[] = [
{
id: 'Comfy.Assets.UseAssetAPI',
name: 'Use Asset API for model library',
type: 'boolean',
type: 'hidden',
tooltip: 'Use new Asset API for model browsing',
defaultValue: false,
experimental: true

View File

@@ -195,7 +195,7 @@ defineExpose({
bottom: calc(
var(--sidebar-width) * 2 + var(--sidebar-icon-size) / 2 -
var(--whats-new-popup-bottom)
); /* Position to center of help center icon (2 icons below + half icon height for center - whats new popup bottom position ) */
); /* Position to center of help center icon (2 icons below + half icon height for center - what's new popup bottom position ) */
}
/* Sidebar positioning classes applied by parent */

View File

@@ -3,7 +3,11 @@ import { defineStore } from 'pinia'
import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue'
import { t } from '@/i18n'
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import type {
LGraph,
LGraphNode,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
@@ -87,7 +91,6 @@ export class ComfyWorkflow extends UserFile {
}
// Note: originalContent is populated by super.load()
console.debug('load and start tracking of workflow', this.path)
this.changeTracker = markRaw(
new ChangeTracker(
this,
@@ -98,7 +101,6 @@ export class ComfyWorkflow extends UserFile {
}
override unload(): void {
console.debug('unload workflow', this.path)
this.changeTracker = null
super.unload()
}
@@ -184,6 +186,7 @@ interface WorkflowStore {
updateActiveGraph: () => void
executionIdToCurrentId: (id: string) => any
nodeIdToNodeLocatorId: (nodeId: NodeId, subgraph?: Subgraph) => NodeLocatorId
nodeToNodeLocatorId: (node: LGraphNode) => NodeLocatorId
nodeExecutionIdToNodeLocatorId: (
nodeExecutionId: NodeExecutionId | string
) => NodeLocatorId | null
@@ -302,7 +305,6 @@ export const useWorkflowStore = defineStore('workflow', () => {
const loadedWorkflow = await workflow.load()
activeWorkflow.value = loadedWorkflow
comfyApp.canvas.bg_tint = loadedWorkflow.tintCanvasBg
console.debug('[workflowStore] open workflow', workflow.path)
return loadedWorkflow
}
@@ -379,7 +381,6 @@ export const useWorkflowStore = defineStore('workflow', () => {
} else {
workflow.unload()
}
console.debug('[workflowStore] close workflow', workflow.path)
}
/**
@@ -581,6 +582,17 @@ export const useWorkflowStore = defineStore('workflow', () => {
return createNodeLocatorId(targetSubgraph.id, nodeId)
}
/**
* Convert a node to a NodeLocatorId
* Does not assume the node resides in the active graph
* @param The actual node instance
* @returns The NodeLocatorId (for root graph nodes, returns the node ID as-is)
*/
const nodeToNodeLocatorId = (node: LGraphNode): NodeLocatorId => {
if (isSubgraph(node.graph))
return createNodeLocatorId(node.graph.id, node.id)
return String(node.id)
}
/**
* Convert an execution ID to a NodeLocatorId
@@ -723,6 +735,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
updateActiveGraph,
executionIdToCurrentId,
nodeIdToNodeLocatorId,
nodeToNodeLocatorId,
nodeExecutionIdToNodeLocatorId,
nodeLocatorIdToNodeId,
nodeLocatorIdToNodeExecutionId

View File

@@ -104,7 +104,7 @@ export function useWorkflowPersistence() {
}
const paths = openWorkflows.value
.filter((workflow) => workflow?.isPersisted && !workflow.isModified)
.filter((workflow) => workflow?.isPersisted)
.map((workflow) => workflow.path)
const activeIndex = openWorkflows.value.findIndex(
(workflow) => workflow.path === activeWorkflow.value?.path

View File

@@ -219,7 +219,7 @@ const zSubgraphIO = zNodeInput.extend({
id: z.string().uuid(),
/** The data type this slot uses. Unlike nodes, this does not support legacy numeric types. */
type: z.string(),
/** Links connected to this slot, or `undefined` if not connected. An ouptut slot should only ever have one link. */
/** Links connected to this slot, or `undefined` if not connected. An output slot should only ever have one link. */
linkIds: z.array(z.number()).optional()
})