Compare commits

...

3 Commits

Author SHA1 Message Date
bymyself
db86114d7d feat: read category from blueprint subgraph definition
Read category from definitions.subgraphs[0].category in blueprint
JSON files as a fallback default. Overrides from info.category or
explicit category params still take precedence.

Amp-Thread-ID: https://ampcode.com/threads/T-019c6f43-6212-7308-bea6-bfc35a486cbf
2026-02-20 21:54:40 -08:00
bymyself
15c98676e9 feat: filter global blueprints by requiresCustomNodes and includeOnDistributions
Filter global subgraph blueprints before loading to avoid unnecessary
data fetches. Blueprints with requiresCustomNodes are hidden on
non-cloud distributions. Blueprints with includeOnDistributions are
only shown on matching distributions.

Amp-Thread-ID: https://ampcode.com/threads/T-019c6f43-6212-7308-bea6-bfc35a486cbf
2026-02-18 17:29:35 -08:00
Christian Byrne
38edba7024 fix: exclude missing assets from cloud mode dropdown (COM-14333) (#8747)
## Summary

Fixes a bug where non-existent images appeared in the asset search
dropdown when loading workflows that reference images the user doesn't
have in cloud mode.

## Changes

- Add `displayItems` prop to `FormDropdown` and `FormDropdownInput` for
showing selected values that aren't in the dropdown list
- Exclude `missingValueItem` from cloud asset mode `dropdownItems` while
still displaying it in the input field via `displayItems`
- Use localized error messages in `ImagePreview` for missing images
(`g.imageDoesNotExist`, `g.unknownFile`)
- Add tests for cloud asset mode behavior in
`WidgetSelectDropdown.test.ts`

## Context

The `missingValueItem` was originally added in PR #8276 for template
workflows. This fix keeps that behavior for local mode but excludes it
from cloud asset mode dropdown. Cloud users can't access files they
don't own, so showing them as search results causes confusion.

## Testing

- Added unit tests for cloud asset mode behavior
- Verified existing tests pass
- All quality gates pass: typecheck, lint, format, tests

Fixes COM-14333



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8747-fix-exclude-missing-assets-from-cloud-mode-dropdown-COM-14333-3016d73d365081e3ab47c326d791257e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-13 14:30:55 -08:00
12 changed files with 392 additions and 21 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -94,6 +94,8 @@
"openNewIssue": "Open New Issue",
"showReport": "Show Report",
"imageFailedToLoad": "Image failed to load",
"imageDoesNotExist": "Image does not exist",
"unknownFile": "Unknown file",
"reconnecting": "Reconnecting",
"reconnected": "Reconnected",
"delete": "Delete",

View File

@@ -29,6 +29,8 @@ const i18n = createI18n({
failedToDownloadImage: 'Failed to download image',
calculatingDimensions: 'Calculating dimensions',
imageFailedToLoad: 'Image failed to load',
imageDoesNotExist: 'Image does not exist',
unknownFile: 'Unknown file',
loading: 'Loading'
}
}

View File

@@ -321,10 +321,11 @@ const handleKeyDown = (event: KeyboardEvent) => {
}
const getImageFilename = (url: string): string => {
if (!url) return t('g.imageDoesNotExist')
try {
return new URL(url).searchParams.get('filename') || 'Unknown file'
return new URL(url).searchParams.get('filename') || t('g.unknownFile')
} catch {
return 'Invalid URL'
return t('g.imageDoesNotExist')
}
}
</script>

View File

@@ -2,16 +2,31 @@ import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import { computed } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
const mockAssetsData = vi.hoisted(() => ({ items: [] as AssetItem[] }))
vi.mock(
'@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData',
() => ({
useAssetWidgetData: () => ({
category: computed(() => 'checkpoints'),
assets: computed(() => mockAssetsData.items),
isLoading: computed(() => false),
error: computed(() => null)
})
})
)
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -306,3 +321,133 @@ describe('WidgetSelectDropdown custom label mapping', () => {
})
})
})
describe('WidgetSelectDropdown cloud asset mode (COM-14333)', () => {
interface CloudModeInstance extends ComponentPublicInstance {
dropdownItems: FormDropdownItem[]
displayItems: FormDropdownItem[]
selectedSet: Set<string>
}
const createTestAsset = (
id: string,
name: string,
preview_url: string
): AssetItem => ({
id,
name,
preview_url,
tags: []
})
const createCloudModeWidget = (
value: string = 'model.safetensors'
): SimplifiedWidget<string | undefined> => ({
name: 'test_model_select',
type: 'combo',
value,
options: {
values: [],
nodeType: 'CheckpointLoaderSimple'
}
})
const mountCloudComponent = (
widget: SimplifiedWidget<string | undefined>,
modelValue: string | undefined
): VueWrapper<CloudModeInstance> => {
return mount(WidgetSelectDropdown, {
props: {
widget,
modelValue,
assetKind: 'model',
isAssetMode: true,
nodeType: 'CheckpointLoaderSimple'
},
global: {
plugins: [PrimeVue, createTestingPinia(), i18n]
}
}) as unknown as VueWrapper<CloudModeInstance>
}
beforeEach(() => {
mockAssetsData.items = []
})
it('does not include missing items in cloud asset mode dropdown', () => {
mockAssetsData.items = [
createTestAsset(
'asset-1',
'existing_model.safetensors',
'https://example.com/preview.jpg'
)
]
const widget = createCloudModeWidget('missing_model.safetensors')
const wrapper = mountCloudComponent(widget, 'missing_model.safetensors')
const dropdownItems = wrapper.vm.dropdownItems
expect(dropdownItems).toHaveLength(1)
expect(dropdownItems[0].name).toBe('existing_model.safetensors')
expect(
dropdownItems.some((item) => item.name === 'missing_model.safetensors')
).toBe(false)
})
it('shows only available cloud assets in dropdown', () => {
mockAssetsData.items = [
createTestAsset(
'asset-1',
'model_a.safetensors',
'https://example.com/a.jpg'
),
createTestAsset(
'asset-2',
'model_b.safetensors',
'https://example.com/b.jpg'
)
]
const widget = createCloudModeWidget('model_a.safetensors')
const wrapper = mountCloudComponent(widget, 'model_a.safetensors')
const dropdownItems = wrapper.vm.dropdownItems
expect(dropdownItems).toHaveLength(2)
expect(dropdownItems.map((item) => item.name)).toEqual([
'model_a.safetensors',
'model_b.safetensors'
])
})
it('returns empty dropdown when no cloud assets available', () => {
mockAssetsData.items = []
const widget = createCloudModeWidget('missing_model.safetensors')
const wrapper = mountCloudComponent(widget, 'missing_model.safetensors')
const dropdownItems = wrapper.vm.dropdownItems
expect(dropdownItems).toHaveLength(0)
})
it('includes missing cloud asset in displayItems for input field visibility', () => {
mockAssetsData.items = [
createTestAsset(
'asset-1',
'existing_model.safetensors',
'https://example.com/preview.jpg'
)
]
const widget = createCloudModeWidget('missing_model.safetensors')
const wrapper = mountCloudComponent(widget, 'missing_model.safetensors')
const displayItems = wrapper.vm.displayItems
expect(displayItems).toHaveLength(2)
expect(displayItems[0].name).toBe('missing_model.safetensors')
expect(displayItems[0].id).toBe('missing-missing_model.safetensors')
expect(displayItems[1].name).toBe('existing_model.safetensors')
const selectedSet = wrapper.vm.selectedSet
expect(selectedSet.has('missing-missing_model.safetensors')).toBe(true)
})
})

View File

@@ -254,9 +254,8 @@ const baseModelFilteredAssetItems = computed<FormDropdownItem[]>(() =>
const allItems = computed<FormDropdownItem[]>(() => {
if (props.isAssetMode && assetData) {
if (missingValueItem.value) {
return [missingValueItem.value, ...baseModelFilteredAssetItems.value]
}
// Cloud assets not in user's library shouldn't appear as search results (COM-14333).
// Unlike local mode, cloud users can't access files they don't own.
return baseModelFilteredAssetItems.value
}
return [
@@ -282,6 +281,17 @@ const dropdownItems = computed<FormDropdownItem[]>(() => {
}
})
/**
* Items used for display in the input field. In cloud mode, includes
* missing items so users can see their selected value even if not in library.
*/
const displayItems = computed<FormDropdownItem[]>(() => {
if (props.isAssetMode && assetData && missingValueItem.value) {
return [missingValueItem.value, ...baseModelFilteredAssetItems.value]
}
return dropdownItems.value
})
const mediaPlaceholder = computed(() => {
const options = props.widget.options
@@ -332,18 +342,20 @@ const acceptTypes = computed(() => {
const layoutMode = ref<LayoutMode>(props.defaultLayoutMode ?? 'grid')
watch(
[modelValue, dropdownItems],
([currentValue, _dropdownItems]) => {
[modelValue, displayItems],
([currentValue]) => {
if (currentValue === undefined) {
selectedSet.value.clear()
return
}
const item = dropdownItems.value.find((item) => item.name === currentValue)
if (item) {
const item = displayItems.value.find((item) => item.name === currentValue)
if (!item) {
selectedSet.value.clear()
selectedSet.value.add(item.id)
return
}
selectedSet.value.clear()
selectedSet.value.add(item.id)
},
{ immediate: true }
)
@@ -461,6 +473,7 @@ function getMediaUrl(
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:items="dropdownItems"
:display-items="displayItems"
:placeholder="mediaPlaceholder"
:multiple="false"
:uploadable

View File

@@ -18,6 +18,8 @@ import type { FormDropdownItem, LayoutMode, SortOption } from './types'
interface Props {
items: FormDropdownItem[]
/** Items used for display in the input field. Falls back to items if not provided. */
displayItems?: FormDropdownItem[]
placeholder?: string
/**
* If true, allows multiple selections. If a number is provided,
@@ -193,6 +195,7 @@ async function customSearcher(
:is-open
:placeholder="placeholderText"
:items
:display-items
:max-selectable
:selected
:uploadable

View File

@@ -10,6 +10,8 @@ interface Props {
isOpen?: boolean
placeholder?: string
items: FormDropdownItem[]
/** Items used for display in the input field. Falls back to items if not provided. */
displayItems?: FormDropdownItem[]
selected: Set<string>
maxSelectable: number
uploadable: boolean
@@ -28,7 +30,8 @@ const emit = defineEmits<{
}>()
const selectedItems = computed(() => {
return props.items.filter((item) => props.selected.has(item.id))
const itemsToSearch = props.displayItems ?? props.items
return itemsToSearch.filter((item) => props.selected.has(item.id))
})
const theButtonStyle = computed(() =>

View File

@@ -11,9 +11,10 @@ import type {
import { isCloud } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { IFuseOptions } from 'fuse.js'
import {
type TemplateInfo,
type WorkflowTemplates
import type {
TemplateIncludeOnDistributionEnum,
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import type {
ComfyApiWorkflow,
@@ -235,6 +236,8 @@ export type GlobalSubgraphData = {
node_pack: string
category?: string
search_aliases?: string[]
requiresCustomNodes?: string[]
includeOnDistributions?: TemplateIncludeOnDistributionEnum[]
}
data: string | Promise<string>
}

View File

@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import type { GlobalSubgraphData } from '@/scripts/api'
import type { ExportedSubgraph } from '@/lib/litegraph/src/types/serialisation'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
@@ -16,6 +17,12 @@ import {
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { createTestingPinia } from '@pinia/testing'
const mockDistributionTypes = vi.hoisted(() => ({
isCloud: false,
isDesktop: false
}))
vi.mock('@/platform/distribution/types', () => mockDistributionTypes)
// Mock telemetry to break circular dependency (telemetry → workflowStore → app → telemetry)
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => null
@@ -85,6 +92,8 @@ describe('useSubgraphStore', () => {
}
beforeEach(() => {
mockDistributionTypes.isCloud = false
mockDistributionTypes.isDesktop = false
setActivePinia(createTestingPinia({ stubActions: false }))
store = useSubgraphStore()
vi.clearAllMocks()
@@ -305,4 +314,174 @@ describe('useSubgraphStore', () => {
expect(subgraphExtra?.BlueprintSearchAliases).toBeUndefined()
})
})
describe('subgraph definition category', () => {
it('should use category from subgraph definition as default', async () => {
const mockGraphWithCategory = {
nodes: [{ type: '123' }],
definitions: {
subgraphs: [{ id: '123', category: 'Image Processing' }]
}
}
await mockFetch(
{},
{
categorized: {
name: 'Categorized Blueprint',
info: { node_pack: 'test_pack' },
data: JSON.stringify(mockGraphWithCategory)
}
}
)
const nodeDef = useNodeDefStore().nodeDefs.find(
(d) => d.name === 'SubgraphBlueprint.categorized'
)
expect(nodeDef).toBeDefined()
expect(nodeDef?.category).toBe('Subgraph Blueprints/Image Processing')
})
it('should use User override for user blueprints even with definition category', async () => {
const mockGraphWithCategory = {
nodes: [{ type: '123' }],
definitions: {
subgraphs: [{ id: '123', category: 'Image Processing' }]
}
}
await mockFetch({ 'user-bp.json': mockGraphWithCategory })
const nodeDef = useNodeDefStore().nodeDefs.find(
(d) => d.name === 'SubgraphBlueprint.user-bp'
)
expect(nodeDef).toBeDefined()
expect(nodeDef?.category).toBe('Subgraph Blueprints/User')
})
it('should fallback to bare Subgraph Blueprints when no category anywhere', async () => {
await mockFetch(
{},
{
no_cat_global: {
name: 'No Category Global',
info: { node_pack: 'test_pack' },
data: JSON.stringify(mockGraph)
}
}
)
const nodeDef = useNodeDefStore().nodeDefs.find(
(d) => d.name === 'SubgraphBlueprint.no_cat_global'
)
expect(nodeDef).toBeDefined()
expect(nodeDef?.category).toBe('Subgraph Blueprints')
})
it('should let overrides take precedence over definition category', async () => {
const mockGraphWithCategory = {
nodes: [{ type: '123' }],
definitions: {
subgraphs: [{ id: '123', category: 'Image Processing' }]
}
}
await mockFetch(
{},
{
bp_override: {
name: 'Override Blueprint',
info: {
node_pack: 'test_pack',
category: 'Custom Category'
},
data: JSON.stringify(mockGraphWithCategory)
}
}
)
const nodeDef = useNodeDefStore().nodeDefs.find(
(d) => d.name === 'SubgraphBlueprint.bp_override'
)
expect(nodeDef).toBeDefined()
expect(nodeDef?.category).toBe('Subgraph Blueprints/Custom Category')
})
})
describe('global blueprint filtering', () => {
function globalBlueprint(
overrides: Partial<GlobalSubgraphData['info']> = {}
): GlobalSubgraphData {
return {
name: 'Filtered Blueprint',
info: { node_pack: 'test_pack', ...overrides },
data: JSON.stringify(mockGraph)
}
}
it('should exclude blueprints with requiresCustomNodes on non-cloud', async () => {
await mockFetch(
{},
{
bp: globalBlueprint({ requiresCustomNodes: ['custom-node-pack'] })
}
)
expect(store.isGlobalBlueprint('bp')).toBe(false)
})
it('should include blueprints with requiresCustomNodes on cloud', async () => {
mockDistributionTypes.isCloud = true
await mockFetch(
{},
{
bp: globalBlueprint({ requiresCustomNodes: ['custom-node-pack'] })
}
)
expect(store.isGlobalBlueprint('bp')).toBe(true)
})
it('should include blueprints with empty requiresCustomNodes everywhere', async () => {
await mockFetch({}, { bp: globalBlueprint({ requiresCustomNodes: [] }) })
expect(store.isGlobalBlueprint('bp')).toBe(true)
})
it('should exclude blueprints whose includeOnDistributions does not match', async () => {
await mockFetch(
{},
{
bp: globalBlueprint({
includeOnDistributions: [TemplateIncludeOnDistributionEnum.Cloud]
})
}
)
expect(store.isGlobalBlueprint('bp')).toBe(false)
})
it('should include blueprints whose includeOnDistributions matches current distribution', async () => {
await mockFetch(
{},
{
bp: globalBlueprint({
includeOnDistributions: [TemplateIncludeOnDistributionEnum.Local]
})
}
)
expect(store.isGlobalBlueprint('bp')).toBe(true)
})
it('should include blueprints on desktop when includeOnDistributions has desktop', async () => {
mockDistributionTypes.isDesktop = true
await mockFetch(
{},
{
bp: globalBlueprint({
includeOnDistributions: [TemplateIncludeOnDistributionEnum.Desktop]
})
}
)
expect(store.isGlobalBlueprint('bp')).toBe(true)
})
it('should include blueprints with no filtering fields', async () => {
await mockFetch({}, { bp: globalBlueprint() })
expect(store.isGlobalBlueprint('bp')).toBe(true)
})
})
})

View File

@@ -20,6 +20,8 @@ import type {
ComfyNodeDef as ComfyNodeDefV1,
InputSpec
} from '@/schemas/nodeDefSchema'
import { isCloud, isDesktop } from '@/platform/distribution/types'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import { api } from '@/scripts/api'
import type { GlobalSubgraphData } from '@/scripts/api'
import { useDialogService } from '@/services/dialogService'
@@ -208,22 +210,35 @@ export const useSubgraphStore = defineStore('subgraph', () => {
const loaded = await blueprint.load()
const category = v.info.category
? `Subgraph Blueprints/${v.info.category}`
: 'Subgraph Blueprints'
: undefined
registerNodeDef(
loaded,
{
python_module: v.info.node_pack,
display_name: v.name,
category,
...(category && { category }),
search_aliases: v.info.search_aliases
},
k
)
}
const subgraphs = await api.getGlobalSubgraphs()
await Promise.allSettled(
Object.entries(subgraphs).map(loadGlobalBlueprint)
)
const currentDistribution: TemplateIncludeOnDistributionEnum = isCloud
? TemplateIncludeOnDistributionEnum.Cloud
: isDesktop
? TemplateIncludeOnDistributionEnum.Desktop
: TemplateIncludeOnDistributionEnum.Local
const filteredEntries = Object.entries(subgraphs).filter(([, v]) => {
if (!isCloud && (v.info.requiresCustomNodes?.length ?? 0) > 0)
return false
if (
(v.info.includeOnDistributions?.length ?? 0) > 0 &&
!v.info.includeOnDistributions!.includes(currentDistribution)
)
return false
return true
})
await Promise.allSettled(filteredEntries.map(loadGlobalBlueprint))
}
const userSubs = (
@@ -265,6 +280,11 @@ export const useSubgraphStore = defineStore('subgraph', () => {
const description =
workflowExtra?.BlueprintDescription ?? 'User generated subgraph blueprint'
const search_aliases = workflowExtra?.BlueprintSearchAliases
const subgraphDefCategory =
workflow.initialState.definitions?.subgraphs?.[0]?.category
const category = subgraphDefCategory
? `Subgraph Blueprints/${subgraphDefCategory}`
: 'Subgraph Blueprints'
const nodedefv1: ComfyNodeDefV1 = {
input: { required: inputs },
output: subgraphNode.outputs.map((o) => `${o.type}`),
@@ -272,7 +292,7 @@ export const useSubgraphStore = defineStore('subgraph', () => {
name: typePrefix + name,
display_name: name,
description,
category: 'Subgraph Blueprints',
category,
output_node: false,
python_module: 'blueprint',
search_aliases,