Compare commits

...

3 Commits

Author SHA1 Message Date
Glary Bot
a98d5557db fix(assets): disable Use for no-category assets; drop duplicate i18n key
- canCreateNodeForAsset now returns false when the asset has no usable
  category tag, matching resolveModelNodeFromAsset's INVALID_ASSET
  rejection. Previously this branch returned true and let users click
  through to the same error toast this PR is meant to suppress.
- Drop the duplicate `whatsIncluded` key at en/main.json:2587 (kept the
  identical 2677 occurrence); Biome's noDuplicateObjectKeys would fail
  lint on this. Introduced by the rebased billing-panel commit, fixed
  here as a one-line drive-by to keep the PR's lint pass clean.
2026-06-26 00:02:47 +00:00
Glary Bot
19f5c9f6ae fix(assets): keep tooltip hoverable and re-warn when broken set changes
Address PR review feedback:

- AssetCard: drop native disabled attribute on the Use button so PrimeVue's
  tooltip (and hover) still fires when the model category has no provider.
  aria-disabled plus the existing handleSelect/Enter guards already prevent
  any interaction; visual disabled styling is reapplied via cn().
- assetsStore: dedupe broken-category warnings by (loadKey, broken-set
  fingerprint) instead of loadKey alone. Refreshes with the same broken
  set stay quiet (matching the original 'fire ~3 times' spec); refreshes
  where a category gains or loses provider coverage emit one new warning.
  Tag-scan now reuses the shared getAssetCategory helper for consistency
  with canCreateNodeForAsset.
2026-06-25 23:51:51 +00:00
Glary Bot
fbff4c07f4 fix(assets): disable Use button for unsupported categories and log them
Short-term workaround for BE-482. The Asset Browser previously surfaced
NO_PROVIDER errors only after a user clicked Use on an unsupported
category (e.g. BEN). The button now reactively disables once the
modelToNode registry has loaded and the asset's category has no
matching provider, and the assets store logs the set of broken
categories once per category load to aid diagnosis.
2026-06-25 23:51:51 +00:00
11 changed files with 411 additions and 14 deletions

View File

@@ -2584,7 +2584,6 @@
"viewMoreDetailsPlans": "View more details about plans & pricing",
"nextBillingCycle": "next billing cycle",
"yourPlanIncludes": "Your plan includes:",
"whatsIncluded": "What's included:",
"planLoadError": "We couldn't load your plan details.",
"planLoadErrorRetry": "Try again",
"teamPlanName": "Team",
@@ -3308,6 +3307,7 @@
"invalidFilenameDetail": "The asset filename could not be determined. Please try again.",
"failedToCreateNode": "Failed to create node. Please try again or check console for details.",
"failedToSetModelValue": "Node added, but its model could not be set automatically. Check the console for details.",
"useDisabledNoProvider": "No matching node is registered for this model type.",
"fileFormats": "File formats",
"fileName": "File Name",
"fileSize": "File Size",

View File

@@ -92,6 +92,7 @@
"errorUserTokenAccessDenied": "お使いのAPIトークンにはこのリソースへのアクセス権がありません。トークンの権限を確認してください。",
"errorUserTokenInvalid": "保存されているAPIトークンが無効または期限切れです。設定でトークンを更新してください。",
"failedToCreateNode": "ノードの作成に失敗しました。再試行するか、詳細はコンソールをご確認ください。",
"useDisabledNoProvider": "このモデルタイプに一致するノードが登録されていません。",
"fileFormats": "ファイル形式",
"fileName": "ファイル名",
"fileSize": "ファイルサイズ",

View File

@@ -92,6 +92,7 @@
"errorUserTokenAccessDenied": "您的 API 密钥无权访问此资源。请检查密钥权限。",
"errorUserTokenInvalid": "您保存的 API 密钥无效或已过期。请在设置中更新密钥。",
"failedToCreateNode": "创建节点失败。请重试或查看控制台获取详细信息。",
"useDisabledNoProvider": "没有为此模型类型注册匹配的节点。",
"fileFormats": "文件格式",
"fileName": "文件名",
"fileSize": "文件大小",

View File

@@ -6,6 +6,24 @@ import { createI18n } from 'vue-i18n'
import AssetCard from '@/platform/assets/components/AssetCard.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
const mockModelToNodeState = vi.hoisted(() => ({
isReady: true,
registeredCategories: new Set<string>(['checkpoints', 'loras'])
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({
get isReady() {
return mockModelToNodeState.isReady
},
getNodeProvider: (category: string) =>
mockModelToNodeState.registeredCategories.has(category)
? { nodeDef: { name: `${category}Loader` }, key: 'model' }
: undefined,
registerDefaults: vi.fn()
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: () => 0
@@ -28,7 +46,9 @@ vi.mock('@/stores/dialogStore', () => ({
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
deleteAsset: vi.fn()
}
},
MODELS_TAG: 'models',
MISSING_TAG: 'missing'
}))
vi.mock('@/components/dialog/confirm/confirmDialog', () => ({
@@ -83,7 +103,10 @@ function renderCard(asset: AssetDisplayItem) {
IconGroup: true,
MoreButton: true,
StatusBadge: true,
Button: { template: '<button><slot /></button>' }
Button: {
props: ['disabled'],
template: '<button :disabled="disabled"><slot /></button>'
}
},
directives: {
tooltip: {}
@@ -95,6 +118,64 @@ function renderCard(asset: AssetDisplayItem) {
describe('AssetCard', () => {
beforeEach(() => {
vi.clearAllMocks()
mockModelToNodeState.isReady = true
mockModelToNodeState.registeredCategories = new Set([
'checkpoints',
'loras'
])
})
describe('"Use" button gating for unsupported categories', () => {
const findUseButton = () =>
screen
.getAllByRole('button')
.find((b) => b.textContent?.toLowerCase().includes('use'))
it('enables Use when the asset category has a registered provider', () => {
renderCard(
createDisplayAsset({
id: 'usable',
tags: ['models', 'checkpoints']
})
)
const useBtn = findUseButton()
expect(useBtn).toBeDefined()
expect(useBtn).toHaveAttribute('aria-disabled', 'false')
})
it('disables Use when the registry is ready and the category has no provider', () => {
renderCard(
createDisplayAsset({
id: 'unsupported',
tags: ['models', 'BEN']
})
)
const useBtn = findUseButton()
expect(useBtn).toHaveAttribute('aria-disabled', 'true')
})
it('keeps Use enabled while the registry is still warming up', () => {
mockModelToNodeState.isReady = false
renderCard(
createDisplayAsset({
id: 'warming',
tags: ['models', 'BEN']
})
)
const useBtn = findUseButton()
expect(useBtn).toHaveAttribute('aria-disabled', 'false')
})
it('keeps the use button hoverable (not pointer-events-none) when disabled so the tooltip can fire', () => {
renderCard(
createDisplayAsset({
id: 'unsupported',
tags: ['models', 'BEN']
})
)
const useBtn = findUseButton()
expect(useBtn).not.toHaveAttribute('disabled')
})
})
describe('FE-228: filename rendering', () => {

View File

@@ -16,7 +16,7 @@
"
@click.stop="interactive && $emit('focus', asset)"
@focus="interactive && $emit('focus', asset)"
@keydown.enter.self="interactive && $emit('select', asset)"
@keydown.enter.self="interactive && canUseAsset && handleSelect()"
>
<div class="relative aspect-square w-full overflow-hidden rounded-xl">
<div
@@ -111,9 +111,23 @@
</div>
<Button
v-if="interactive"
v-tooltip.top="
canUseAsset
? undefined
: {
value: $t('assetBrowser.useDisabledNoProvider'),
showDelay: tooltipDelay
}
"
variant="secondary"
size="lg"
class="relative shrink-0"
:class="
cn(
'relative shrink-0',
!canUseAsset && 'cursor-not-allowed opacity-50'
)
"
:aria-disabled="!canUseAsset"
@click.stop="handleSelect"
>
{{ $t('g.use') }}
@@ -142,6 +156,7 @@ import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { assetService } from '@/platform/assets/services/assetService'
import { getAssetCardTitle } from '@/platform/assets/utils/assetMetadataUtils'
import { canCreateNodeForAsset } from '@/platform/assets/utils/resolveModelNodeFromAsset'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useDialogStore } from '@/stores/dialogStore'
@@ -187,6 +202,8 @@ const isNewlyImported = computed(() => isDownloadedThisSession(asset.id))
const showAssetOptions = computed(() => !(asset.is_immutable ?? true))
const canUseAsset = computed(() => canCreateNodeForAsset(asset))
const tooltipDelay = computed<number>(() =>
settingStore.get('LiteGraph.Node.TooltipDelay')
)
@@ -197,6 +214,7 @@ const { isLoading, error } = useImageQuiet({
})
function handleSelect() {
if (!canUseAsset.value) return
acknowledgeAsset(asset.id)
emit('select', asset)
}

View File

@@ -1,12 +1,24 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { resolveModelNodeFromAsset } from '@/platform/assets/utils/resolveModelNodeFromAsset'
import {
canCreateNodeForAsset,
getAssetCategory,
resolveModelNodeFromAsset
} from '@/platform/assets/utils/resolveModelNodeFromAsset'
const mockGetNodeProvider = vi.hoisted(() => vi.fn())
const mockRegisterDefaults = vi.hoisted(() => vi.fn())
const mockIsReady = vi.hoisted(() => ({ value: true }))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({ getNodeProvider: mockGetNodeProvider })
useModelToNodeStore: () => ({
getNodeProvider: mockGetNodeProvider,
registerDefaults: mockRegisterDefaults,
get isReady() {
return mockIsReady.value
}
})
}))
function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
@@ -48,6 +60,7 @@ function mockProvider(
describe('resolveModelNodeFromAsset', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsReady.value = true
vi.spyOn(console, 'error').mockImplementation(() => {})
})
@@ -206,3 +219,68 @@ describe('resolveModelNodeFromAsset', () => {
})
})
})
describe('getAssetCategory', () => {
it('returns the first tag that is not models or missing', () => {
expect(
getAssetCategory(createMockAsset({ tags: ['models', 'checkpoints'] }))
).toBe('checkpoints')
})
it('skips the missing placeholder tag', () => {
expect(
getAssetCategory(
createMockAsset({ tags: ['models', 'missing', 'loras'] })
)
).toBe('loras')
})
it('returns undefined when only excluded tags are present', () => {
expect(
getAssetCategory(createMockAsset({ tags: ['models', 'missing'] }))
).toBeUndefined()
})
it('returns undefined when tags is missing', () => {
const asset = { ...createMockAsset(), tags: undefined } as unknown as
| AssetItem
| { tags: undefined }
expect(getAssetCategory(asset as AssetItem)).toBeUndefined()
})
})
describe('canCreateNodeForAsset', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsReady.value = true
})
it('returns true while the registry is still warming up', () => {
mockIsReady.value = false
mockProvider(null)
expect(canCreateNodeForAsset(createMockAsset())).toBe(true)
expect(mockRegisterDefaults).toHaveBeenCalled()
expect(mockGetNodeProvider).not.toHaveBeenCalled()
})
it('returns true when a provider exists for the category', () => {
mockProvider(createMockNodeProvider())
expect(canCreateNodeForAsset(createMockAsset())).toBe(true)
expect(mockGetNodeProvider).toHaveBeenCalledWith('checkpoints')
})
it('returns false when the registry is ready but no provider matches', () => {
mockProvider(null)
expect(
canCreateNodeForAsset(createMockAsset({ tags: ['models', 'BEN'] }))
).toBe(false)
expect(mockGetNodeProvider).toHaveBeenCalledWith('BEN')
})
it('returns false when the asset has no usable category tag (would fail INVALID_ASSET)', () => {
expect(
canCreateNodeForAsset(createMockAsset({ tags: ['models', 'missing'] }))
).toBe(false)
expect(mockGetNodeProvider).not.toHaveBeenCalled()
})
})

View File

@@ -8,6 +8,32 @@ import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import type { ModelNodeProvider } from '@/stores/modelToNodeStore'
/**
* Extracts the category tag used to look up a node provider for this asset.
* Returns `undefined` when the asset carries no usable category tag.
*/
export function getAssetCategory(asset: AssetItem): string | undefined {
return asset.tags?.find((tag) => tag !== MODELS_TAG && tag !== MISSING_TAG)
}
/**
* Returns `false` only when we are confident the asset cannot be turned into a
* node — either its category has no provider in the (initialised) registry, or
* it carries no usable category tag at all. While the registry is still warming
* up we return `true` so the UI stays enabled by default and only transitions
* to disabled once we know.
*/
export function canCreateNodeForAsset(asset: AssetItem): boolean {
const store = useModelToNodeStore()
store.registerDefaults()
if (!store.isReady) return true
const category = getAssetCategory(asset)
if (!category) return false
return Boolean(store.getNodeProvider(category))
}
type ResolveErrorCode = 'INVALID_ASSET' | 'NO_PROVIDER'
export interface ResolveModelNodeError {
@@ -81,9 +107,7 @@ export function resolveModelNodeFromAsset(
}
}
const category = validAsset.tags.find(
(tag) => tag !== MODELS_TAG && tag !== MISSING_TAG
)
const category = getAssetCategory(validAsset)
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}')`

View File

@@ -37,7 +37,9 @@ vi.mock('@/platform/assets/services/assetService', () => ({
removeAssetTags: vi.fn()
},
INPUT_TAG: 'input',
OUTPUT_TAG: 'output'
OUTPUT_TAG: 'output',
MODELS_TAG: 'models',
MISSING_TAG: 'missing'
}))
// Mock distribution type - hoisted so it can be changed per test
@@ -48,7 +50,16 @@ vi.mock('@/platform/distribution/types', () => ({
}
}))
// Mock modelToNodeStore with proper node providers and category lookups
const mockModelToNodeState = vi.hoisted(() => ({
isReady: true,
registeredCategories: new Set<string>([
'checkpoints',
'loras',
'vae',
'models'
])
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({
getAllNodeProviders: vi.fn((category: string) => {
@@ -62,7 +73,7 @@ vi.mock('@/stores/modelToNodeStore', () => ({
],
loras: [
{ nodeDef: { name: 'LoraLoader' }, key: 'lora_name' },
{ nodeDef: { name: 'LoraLoaderModelOnly' }, key: 'lora_name' }
{ nodeDef: { name: 'VAELoader' }, key: 'vae_name' }
],
vae: [{ nodeDef: { name: 'VAELoader' }, key: 'vae_name' }]
}
@@ -78,7 +89,14 @@ vi.mock('@/stores/modelToNodeStore', () => ({
}
return nodeToCategory[nodeType]
}),
getNodeProvider: vi.fn(),
getNodeProvider: vi.fn((category: string) =>
mockModelToNodeState.registeredCategories.has(category)
? { nodeDef: { name: `${category}Loader` }, key: 'model' }
: undefined
),
get isReady() {
return mockModelToNodeState.isReady
},
registerDefaults: vi.fn()
})
}))
@@ -1433,6 +1451,125 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
expect(store.hasCategory('tag:models')).toBe(false)
})
})
describe('broken-category warning', () => {
const supportedAsset = (id: string, category: string) => ({
id,
name: `asset-${id}`,
size: 100,
created_at: new Date().toISOString(),
tags: ['models', category],
preview_url: `http://test.com/${id}`
})
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
mockModelToNodeState.isReady = true
mockModelToNodeState.registeredCategories = new Set([
'checkpoints',
'loras',
'vae',
'models'
])
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
warnSpy.mockRestore()
})
it('warns once with the broken set after a category finishes its first load', async () => {
const store = useAssetsStore()
vi.mocked(assetService.getAssetsByTag).mockResolvedValue([
supportedAsset('a', 'checkpoints'),
supportedAsset('b', 'BEN'),
supportedAsset('c', 'BEN'),
supportedAsset('d', 'WAN')
])
await store.updateModelsForTag('models')
expect(warnSpy).toHaveBeenCalledTimes(1)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('No node provider for categories'),
['BEN', 'WAN']
)
})
it('does not warn when every loaded asset has a registered provider', async () => {
const store = useAssetsStore()
vi.mocked(assetService.getAssetsByTag).mockResolvedValue([
supportedAsset('a', 'checkpoints'),
supportedAsset('b', 'loras')
])
await store.updateModelsForTag('models')
expect(warnSpy).not.toHaveBeenCalled()
})
it('does not warn again on subsequent refresh of the same category', async () => {
const store = useAssetsStore()
vi.mocked(assetService.getAssetsByTag).mockResolvedValue([
supportedAsset('a', 'BEN')
])
await store.updateModelsForTag('models')
store.invalidateCategory('tag:models')
await store.updateModelsForTag('models')
expect(warnSpy).toHaveBeenCalledTimes(1)
})
it('warns again when a refresh introduces a new broken category', async () => {
const store = useAssetsStore()
vi.mocked(assetService.getAssetsByTag)
.mockResolvedValueOnce([supportedAsset('a', 'BEN')])
.mockResolvedValueOnce([
supportedAsset('a', 'BEN'),
supportedAsset('b', 'WAN')
])
await store.updateModelsForTag('models')
store.invalidateCategory('tag:models')
await store.updateModelsForTag('models')
expect(warnSpy).toHaveBeenCalledTimes(2)
expect(warnSpy).toHaveBeenLastCalledWith(
expect.stringContaining('No node provider for categories'),
['BEN', 'WAN']
)
})
it('stops warning once an extension registers a provider for the previously broken category', async () => {
const store = useAssetsStore()
vi.mocked(assetService.getAssetsByTag).mockResolvedValue([
supportedAsset('a', 'BEN')
])
await store.updateModelsForTag('models')
expect(warnSpy).toHaveBeenCalledTimes(1)
mockModelToNodeState.registeredCategories.add('BEN')
store.invalidateCategory('tag:models')
await store.updateModelsForTag('models')
expect(warnSpy).toHaveBeenCalledTimes(1)
})
it('skips the report while the modelToNode registry is not ready', async () => {
mockModelToNodeState.isReady = false
const store = useAssetsStore()
vi.mocked(assetService.getAssetsByTag).mockResolvedValue([
supportedAsset('a', 'BEN')
])
await store.updateModelsForTag('models')
expect(warnSpy).not.toHaveBeenCalled()
})
})
})
describe('assetsStore - Deletion State and Input Mapping', () => {

View File

@@ -15,6 +15,7 @@ import {
OUTPUT_TAG,
assetService
} from '@/platform/assets/services/assetService'
import { getAssetCategory } from '@/platform/assets/utils/resolveModelNodeFromAsset'
import type { PaginationOptions } from '@/platform/assets/services/assetService'
import { isCloud } from '@/platform/distribution/types'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
@@ -407,6 +408,11 @@ export const useAssetsStore = defineStore('assets', () => {
const pendingRequestByCategory = new Map<string, ModelPaginationState>()
const pendingPromiseByCategory = new Map<string, Promise<void>>()
const reportedBrokenCategoriesByLoad = new Set<string>()
function brokenCategoriesFingerprint(loadKey: string, broken: string[]) {
return `${loadKey}::${[...broken].sort().join(',')}`
}
function createState(
existingAssets?: Map<string, AssetItem>
@@ -426,6 +432,32 @@ export const useAssetsStore = defineStore('assets', () => {
return committed !== state && pending !== state
}
function reportBrokenCategoriesOnce(
loadKey: string,
assets: Map<string, AssetItem>
): void {
modelToNodeStore.registerDefaults()
if (!modelToNodeStore.isReady) return
const broken = new Set<string>()
for (const asset of assets.values()) {
const category = getAssetCategory(asset)
if (!category) continue
if (!modelToNodeStore.getNodeProvider(category)) broken.add(category)
}
if (broken.size === 0) return
const sorted = [...broken].sort()
const fingerprint = brokenCategoriesFingerprint(loadKey, sorted)
if (reportedBrokenCategoriesByLoad.has(fingerprint)) return
reportedBrokenCategoriesByLoad.add(fingerprint)
console.warn(
`[AssetBrowser] No node provider for categories (${loadKey}):`,
sorted
)
}
const EMPTY_ASSETS: AssetItem[] = []
/**
@@ -587,6 +619,8 @@ export const useAssetsStore = defineStore('assets', () => {
}
assetsArrayCache.delete(category)
pendingRequestByCategory.delete(category)
reportBrokenCategoriesOnce(category, state.assets)
}
const promise = loadBatches().finally(() => {

View File

@@ -679,4 +679,23 @@ describe('useModelToNodeStore', () => {
expect(modelToNodeStore.getAllNodeProviders(undefined)).toEqual([])
})
})
describe('isReady', () => {
it('starts false before defaults are registered', () => {
const modelToNodeStore = useModelToNodeStore()
expect(modelToNodeStore.isReady).toBe(false)
})
it('flips to true after registerDefaults', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
expect(modelToNodeStore.isReady).toBe(true)
})
it('flips to true as a side-effect of getNodeProvider lazy-registering', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.getNodeProvider('checkpoints')
expect(modelToNodeStore.isReady).toBe(true)
})
})
})

View File

@@ -25,6 +25,9 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
const nodeDefStore = useNodeDefStore()
const haveDefaultsLoaded = ref(false)
/** True once default provider registrations have been applied. */
const isReady = computed(() => haveDefaultsLoaded.value)
/** Internal computed for reactive caching of registered node types */
const registeredNodeTypes = computed<Record<string, string>>(() => {
return Object.fromEntries(
@@ -164,6 +167,7 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
return {
modelToNodeMap,
isReady,
getRegisteredNodeTypes,
getCategoryForNodeType,
getNodeProvider,