mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-25 01:04:06 +00:00
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:
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user