mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-07 06:00:03 +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:
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
211
src/platform/assets/utils/createModelNodeFromAsset.ts
Normal file
211
src/platform/assets/utils/createModelNodeFromAsset.ts
Normal 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 }
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user