Compare commits

...

25 Commits

Author SHA1 Message Date
Simon Pinfold
4fa87647e3 spike: force model picker down isCloud asset paths
Rebuilt on the updated PR stack (12634 now drives the sidebar Model
Library from the assets API on every distribution and un-gates the
assets store, so those earlier forcings are gone). Remaining flag hooks:

- isAssetAPIEnabled() returns true regardless of distribution/setting
- useAssetWidgetData reads from assetsStore instead of the local source
- useComboWidget creates asset-browser widgets for eligible loaders
  (cloud-only media input mapping stays distribution-gated)
- picker keeps base-model sort/grouping, ownership + base-model filters,
  and the cloud list layout

Known failing tests (by design under the forced flag):
- assetService.test.ts 'returns false when asset API setting is disabled'
- useAssetWidgetData.desktop.test.ts (local branch is bypassed)
2026-06-12 16:01:03 +12:00
Simon Pinfold
f2c018902d test: update default-sort test for 'default' to 'recent' rename
PR #12635 renamed getDefaultSortOptions' 'default' option to 'recent'
(created_at descending) without updating main's shared.test.ts, which
crashed on the missing option. Cover the new recency semantics.
2026-06-12 15:57:36 +12:00
Simon Pinfold
d562acd2ce merge: PR #12635 feat: redesign in-node model/media picker
Includes fixup: FormDropdown still referenced useTransformCompatOverlayProps,
removed on main in #12513; pass append-to="body" directly.
2026-06-12 15:40:15 +12:00
Simon Pinfold
bd125b2acf merge: PR #12634 feat: add Model Library sidebar tab (cloud + local) 2026-06-12 15:34:29 +12:00
Simon Pinfold
e06aa2ed91 merge: PR #12633 feat: add Model Library data foundation 2026-06-12 15:28:57 +12:00
shrimbly
0a47aec4db chore: keep local model source for the picker's local mode
The Model Library sidebar no longer consumes the local enumeration
adapter, but the in-node picker's desktop/localhost path still does
until it moves to the assets API.
2026-06-12 10:30:29 +12:00
shrimbly
eba0fb7cbf feat: order in-node media picker "All" tab by recency
The "All" tab concatenated imported then generated media, so type
dictated order. Interleave both by creation time instead, newest first:

- Thread created_at onto FormDropdownItem; populate it for output items
  (asset.created_at) and imported items (looked up from the input assets
  store by filename).
- Sort the combined "All" list via the shared sortAssets(..., 'recent').
- Load input assets when the picker opens so imported items have
  timestamps to sort by.
- Default the media picker's sort to "Recent" (relabels the former
  "Unsorted" option and makes it an actual recency sort).
2026-06-12 10:30:28 +12:00
shrimbly
086ec44c7e feat: redesign in-node model/media picker
Reworks the widget select dropdown into a model/media picker:

- FormDropdown with a new FormDropdownActionPopover and menu/actions/
  filter/item subcomponents, shared sort helpers, and types.
- WidgetSelect / WidgetSelectDropdown wire asset-mode browsing:
  useAssetWidgetData sources cloud or on-disk local models, with
  base-model grouping, recently-used pins, and the media import button.

Reconciles upstream's FE-227 hash fallback in useWidgetSelectItems and
removes the final knip stacked-PR ignore now that useRecentlyUsedModels
is consumed.
2026-06-12 10:30:28 +12:00
shrimbly
a61a1efd73 refactor: classify purely by folder-tag remapping
Removes the base-model modality override: grouping is now a pure
remapping of folder tags onto curated groups, with no metadata
heuristics. ACE-Step and LTX transformers land under Diffusion models
with the rest of checkpoints/diffusion_models until backends assert
real type metadata.
2026-06-12 10:28:51 +12:00
shrimbly
36abb09b47 feat: drop divider above unmapped-folder tail, rename heading to Other models 2026-06-12 10:24:47 +12:00
shrimbly
f447dec206 refactor: remove filename-based VAE detection
Classification by filename substring was unpredictable and compensated
for backend mis-tagging; assets now group strictly by their folder tags.
Files in nested family folders (CogVideo/VAE) follow their top-level
folder until backends assert real type metadata.
2026-06-12 10:21:23 +12:00
shrimbly
4141ca72c4 fix: withhold drag and add-to-graph for models with no registered loader
Models in folders with no MODEL_NODE_MAPPINGS entry previously offered a
drag affordance and context-menu action that silently did nothing. The
leaf now shows a plain file icon instead of the model glyph, disables
drag, hides Add to graph, and the hover preview states 'No node
available to load this model' where the node preview would render.
2026-06-12 09:49:12 +12:00
shrimbly
b07d32f754 fix: classify models by type first, modality override for main models only
The base-model override pulled companion files into family buckets —
CLIP/text encoders landed under Diffusion models, whisper under Video &
motion (259 of 2000 prod cloud models relocated, ~67 type-incorrectly).
The override now applies only to assets whose folder tag is a main
generative model (checkpoints / diffusion_models), re-homing e.g. LTX
transformers into Video & motion while every companion type keeps its
bucket. Unmapped tags are no longer captured by base model and fall to
'Your models'. Tag lookup is now case-insensitive (cogvideo/CogVideo,
llm/LLM).
2026-06-12 09:44:08 +12:00
shrimbly
ce29e22da6 feat: restore group-by toggle with a true disk view
With multi-folder tag membership the category view no longer reflects
where files live — a shared-folder model appears in several groups — so
the disk view becomes meaningful again. It groups by the backend's
reported file_path (falling back to metadata paths, then the folder
tag), one verbatim section per directory, partner nodes trailing.
2026-06-12 09:21:36 +12:00
shrimbly
a0992e82a1 feat: drive Model Library from assets API on every distribution
The library now assumes an assets-enabled backend: the source always
reads the assets API, the assets store's model cache is no longer
cloud-gated, and the local enumeration adapter is removed. Base-model
sorting is offered when the data actually contains base-model metadata
instead of checking the distribution, falling back to name order without
overwriting the stored preference. No isCloud branching remains in the
library.
2026-06-12 09:09:19 +12:00
shrimbly
07bfe56fab feat: unify Model Library grouping with multi-folder tag membership
Reverts the local-only 1:1 disk mirror in favor of one grouping pipeline
for all distributions, per the namespace-tags direction: the backend tags
a file with every model folder it could belong to, so an asset may appear
in several curated groups (shared-folder setups land in both Diffusion
models and LoRAs). Tags with no curated mapping surface verbatim under a
single 'Your models' tail on every distribution.
2026-06-12 09:01:57 +12:00
shrimbly
a4374710c3 refactor: remove Model Library group-by view modes
The directory grouping existed for local users browsing their disk, but
the local library now always mirrors disk folders 1:1, leaving the
toggle meaningless there and redundant on cloud. Cloud always uses the
curated category view; the settings popover keeps only sort options.
2026-06-12 06:10:55 +12:00
shrimbly
626f2bd4ac feat: hide group-by toggle in local Model Library
The local library always mirrors disk folders 1:1, so category and
directory grouping render the same list. The toggle now only shows on
cloud, and a previously persisted directory preference no longer
changes the local view.
2026-06-12 05:55:00 +12:00
shrimbly
5e5c2c918d feat: mirror disk folders 1:1 in local Model Library
Local items carry no type metadata, so consolidating folders into the
curated taxonomy (checkpoints + diffusion_models + diffusers merging
into 'Diffusion models') misrepresented the user's disk. The local
library now pins Partner Nodes on top and lists every model folder
verbatim under a 'Your models' heading; the curated taxonomy still
applies on cloud where type metadata exists.
2026-06-12 05:34:52 +12:00
shrimbly
88f8bf9228 feat: render unmapped model folders verbatim below curated groups
Unmapped category tags were title-cased into pseudo-categories that
rendered indistinguishably from the curated taxonomy (kjnodes_fonts
became 'Kjnodes Fonts' beside 'Diffusion models'). They now render
verbatim in a separate 'Your folders' section after the curated groups,
and sidecar files that live next to models on disk (configs, tokenizers,
fonts, licenses) are filtered out of the library entirely.
2026-06-12 05:17:29 +12:00
shrimbly
924eb8d648 fix: stop hijacking model-library sidebar toggle into legacy asset modal
When Comfy.Assets.UseAssetAPI was enabled, toggling the model-library
sidebar tab delegated to Comfy.BrowseModelAssets, which on non-cloud
builds opens the legacy asset-browser modal instead of the tab. The
unified Model Library tab is now registered unconditionally, so the
redirect only ever reproduced the broken local fallback. Remove it.
2026-06-12 04:03:43 +12:00
shrimbly
c19b217ab0 feat: add group-by toggle to Model Library sidebar
Replace the sort-only popover button with a settings button (settings-2
icon) matching the media-assets panel, and add a group-by toggle:

- Group by category (existing base-model family grouping) or by directory
  (the model's on-disk folder, from its first non-models tag, falling back
  to the filepath dirname). Persisted to Comfy.CloudModelLibrary.GroupBy.
- Directory sections sort alphabetically; partner nodes (no directory)
  trail at the end.
- Settings popover mirrors MediaAssetSettingsMenu: group-by rows with
  leading icons, a divider, then the sort rows.

Consolidates the section builders (buildAssetSection / buildPartnerSection)
shared by both grouping modes.
2026-06-10 06:31:22 +12:00
shrimbly
a13d6cf99e refactor: decompose Model Library sidebar tab
Split the 867-line CloudModelLibrarySidebarTab into focused units:

- modelLibraryGrouping: pure asset→group heuristics (firstNonModelsTag,
  groupIdForAsset, looksLikeVae, groupLabelForAsset, partnerKind).
- modelLibrarySort: pure sort/section assembly (buildProviderGroups now
  takes mode/isSearching as params instead of reading refs).
- useModelLibraryHoverPopover: the shared row→popover hover bridge.
- useModelLibraryLeaf: shared row wiring + class constants, removing the
  duplication between CloudModelLeaf and CloudPartnerLeaf.

Adds unit tests for the grouping and sorting heuristics (previously
untested), reuses partnerKind in PartnerNodeHoverPreview, and removes the
dead preview-container div.
2026-06-04 08:49:52 +12:00
shrimbly
c7873ac7ed feat: add Model Library sidebar tab (cloud + local)
Replaces the legacy TreeExplorer Model Library tab with a new browser:

- CloudModelLibrarySidebarTab with grouped cloud + local model browsing,
  category/partner leaves (CloudModelLeaf, CloudPartnerLeaf), and rich
  hover previews (AssetHoverPreview, PartnerNodeHoverPreview).
- Registration via sidebarTabStore (the legacy useModelLibrarySidebarTab
  is removed), wired through SidebarTabTemplate, useCoreCommands, and
  useCanvasDrop for drag-to-canvas.
- Tints the shared LGraphNodePreview header so node previews read as real
  canvas nodes (also improves the node-library hover cards).

Drops the foundation PR's knip stacked-PR scaffolding for the files and
exports this tab now consumes; only useRecentlyUsedModels stays ignored
for the follow-up in-node picker PR.
2026-06-04 08:34:18 +12:00
shrimbly
87625d852b feat: add model-library data foundation
Shared data layer for the Model Library feature, with no app-visible UI:

- Asset metadata/sort/filter helpers (assetService, assetMetadataUtils,
  assetSortUtils, filterTypes) extended for model browsing.
- Base-model inference with curated overrides (baseModelInference,
  baseModelOverrides, baseModelCategoryOverrides, comfyOrgProviderOverrides).
- Model grouping and provider formatting (modelGroups), category icon
  helpers (categoryUtil), and category placeholder support.
- Model Library data sources: cloud (useModelLibrarySource) and on-disk
  local (useLocalModelLibrarySource), plus useRecentlyUsedModels and the
  node-preview drag image helper.

Consumers land in stacked follow-up PRs (sidebar tab, in-node picker).
Until then their orphaned files/exports are scoped under the existing
knip stacked-PR conventions (ignore list + @knipIgnoreUsedByStackedPR),
to be removed as each consumer arrives.
2026-06-04 08:32:55 +12:00
70 changed files with 5127 additions and 1132 deletions

View File

@@ -20,7 +20,13 @@
</template>
<template #end>
<div
class="flex flex-row overflow-hidden transition-all duration-200 motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100 touch:w-auto touch:opacity-100 [&_.p-button]:py-1 2xl:[&_.p-button]:py-2"
:class="
cn(
'flex flex-row overflow-hidden transition-all duration-200 [&_.p-button]:py-1 2xl:[&_.p-button]:py-2',
!props.toolButtonsAlwaysVisible &&
'motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100 touch:w-auto touch:opacity-100'
)
"
>
<slot name="tool-buttons" />
</div>
@@ -45,6 +51,7 @@ import { cn } from '@comfyorg/tailwind-utils'
const props = defineProps<{
title: string
class?: string
toolButtonsAlwaysVisible?: boolean
}>()
const sidebarPt = {
start: 'min-w-0 flex-1 overflow-hidden'

View File

@@ -0,0 +1,64 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import AssetHoverPreview from './AssetHoverPreview.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
// An empty `tags` array yields no model type, so the node-preview section stays
// hidden — keeping the component free of the model-to-node store and the heavy
// NodePreview render for these presentational assertions.
const baseAsset: AssetItem = {
id: 'asset-1',
name: 'mymodel.safetensors',
tags: []
}
function renderPreview(asset: AssetItem) {
return render(AssetHoverPreview, {
global: {
plugins: [i18n],
directives: { tooltip: {} }
},
props: { asset }
})
}
describe('AssetHoverPreview', () => {
it('shows the description section when a description is present', () => {
renderPreview({
...baseAsset,
user_metadata: { user_description: 'A cutting-edge model.' }
})
expect(screen.getByText('Description')).toBeInTheDocument()
expect(screen.getByText('A cutting-edge model.')).toBeInTheDocument()
})
it('hides the description section when the description is empty', () => {
renderPreview({ ...baseAsset, user_metadata: { user_description: '' } })
expect(screen.queryByText('Description')).toBeNull()
})
it('renders trigger words as chips under a labelled section', () => {
renderPreview({
...baseAsset,
metadata: { trained_words: ['cat', 'digital art'] }
})
expect(screen.getByText('Trigger words')).toBeInTheDocument()
expect(screen.getByText('cat')).toBeInTheDocument()
expect(screen.getByText('digital art')).toBeInTheDocument()
})
it('omits the trigger words section when there are none', () => {
renderPreview(baseAsset)
expect(screen.queryByText('Trigger words')).toBeNull()
})
})

View File

@@ -0,0 +1,276 @@
<template>
<div
class="flex w-96 flex-col gap-2 overflow-hidden rounded-2xl border border-border-default bg-comfy-menu-bg p-4 text-sm text-base-foreground shadow-lg"
>
<!-- Header -->
<div class="flex w-full items-start gap-2 pb-1">
<div class="flex min-w-0 flex-1 flex-col items-start gap-2">
<div
class="flex w-full flex-col gap-1.5 pr-2 leading-tight wrap-break-word"
>
<span class="font-medium">{{ displayName }}</span>
<span
v-if="filename"
class="font-normal break-all text-muted-foreground"
>
{{ filename }}
</span>
</div>
<div
v-if="baseModels.length || sourceUrl"
class="flex w-full flex-wrap items-start gap-2 pb-1"
>
<span
v-for="baseModel in baseModels"
:key="baseModel"
class="inline-flex h-6 max-w-full items-center rounded-full bg-secondary-background px-2 py-1 text-xs text-base-foreground"
>
<span class="truncate">{{ baseModel }}</span>
</span>
<Button
v-if="sourceUrl"
v-tooltip.bottom="$t('cloudModelLibrary.preview.openUrl')"
variant="secondary"
size="sm"
class="h-6 shrink-0 gap-1 rounded-full px-2 font-normal text-base-foreground"
:aria-label="$t('cloudModelLibrary.preview.openUrl')"
@click="openSourceUrl"
>
{{ $t('cloudModelLibrary.preview.url') }}
<i class="icon-[lucide--external-link] size-3.5" />
</Button>
</div>
</div>
<div
v-if="isCloud"
class="relative size-27 shrink-0 overflow-hidden rounded-sm bg-muted-background"
>
<template v-if="thumbnail">
<Skeleton v-if="!thumbnailLoaded" class="absolute inset-0" />
<img
:src="thumbnail.src"
:alt="displayName"
class="size-full object-cover transition-opacity duration-150"
:class="thumbnailLoaded ? 'opacity-100' : 'opacity-0'"
@load="thumbnailLoaded = true"
@error="onMediaError"
/>
</template>
<CategoryPlaceholder v-else :category="placeholderCategory" />
</div>
</div>
<!-- Divider: header / description -->
<div v-if="description" class="-mx-4 border-t border-border-default" />
<!-- Description -->
<div v-if="description" class="flex w-full flex-col gap-2 py-2">
<span
class="text-xs font-bold tracking-wide text-muted-foreground uppercase"
>
{{ $t('cloudModelLibrary.preview.description') }}
</span>
<p
class="max-h-24 scrollbar-thin overflow-y-auto wrap-break-word text-muted-foreground"
>
{{ description }}
</p>
</div>
<!-- Trigger words -->
<div v-if="triggerPhrases.length" class="flex w-full flex-col gap-2 pb-2">
<div class="flex items-center gap-2.5">
<span
class="flex-1 text-xs font-bold tracking-wide text-muted-foreground uppercase"
>
{{ $t('cloudModelLibrary.preview.triggerWords') }}
</span>
<Button
v-tooltip.top="$t('g.copyAll')"
variant="muted-textonly"
size="icon"
class="rounded-lg"
:aria-label="$t('g.copyAll')"
@click="copyText(triggerPhrases.join(', '))"
>
<i class="icon-[lucide--copy] size-4" />
</Button>
</div>
<div class="flex flex-wrap gap-2">
<Button
v-for="phrase in triggerPhrases"
:key="phrase"
v-tooltip.bottom="
copiedPhrase === phrase ? $t('g.copied') : $t('g.copyToClipboard')
"
variant="secondary"
size="sm"
class="h-6 rounded-full px-2 font-normal text-base-foreground"
@click="copyTriggerPhrase(phrase, $event)"
>
{{ truncatePhrase(phrase) }}
</Button>
</div>
</div>
<!-- Divider: metadata / node preview -->
<div class="-mx-4 border-t border-border-default" />
<!-- No registered loader: say so instead of silently omitting the
node preview, since drag-out is also unavailable. -->
<span v-if="!previewNodeDef" class="mt-2 text-xs text-muted-foreground">
{{ $t('cloudModelLibrary.preview.noLoader') }}
</span>
<!-- Node preview -->
<div v-if="previewNodeDef" class="flex w-full flex-col gap-2">
<span
class="mt-2 text-xs font-bold tracking-wide text-muted-foreground uppercase"
>
{{ $t('cloudModelLibrary.preview.nodePreview') }}
</span>
<div class="flex w-full justify-center py-2.5">
<div
ref="previewContainerRef"
class="overflow-hidden"
:style="{ width: `${NODE_PREVIEW_WIDTH_PX}px` }"
>
<div
ref="previewWrapperRef"
class="origin-top-left"
:style="{ transform: `scale(${nodePreviewScale})` }"
>
<LGraphNodePreview :node-def="previewNodeDef" position="relative" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useResizeObserver } from '@vueuse/core'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import CategoryPlaceholder from '@/components/sidebar/tabs/cloudModelLibrary/CategoryPlaceholder.vue'
import { formatRowDisplayName } from '@/components/sidebar/tabs/cloudModelLibrary/modelGroups'
import Button from '@/components/ui/button/Button.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import { placeholderCategoryForAsset } from '@/composables/sidebarTabs/useCategoryPlaceholder'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getAssetBaseModels,
getAssetDisplayName,
getAssetFilename,
getAssetModelType,
getAssetSourceUrl,
getAssetTriggerPhrases,
getAssetUserDescription
} from '@/platform/assets/utils/assetMetadataUtils'
import { isCloud } from '@/platform/distribution/types'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
const { asset } = defineProps<{ asset: AssetItem }>()
const rawDisplayName = computed(() => getAssetDisplayName(asset))
const displayName = computed(() => formatRowDisplayName(rawDisplayName.value))
const filename = computed(() => {
const value = getAssetFilename(asset)
return value && value !== rawDisplayName.value ? value : ''
})
const baseModels = computed(() => getAssetBaseModels(asset))
const description = computed(() => getAssetUserDescription(asset))
const triggerPhrases = computed(() => getAssetTriggerPhrases(asset))
const nativePreviewUrl = computed(
() => asset.preview_url ?? asset.thumbnail_url ?? ''
)
const nativeErrored = ref(false)
watch(nativePreviewUrl, () => {
nativeErrored.value = false
})
const thumbnail = computed(() =>
nativePreviewUrl.value && !nativeErrored.value
? { src: nativePreviewUrl.value }
: null
)
const thumbnailLoaded = ref(false)
watch(
() => thumbnail.value?.src,
() => {
thumbnailLoaded.value = false
}
)
const placeholderCategory = computed(() => placeholderCategoryForAsset(asset))
function onMediaError() {
nativeErrored.value = true
}
const sourceUrl = computed(() => getAssetSourceUrl(asset))
function openSourceUrl() {
if (!sourceUrl.value) return
window.open(sourceUrl.value, '_blank', 'noopener,noreferrer')
}
// The plain Load node for the asset's category — surfaced as a live preview so
// the user sees the result before inserting.
const previewNodeDef = computed(() => {
const category = getAssetModelType(asset)
if (!category) return null
return useModelToNodeStore().getNodeProvider(category)?.nodeDef ?? null
})
// LGraphNodePreview renders at a fixed 350px; scale it to the Figma node-preview
// width and compensate the container height so the CSS transform doesn't leave
// empty space below the node.
const NODE_PREVIEW_WIDTH_PX = 268
const NODE_BASE_WIDTH_PX = 350
const nodePreviewScale = NODE_PREVIEW_WIDTH_PX / NODE_BASE_WIDTH_PX
const previewContainerRef = ref<HTMLElement>()
const previewWrapperRef = ref<HTMLElement>()
useResizeObserver(previewWrapperRef, (entries) => {
const entry = entries[0]
if (entry && previewContainerRef.value) {
previewContainerRef.value.style.height = `${entry.contentRect.height * nodePreviewScale}px`
}
})
async function copyText(text: string) {
await navigator.clipboard.writeText(text)
}
// Tracks the trigger word most recently copied so its tooltip can flip to
// "Copied" as confirmation.
const copiedPhrase = ref<string | null>(null)
let copiedResetTimer: ReturnType<typeof setTimeout> | null = null
const COPIED_FEEDBACK_MS = 1500
async function copyTriggerPhrase(phrase: string, event: MouseEvent) {
const target = event.currentTarget
await copyText(phrase)
copiedPhrase.value = phrase
// PrimeVue hides the tooltip on click and doesn't refresh a visible tooltip's
// text, so re-trigger it to surface the updated "Copied" label in place.
await nextTick()
if (target instanceof HTMLElement)
target.dispatchEvent(new MouseEvent('mouseenter'))
if (copiedResetTimer) clearTimeout(copiedResetTimer)
copiedResetTimer = setTimeout(() => {
copiedPhrase.value = null
copiedResetTimer = null
}, COPIED_FEEDBACK_MS)
}
onBeforeUnmount(() => {
if (copiedResetTimer) clearTimeout(copiedResetTimer)
})
const TRIGGER_PHRASE_MAX_LENGTH = 20
function truncatePhrase(phrase: string): string {
return phrase.length > TRIGGER_PHRASE_MAX_LENGTH
? `${phrase.slice(0, TRIGGER_PHRASE_MAX_LENGTH)}`
: phrase
}
</script>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import { placeholderGradientForCategory } from '@/composables/sidebarTabs/useCategoryPlaceholder'
const { category } = defineProps<{ category: string }>()
const background = computed(() => placeholderGradientForCategory(category))
</script>
<template>
<div class="size-full" :style="{ background }" />
</template>

View File

@@ -0,0 +1,143 @@
<template>
<ContextMenuRoot v-model:open="isContextMenuOpen">
<ContextMenuTrigger as-child>
<div
ref="rowRef"
:class="LEAF_ROW_CLASS"
:data-asset-id="asset.id"
role="listitem"
tabindex="0"
@dblclick="handleActivate"
@keydown.enter.prevent="handleActivate"
>
<i
:class="
cn(
hasLoader ? 'icon-[comfy--ai-model]' : 'icon-[lucide--file]',
'size-4 shrink-0 text-muted-foreground'
)
"
/>
<span class="text-foreground min-w-0 flex-1 truncate text-sm">
{{ displayName }}
</span>
</div>
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent :class="LEAF_MENU_CONTENT_CLASS">
<ContextMenuItem
v-if="hasLoader"
:class="LEAF_MENU_ITEM_CLASS"
@select="handleActivate"
>
<i class="icon-[comfy--node] size-4" />
{{ $t('cloudModelLibrary.contextMenu.addToGraph') }}
</ContextMenuItem>
<ContextMenuItem
:class="LEAF_MENU_ITEM_CLASS"
@select="handleCopyFilename"
>
<i class="icon-[lucide--copy] size-4" />
{{ $t('cloudModelLibrary.contextMenu.copyFilename') }}
</ContextMenuItem>
<ContextMenuItem
v-if="huggingFaceUrl"
:class="LEAF_MENU_ITEM_CLASS"
@select="openHuggingFace"
>
<i class="icon-[lucide--external-link] size-4" />
{{ $t('cloudModelLibrary.contextMenu.openOnHuggingFace') }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenuPortal>
</ContextMenuRoot>
</template>
<script setup lang="ts">
import {
ContextMenuContent,
ContextMenuItem,
ContextMenuPortal,
ContextMenuRoot,
ContextMenuTrigger
} from 'reka-ui'
import { computed } from 'vue'
import { formatRowDisplayName } from '@/components/sidebar/tabs/cloudModelLibrary/modelGroups'
import { useNodePreviewDragImage } from '@/components/sidebar/tabs/cloudModelLibrary/useNodePreviewDragImage'
import {
LEAF_MENU_CONTENT_CLASS,
LEAF_MENU_ITEM_CLASS,
LEAF_ROW_CLASS,
useModelLibraryLeaf
} from '@/composables/sidebarTabs/useModelLibraryLeaf'
import { usePragmaticDraggable } from '@/composables/usePragmaticDragAndDrop'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getAssetDisplayName,
getAssetFilename,
getAssetModelType,
getAssetSourceUrl
} from '@/platform/assets/utils/assetMetadataUtils'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { cn } from '@comfyorg/tailwind-utils'
const { asset } = defineProps<{
asset: AssetItem
}>()
const emit = defineEmits<{
activate: [asset: AssetItem]
// Emitted on mouseenter/leave with the row's bounding rect. The parent owns
// the single shared hover popover and uses the rect for positioning.
hoverChange: [payload: { asset: AssetItem; rect: DOMRect } | { asset: null }]
}>()
const displayName = computed(() =>
formatRowDisplayName(getAssetDisplayName(asset))
)
const hide = () => emit('hoverChange', { asset: null })
const { rowRef, isContextMenuOpen } = useModelLibraryLeaf({
onShow: (rect) => emit('hoverChange', { asset, rect }),
onHide: hide
})
const huggingFaceUrl = computed(() => {
const url = getAssetSourceUrl(asset)
return url && url.includes('huggingface.co') ? url : ''
})
const handleCopyFilename = async () => {
await navigator.clipboard.writeText(getAssetFilename(asset))
}
const openHuggingFace = () => {
if (!huggingFaceUrl.value) return
window.open(huggingFaceUrl.value, '_blank', 'noopener,noreferrer')
}
// Whether any registered loader node can open this asset. Without one,
// drag-out and add-to-graph have nothing to create, so the affordances
// are withheld instead of failing silently.
const loaderNodeDef = computed(() => {
const category = getAssetModelType(asset)
return category
? (useModelToNodeStore().getNodeProvider(category)?.nodeDef ?? null)
: null
})
const hasLoader = computed(() => loaderNodeDef.value !== null)
const handleActivate = () => {
if (!hasLoader.value) return
emit('activate', asset)
}
const onGenerateDragPreview = useNodePreviewDragImage(() => loaderNodeDef.value)
usePragmaticDraggable(() => rowRef.value, {
getInitialData: () => ({ type: 'cloud-model-asset', asset }),
canDrag: () => hasLoader.value,
onGenerateDragPreview,
onDragStart: hide
})
</script>

View File

@@ -0,0 +1,686 @@
<template>
<SidebarTabTemplate
:title="$t('sideToolbar.modelLibrary')"
tool-buttons-always-visible
>
<template #tool-buttons>
<div class="flex items-center gap-2">
<Button
v-tooltip.bottom="$t('g.refresh')"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.refresh')"
@click="refreshAssets"
>
<i class="icon-[lucide--refresh-cw] size-4" />
</Button>
<Button
v-if="isUploadButtonEnabled"
variant="inverted"
data-attr="model-library-import-button"
@click="showUploadDialog"
>
<i class="icon-[lucide--folder-input] size-4" />
<span>{{ $t('assetBrowser.uploadModel') }}</span>
</Button>
</div>
</template>
<template #header>
<SidebarTopArea>
<SearchInput
v-model="searchQuery"
:placeholder="$t('g.searchPlaceholder', { subject: '' })"
/>
<template #actions>
<Popover :show-arrow="false">
<template #button>
<Button
v-tooltip.bottom="$t('assets.settings.tooltip')"
variant="secondary"
size="icon"
:aria-label="$t('assets.settings.tooltip')"
>
<i class="icon-[lucide--settings-2]" />
</Button>
</template>
<template #default>
<div class="flex min-w-44 flex-col">
<Button
v-for="option in GROUP_BY_OPTIONS"
:key="option.value"
variant="textonly"
class="w-full"
@click="groupBy = option.value"
>
<span class="flex items-center gap-2">
<i :class="cn(option.icon, 'size-4')" />
<span>{{ $t(option.labelKey) }}</span>
</span>
<i
class="ml-auto icon-[lucide--check] size-4"
:class="groupBy !== option.value && 'opacity-0'"
/>
</Button>
<div class="my-1 w-full border-b border-border-subtle" />
<Button
v-for="option in sortOptions"
:key="option.value"
variant="textonly"
class="w-full"
@click="sortMode = option.value"
>
<span>{{ $t(option.labelKey) }}</span>
<i
class="ml-auto icon-[lucide--check] size-4"
:class="effectiveSortMode !== option.value && 'opacity-0'"
/>
</Button>
</div>
</template>
</Popover>
</template>
</SidebarTopArea>
</template>
<template #body>
<div
v-if="isLoading"
class="flex h-full items-center justify-center text-xs text-muted-foreground"
>
{{ $t('g.loading') }}
</div>
<div
v-else-if="!sections.length"
class="flex h-full items-center justify-center px-4 text-center text-xs text-muted-foreground"
>
{{ $t('assetBrowser.noResultsCanImport') }}
</div>
<div v-else class="flex flex-col">
<template v-for="(section, sectionIndex) in sections" :key="section.id">
<div
v-if="sectionIndex === firstUserFolderSectionIndex"
class="px-2 pt-2 pb-0.5 text-3xs font-medium tracking-wide text-muted-foreground uppercase"
>
{{ $t('assets.otherModels') }}
</div>
<button
type="button"
class="group/tree-node flex w-full min-w-0 cursor-pointer items-center gap-3 overflow-hidden rounded-sm border-0 bg-transparent py-2 pl-2 text-left outline-none select-none hover:bg-comfy-input"
:aria-expanded="isExpanded(section.id)"
:aria-controls="`cloud-model-section-${section.id}`"
@click="setExpanded(section.id, !isExpanded(section.id))"
>
<i
:class="
cn(
'icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground transition-transform',
!isExpanded(section.id) && '-rotate-90'
)
"
/>
<i
class="icon-[lucide--folder] size-4 shrink-0 text-muted-foreground"
/>
<span class="text-foreground min-w-0 flex-1 truncate text-sm">
{{ section.label }}
</span>
<span class="shrink-0 pr-2 text-2xs text-muted-foreground">
{{ section.totalCount }}
</span>
</button>
<div
v-if="isExpanded(section.id)"
:id="`cloud-model-section-${section.id}`"
class="flex flex-col"
role="list"
>
<template v-for="pg in section.providers" :key="pg.provider">
<div
v-if="section.providers.length > 1"
class="pt-2 pr-2 pb-0.5 pl-8 text-3xs font-medium tracking-wide text-muted-foreground uppercase"
>
{{ pg.provider }}
</div>
<template v-for="item in pg.items" :key="itemKey(item)">
<CloudModelLeaf
v-if="item.kind === 'asset'"
:asset="item.asset"
@activate="handleAssetActivate"
@hover-change="handleAssetHoverChange"
/>
<CloudPartnerLeaf
v-else
:node-def="item.nodeDef"
@activate="handlePartnerActivate"
@hover-change="handlePartnerHoverChange"
/>
</template>
</template>
</div>
<div
v-if="
sectionIndex === lastPinnedSectionIndex &&
sectionIndex < sections.length - 1 &&
!sections[sectionIndex + 1].isUserFolder
"
class="mx-6 my-2 border-t border-border-default/40"
/>
</template>
</div>
</template>
</SidebarTabTemplate>
<teleport v-if="hoveredItem" to="body">
<div
ref="hoverPopoverRef"
class="fixed z-999"
:style="hoverPopoverStyle"
@pointerdown="handlePopoverEnter"
@mouseenter="handlePopoverEnter"
@mouseleave="handlePopoverLeave"
>
<AssetHoverPreview
v-if="hoveredItem.kind === 'asset'"
:asset="hoveredItem.asset"
/>
<PartnerNodeHoverPreview v-else :node-def="hoveredItem.nodeDef" />
</div>
</teleport>
</template>
<script setup lang="ts">
import { useStorage } from '@vueuse/core'
import { useFuse } from '@vueuse/integrations/useFuse'
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import AssetHoverPreview from '@/components/sidebar/tabs/cloudModelLibrary/AssetHoverPreview.vue'
import CloudModelLeaf from '@/components/sidebar/tabs/cloudModelLibrary/CloudModelLeaf.vue'
import CloudPartnerLeaf from '@/components/sidebar/tabs/cloudModelLibrary/CloudPartnerLeaf.vue'
import PartnerNodeHoverPreview from '@/components/sidebar/tabs/cloudModelLibrary/PartnerNodeHoverPreview.vue'
import {
MODEL_GROUPS,
PARTNER_NODES_GROUP_ID,
formatPartnerProvider,
getAssetProvider,
isPartnerNodeCategory
} from '@/components/sidebar/tabs/cloudModelLibrary/modelGroups'
import { isLikelyModelFile } from '@/components/sidebar/tabs/cloudModelLibrary/modelFileFilter'
import {
directoryForAsset,
groupAsset,
groupLabelForAsset,
partnerKind
} from '@/components/sidebar/tabs/cloudModelLibrary/modelLibraryGrouping'
import { buildProviderGroups } from '@/components/sidebar/tabs/cloudModelLibrary/modelLibrarySort'
import type {
Section,
SidebarItem,
SortMode
} from '@/components/sidebar/tabs/cloudModelLibrary/modelLibrarySort'
import Button from '@/components/ui/button/Button.vue'
import Popover from '@/components/ui/Popover.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import { useModelLibraryHoverPopover } from '@/composables/sidebarTabs/useModelLibraryHoverPopover'
import { useModelLibrarySource } from '@/composables/sidebarTabs/useModelLibrarySource'
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getAssetBaseModels,
getAssetTriggerPhrases
} from '@/platform/assets/utils/assetMetadataUtils'
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
import { useLitegraphService } from '@/services/litegraphService'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { cn } from '@comfyorg/tailwind-utils'
// Surface the most important categories at the top of the library, in this
// exact order, ahead of the alphabetically-sorted long tail.
const PINNED_GROUP_IDS: readonly string[] = [
'diffusion',
'loras',
PARTNER_NODES_GROUP_ID
]
const { t } = useI18n()
const toast = useToast()
const nodeDefStore = useNodeDefStore()
const litegraphService = useLitegraphService()
// Single unified Model Library source. The cloud distribution reads the live
// assets API; desktop/localhost enumerates the on-disk models folder. Both
// surface the same AssetItem[] shape so this component renders without
// branching on distribution.
const source = useModelLibrarySource()
// Mirrors the asset-browser modal's Import action: a header CTA that opens the
// model upload dialog. Gated on the same feature flag as the modal button, so
// it only surfaces where uploading models is supported (cloud).
const { isUploadButtonEnabled, showUploadDialog } =
useModelUpload(refreshAssets)
const searchQuery = ref('')
const ALL_SORT_OPTIONS: ReadonlyArray<{ value: SortMode; labelKey: string }> = [
{ value: 'baseModelAsc', labelKey: 'assets.sort.baseModelAsc' },
{ value: 'baseModelDesc', labelKey: 'assets.sort.baseModelDesc' },
{ value: 'recent', labelKey: 'assets.sort.recent' },
{ value: 'oldest', labelKey: 'assets.sort.oldest' },
{ value: 'nameAsc', labelKey: 'assets.sort.nameAsc' },
{ value: 'nameDesc', labelKey: 'assets.sort.nameDesc' }
] as const
type GroupBy = 'category' | 'directory'
const GROUP_BY_OPTIONS: ReadonlyArray<{
value: GroupBy
labelKey: string
icon: string
}> = [
{
value: 'category',
labelKey: 'assets.groupBy.category',
icon: 'icon-[lucide--layers]'
},
{
value: 'directory',
labelKey: 'assets.groupBy.directory',
icon: 'icon-[lucide--folder]'
}
] as const
const groupBy = useStorage<GroupBy>(
'Comfy.CloudModelLibrary.GroupBy',
'category'
)
const sortMode = useStorage<SortMode>(
'Comfy.CloudModelLibrary.SortBy',
'baseModelAsc'
)
const expanded = ref<Record<string, boolean>>({})
const expandedBeforeSearch = ref<Record<string, boolean>>({})
// Sidecar files that live next to models on disk (configs, tokenizers,
// fonts, licenses) aren't models and never belong in the library.
const assets = computed<AssetItem[]>(() =>
source.assets.value.filter(isLikelyModelFile)
)
// Base-model sorting is offered when the backend actually provides
// base-model metadata — a data capability, not a distribution check.
const hasBaseModelData = computed(() =>
assets.value.some((asset) => getAssetBaseModels(asset).length > 0)
)
const sortOptions = computed(() =>
hasBaseModelData.value
? ALL_SORT_OPTIONS
: ALL_SORT_OPTIONS.filter(
(option) =>
option.value !== 'baseModelAsc' && option.value !== 'baseModelDesc'
)
)
// A persisted base-model sort can't apply when the data has no base models;
// fall back to name order without overwriting the stored preference.
const effectiveSortMode = computed<SortMode>(() =>
!hasBaseModelData.value &&
(sortMode.value === 'baseModelAsc' || sortMode.value === 'baseModelDesc')
? 'nameAsc'
: sortMode.value
)
const partnerNodes = computed<ComfyNodeDefImpl[]>(() =>
nodeDefStore.visibleNodeDefs.filter(
(def) => def.api_node || isPartnerNodeCategory(def.category)
)
)
const isLoading = computed(
() => source.isLoading.value && assets.value.length === 0
)
// Weights are tiered so name/filename matches dominate. Secondary metadata
// (tags, provider, baseModels, etc.) only breaks ties — never outranks an
// asset whose name actually contains the query.
const assetFuseOptions: UseFuseOptions<AssetItem> = {
fuseOptions: {
keys: [
{ name: 'name', weight: 1.0 },
{ name: 'user_metadata.name', weight: 1.0 },
{ name: 'metadata.name', weight: 0.9 },
{ name: 'metadata.filename', weight: 0.9 },
{ name: 'metadata.filepath', weight: 0.4 },
{ name: 'metadata.repo_id', weight: 0.5 },
{ name: 'tags', weight: 0.15 },
{ name: 'user_metadata.user_description', weight: 0.1 },
{
name: 'provider',
weight: 0.15,
getFn: (asset) => getAssetProvider(asset)
},
{
name: 'group',
weight: 0.15,
getFn: (asset) => groupLabelForAsset(asset)
},
{
name: 'baseModels',
weight: 0.2,
getFn: (asset) => getAssetBaseModels(asset)
},
{
name: 'trainedWords',
weight: 0.15,
getFn: (asset) => getAssetTriggerPhrases(asset)
}
],
threshold: 0.3,
ignoreLocation: true,
includeScore: true
},
matchAllWhenSearchEmpty: true
}
const partnerFuseOptions: UseFuseOptions<ComfyNodeDefImpl> = {
fuseOptions: {
keys: [
{ name: 'display_name', weight: 0.5 },
{ name: 'name', weight: 0.3 },
{ name: 'category', weight: 0.2 },
{ name: 'description', weight: 0.2 },
{
name: 'provider',
weight: 0.4,
getFn: (nodeDef) => formatPartnerProvider(nodeDef.category)
},
{
name: 'kind',
weight: 0.3,
getFn: (nodeDef) => partnerKind(nodeDef.category)
}
],
threshold: 0.4,
ignoreLocation: true,
includeScore: true
},
matchAllWhenSearchEmpty: true
}
const { results: assetFuseResults } = useFuse(
searchQuery,
assets,
assetFuseOptions
)
const { results: partnerFuseResults } = useFuse(
searchQuery,
partnerNodes,
partnerFuseOptions
)
const matchedAssets = computed(() =>
assetFuseResults.value.map((result) => result.item)
)
const matchedPartners = computed(() =>
partnerFuseResults.value.map((result) => result.item)
)
const sections = computed<Section[]>(() => {
const isSearching = searchQuery.value.trim().length > 0
const mode = effectiveSortMode.value
// With an active search, collapse category sections into a single flat
// "Search results" list ordered by Fuse relevance across both pools
// (assets and partner nodes). Lower score = better match.
if (isSearching) {
type Scored = { score: number; item: SidebarItem }
const merged: Scored[] = []
for (const r of assetFuseResults.value) {
merged.push({
score: r.score ?? 1,
item: { kind: 'asset', asset: r.item }
})
}
for (const r of partnerFuseResults.value) {
merged.push({
score: r.score ?? 1,
item: { kind: 'partner', nodeDef: r.item }
})
}
if (merged.length === 0) return []
merged.sort((a, b) => a.score - b.score)
return [
{
id: 'search-results',
label: t('assets.searchResults'),
providers: [{ provider: '', items: merged.map((m) => m.item) }],
totalCount: merged.length
}
]
}
const buildAssetSection = (
id: string,
label: string,
list: AssetItem[]
): Section | null => {
if (list.length === 0) return null
const items: SidebarItem[] = list.map((asset) => ({ kind: 'asset', asset }))
return {
id,
label,
providers: buildProviderGroups(items, mode, isSearching),
totalCount: items.length
}
}
const buildPartnerSection = (): Section | null => {
if (matchedPartners.value.length === 0) return null
const items: SidebarItem[] = matchedPartners.value.map((nodeDef) => ({
kind: 'partner',
nodeDef
}))
return {
id: PARTNER_NODES_GROUP_ID,
label: t('sideToolbar.nodeLibraryTab.sections.partnerNodes'),
providers: buildProviderGroups(items, mode, isSearching),
totalCount: items.length
}
}
// Disk view: group purely by where the file lives, one verbatim section
// per directory. Partner nodes have no disk location and trail at the end.
if (groupBy.value === 'directory') {
const byDirectory = new Map<string, AssetItem[]>()
for (const asset of matchedAssets.value) {
const directory = directoryForAsset(asset) ?? ''
const list = byDirectory.get(directory) ?? []
list.push(asset)
byDirectory.set(directory, list)
}
const directorySections = Array.from(byDirectory.entries())
.map(([directory, list]) => {
const id = directory ? `dir:${directory}` : 'dir:uncategorized'
const label = directory || t('assets.groupBy.ungrouped')
return buildAssetSection(id, label, list)
})
.filter((section): section is Section => section !== null)
.sort((a, b) =>
a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
)
const partners = buildPartnerSection()
return partners ? [...directorySections, partners] : directorySections
}
const knownGroups = MODEL_GROUPS.filter(
(g) => g.id !== PARTNER_NODES_GROUP_ID
)
const assetsByGroup = new Map<string, AssetItem[]>()
const unmappedByTag = new Map<string, AssetItem[]>()
// An asset may belong to several groups: the backend tags a file with
// every model folder it could live in (shared-folder setups), and the
// base-model override can pull it into a family bucket. Membership in
// multiple sections is intended; tags with no mapping fall through to
// the verbatim "Your models" tail.
for (const asset of matchedAssets.value) {
const { groupIds, unmappedTags } = groupAsset(asset)
for (const groupId of groupIds) {
const list = assetsByGroup.get(groupId) ?? []
list.push(asset)
assetsByGroup.set(groupId, list)
}
for (const tag of unmappedTags) {
const list = unmappedByTag.get(tag) ?? []
list.push(asset)
unmappedByTag.set(tag, list)
}
}
const result: Section[] = []
// The curated PINNED_GROUP_IDS render first in their declared order
// (Diffusion LoRAs Partner nodes); everything else interleaves
// alphabetically below.
const buildSection = (id: string): Section | null => {
if (id === PARTNER_NODES_GROUP_ID) return buildPartnerSection()
const group = MODEL_GROUPS.find((g) => g.id === id)
if (!group) return null
return buildAssetSection(
group.id,
group.label,
assetsByGroup.get(group.id) ?? []
)
}
const pinnedSections: Section[] = []
for (const id of PINNED_GROUP_IDS) {
const section = buildSection(id)
if (section) pinnedSections.push(section)
}
type PendingSection = { sortKey: string; section: Section }
const pending: PendingSection[] = []
const collect = (section: Section | null) => {
if (section) pending.push({ sortKey: section.label, section })
}
for (const group of knownGroups) {
if (PINNED_GROUP_IDS.includes(group.id)) continue
collect(
buildAssetSection(
group.id,
group.label,
assetsByGroup.get(group.id) ?? []
)
)
}
// Unmapped tags are folders the curated taxonomy doesn't know. They render
// verbatim in their own trailing section so the user recognises their own
// disk structure instead of mistaking it for our categorisation.
const folderSections: PendingSection[] = []
for (const tag of unmappedByTag.keys()) {
const section = buildAssetSection(
`tag:${tag}`,
tag,
unmappedByTag.get(tag) ?? []
)
if (section) {
folderSections.push({
sortKey: tag,
section: { ...section, isUserFolder: true }
})
}
}
const bySortKey = (a: PendingSection, b: PendingSection) =>
a.sortKey.localeCompare(b.sortKey, undefined, { sensitivity: 'base' })
pending.sort(bySortKey)
folderSections.sort(bySortKey)
for (const section of pinnedSections) result.push(section)
for (const { section } of pending) result.push(section)
for (const { section } of folderSections) result.push(section)
return result
})
// Index of the last pinned section — used by the template to render a
// delimiter between the curated stack and the alphabetical long tail.
const lastPinnedSectionIndex = computed<number>(() => {
let lastIndex = -1
for (let i = 0; i < sections.value.length; i++) {
if (PINNED_GROUP_IDS.includes(sections.value[i].id)) lastIndex = i
}
return lastIndex
})
const firstUserFolderSectionIndex = computed<number>(() =>
sections.value.findIndex((section) => section.isUserFolder)
)
const isExpanded = (id: string) => Boolean(expanded.value[id])
const setExpanded = (id: string, open: boolean) => {
expanded.value = { ...expanded.value, [id]: open }
}
function itemKey(item: SidebarItem): string {
return item.kind === 'asset' ? `a:${item.asset.id}` : `n:${item.nodeDef.name}`
}
watch(searchQuery, (next, prev) => {
const wasSearching = prev.trim().length > 0
const nowSearching = next.trim().length > 0
if (!wasSearching && nowSearching) {
expandedBeforeSearch.value = { ...expanded.value }
const expandAll: Record<string, boolean> = {}
for (const section of sections.value) expandAll[section.id] = true
expanded.value = expandAll
} else if (wasSearching && !nowSearching) {
expanded.value = { ...expandedBeforeSearch.value }
}
})
async function refreshAssets(): Promise<void> {
await source.refresh()
}
const handleAssetActivate = (asset: AssetItem) => {
const result = createModelNodeFromAsset(asset)
if (!result.success) {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('assetBrowser.failedToCreateNode'),
life: 4000
})
}
}
const handlePartnerActivate = (nodeDef: ComfyNodeDefImpl) => {
litegraphService.addNodeOnGraph(nodeDef)
}
const hoverPopoverRef = ref<HTMLElement | null>(null)
const {
hoveredItem,
hoverPopoverStyle,
handleAssetHoverChange,
handlePartnerHoverChange,
handlePopoverEnter,
handlePopoverLeave
} = useModelLibraryHoverPopover(hoverPopoverRef)
onMounted(() => {
void refreshAssets()
})
</script>

View File

@@ -0,0 +1,103 @@
<template>
<ContextMenuRoot v-model:open="isContextMenuOpen">
<ContextMenuTrigger as-child>
<div
ref="rowRef"
:class="LEAF_ROW_CLASS"
:data-node-name="nodeDef.name"
role="listitem"
tabindex="0"
@dblclick="handleActivate"
@keydown.enter.prevent="handleActivate"
>
<i
:class="
cn(
'size-4 shrink-0',
hasBrandIcon
? brandIconClass
: 'icon-[lucide--cloud] text-muted-foreground'
)
"
/>
<span class="text-foreground min-w-0 flex-1 truncate text-sm">
{{ nodeDef.display_name }}
</span>
</div>
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent :class="LEAF_MENU_CONTENT_CLASS">
<ContextMenuItem :class="LEAF_MENU_ITEM_CLASS" @select="handleActivate">
<i class="icon-[comfy--node] size-4" />
{{ $t('cloudModelLibrary.contextMenu.addToGraph') }}
</ContextMenuItem>
<ContextMenuItem
:class="LEAF_MENU_ITEM_CLASS"
@select="handleCopyNodeName"
>
<i class="icon-[lucide--copy] size-4" />
{{ $t('cloudModelLibrary.contextMenu.copyNodeName') }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenuPortal>
</ContextMenuRoot>
</template>
<script setup lang="ts">
import {
ContextMenuContent,
ContextMenuItem,
ContextMenuPortal,
ContextMenuRoot,
ContextMenuTrigger
} from 'reka-ui'
import { computed } from 'vue'
import { formatPartnerProvider } from '@/components/sidebar/tabs/cloudModelLibrary/modelGroups'
import { useNodePreviewDragImage } from '@/components/sidebar/tabs/cloudModelLibrary/useNodePreviewDragImage'
import {
LEAF_MENU_CONTENT_CLASS,
LEAF_MENU_ITEM_CLASS,
LEAF_ROW_CLASS,
useModelLibraryLeaf
} from '@/composables/sidebarTabs/useModelLibraryLeaf'
import { usePragmaticDraggable } from '@/composables/usePragmaticDragAndDrop'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { getProviderIcon, hasProviderIcon } from '@/utils/categoryUtil'
import { cn } from '@comfyorg/tailwind-utils'
const { nodeDef } = defineProps<{ nodeDef: ComfyNodeDefImpl }>()
const emit = defineEmits<{
activate: [nodeDef: ComfyNodeDefImpl]
// Mirrors CloudModelLeaf — parent owns the shared hover popover.
hoverChange: [
payload: { nodeDef: ComfyNodeDefImpl; rect: DOMRect } | { nodeDef: null }
]
}>()
const provider = computed(() => formatPartnerProvider(nodeDef.category))
const hasBrandIcon = computed(() => hasProviderIcon(provider.value))
const brandIconClass = computed(() => getProviderIcon(provider.value))
const hide = () => emit('hoverChange', { nodeDef: null })
const { rowRef, isContextMenuOpen } = useModelLibraryLeaf({
onShow: (rect) => emit('hoverChange', { nodeDef, rect }),
onHide: hide
})
const handleCopyNodeName = async () => {
await navigator.clipboard.writeText(nodeDef.display_name || nodeDef.name)
}
const handleActivate = () => {
emit('activate', nodeDef)
}
const onGenerateDragPreview = useNodePreviewDragImage(() => nodeDef)
usePragmaticDraggable(() => rowRef.value, {
getInitialData: () => ({ type: 'partner-node', nodeDef }),
onGenerateDragPreview,
onDragStart: hide
})
</script>

View File

@@ -0,0 +1,42 @@
<template>
<div
class="flex w-96 flex-col gap-2 rounded-xl border border-border-default bg-comfy-menu-bg p-3 text-xs text-base-foreground shadow-lg"
>
<div
v-if="provider || kind"
class="flex items-center gap-1.5 text-2xs tracking-wide text-muted-foreground uppercase"
>
<span v-if="provider">{{ provider }}</span>
<span v-if="provider && kind" class="opacity-60">·</span>
<span v-if="kind">{{ kind }}</span>
</div>
<div class="text-sm font-semibold">{{ nodeDef.display_name }}</div>
<div v-if="nodeDef.description" class="text-muted-foreground">
{{ nodeDef.description }}
</div>
<div
class="-mx-3 mt-1 -mb-3 flex flex-col gap-1.5 border-t border-border-default bg-muted-background/40 p-3 pt-2"
>
<div class="text-2xs tracking-wide text-muted-foreground uppercase">
{{ $t('cloudModelLibrary.preview.createsNode') }}
</div>
<div class="flex justify-center">
<NodePreview :node-def="nodeDef" position="relative" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import NodePreview from '@/components/node/NodePreview.vue'
import { formatPartnerProvider } from '@/components/sidebar/tabs/cloudModelLibrary/modelGroups'
import { partnerKind } from '@/components/sidebar/tabs/cloudModelLibrary/modelLibraryGrouping'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
const { nodeDef } = defineProps<{ nodeDef: ComfyNodeDefImpl }>()
const provider = computed(() => formatPartnerProvider(nodeDef.category))
const kind = computed(() => partnerKind(nodeDef.category))
</script>

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import {
inferBaseModelFromText,
refineBaseModelLabels
} from './baseModelInference'
describe('inferBaseModelFromText', () => {
it.for<{ name: string; expected: string | null }>([
{
name: 'flux1-disney_renaissance_style.safetensors',
expected: 'Flux.1 dev'
},
{ name: 'flux1-arcane_style.safetensors', expected: 'Flux.1 dev' },
{ name: 'flux2-klein-9b-some-thing.safetensors', expected: 'Flux.2 Klein' },
{ name: 'zimage-oldschool_hud_graphics.safetensors', expected: 'Z-Image' },
{ name: 'ZImageTurbo', expected: 'Z-Image' },
{ name: 'Z-Image', expected: 'Z-Image' },
{ name: 'wan22-14b-t2v-instagirl.zip', expected: 'Wan 2.2' },
{ name: 'wan2.2-something.safetensors', expected: 'Wan 2.2' },
{ name: 'wan2.1-x.safetensors', expected: 'Wan 2.1' },
{ name: 'ltx2-squish.safetensors', expected: 'LTX 2' },
{ name: 'qwen-realcomic.zip', expected: 'Qwen' },
{
name: 'Qwen-Image-Edit-2511_Consistency.safetensors',
expected: 'Qwen Image Edit'
},
{ name: 'pony-50s_noir_movie.safetensors', expected: 'Pony' },
{
name: 'illustrious-retro_sci_fi_90_s_anime_style.safetensors',
expected: 'Illustrious'
},
{
name: 'hidream_o1_image_dev_fp8_scaled.safetensors',
expected: 'HiDream O1'
},
{ name: 'hidream-i1-bf16.safetensors', expected: 'HiDream I1' },
{ name: 'Chroma1-HD-fp8mixed.safetensors', expected: 'Chroma1 HD' },
{
name: 'chroma-radiance-x0.safetensors',
expected: 'Chroma1 Radiance'
},
{ name: 'something-unrelated.bin', expected: null }
])('infers $name -> $expected', ({ name, expected }) => {
expect(inferBaseModelFromText(name)).toBe(expected)
})
})
describe('refineBaseModelLabels', () => {
it('promotes a generic family-root label to a versioned variant from filename', () => {
expect(
refineBaseModelLabels(
['LTX Video'],
['LTX_2.3_Crisp_Enhance_Style.safetensors']
)
).toEqual(['LTX 2.3'])
})
it('replaces a non-canonical metadata label with the canonical inferred one', () => {
expect(
refineBaseModelLabels(['LTXV2'], ['ltxv23-dispatch_style.safetensors'])
).toEqual(['LTX 2.3'])
})
it('replaces a non-canonical "Flux.2 Klein 9B" with the canonical "Flux.2 Klein"', () => {
expect(
refineBaseModelLabels(
['Flux.2 Klein 9B'],
['flux-2-klein-9b-something.safetensors']
)
).toEqual(['Flux.2 Klein'])
})
it('keeps a specific label when filename only matches the family root', () => {
expect(
refineBaseModelLabels(['LTX 2.3'], ['something-ltx-tagged.safetensors'])
).toEqual(['LTX 2.3'])
})
it('does not touch labels from a different family', () => {
expect(
refineBaseModelLabels(['SDXL'], ['ltx_2.3_lora.safetensors'])
).toEqual(['SDXL'])
})
it('returns empty when input is empty', () => {
expect(refineBaseModelLabels([], ['anything.safetensors'])).toEqual([])
})
})

View File

@@ -0,0 +1,210 @@
/**
* Filename-based base-model inference for assets that lack both a
* `metadata.base_model` field and a [[BASE_MODEL_OVERRIDES]] entry — typically
* Civitai-sourced LoRAs with no HuggingFace repo. The pattern set mirrors the
* Python scraper's canonical rules so a `flux1-…` LoRA, a `zimage-…` LoRA, etc.
* land in the right bucket without manual tagging.
*
* Underscores are normalised to hyphens before matching because `\b` treats
* `_` as a word char and would otherwise miss `qwen-image_lora`.
*/
const CANONICAL_RULES: ReadonlyArray<
readonly [label: string, pattern: RegExp]
> = [
// Flux family — longest match first
['Flux.2 Klein', /\bflux[-.\s]?2[-.\s]?klein\b/i],
['Flux.2 dev', /\bflux[-.\s]?2\b/i],
['Flux.1 Krea', /\bflux[-.\s]?1?[-.\s]?krea\b/i],
['Flux.1 Kontext', /\bflux[-.\s]?1?[-.\s]?kontext\b/i],
['Flux.1 Redux', /\bflux[-.\s]?1?[-.\s]?redux\b/i],
['Flux.1 Schnell', /\bflux[-.\s]?1?[-.\s]?schnell\b/i],
['Flux.1 dev', /\bflux[-.\s]?1\b/i],
['Flux.1 dev', /\bflux\b/i],
// Stable Diffusion family — require sd/stable_diffusion prefix
['SDXL', /\bsd[-.\s]?xl\b|\bstable[-.\s]?diffusion[-.\s]?xl\b/i],
['SD 3.5', /\b(?:sd|stable[-.\s]?diffusion)[-.\s]?v?3[-.\s]?\.?5\b/i],
['SD 3', /\b(?:sd|stable[-.\s]?diffusion)[-.\s]?v?3\b/i],
['SD 2.1', /\b(?:sd|stable[-.\s]?diffusion)[-.\s]?v?2[-.\s]?\.?1\b/i],
['SD 2', /\b(?:sd|stable[-.\s]?diffusion)[-.\s]?v?2\b/i],
['SD 1.5', /\b(?:sd|stable[-.\s]?diffusion)[-.\s]?v?1[-.\s]?\.?5\b/i],
// Wan
['Wan 2.2', /\bwan[-.\s]?2[-.\s]?\.?2\b/i],
['Wan 2.1', /\bwan[-.\s]?2[-.\s]?\.?1\b/i],
['Wan', /\bwan\b/i],
// Hunyuan
['HunyuanVideo 1.5', /\bhunyuan[-.\s]?video[-.\s]?1[-.\s]?\.?5\b/i],
['HunyuanVideo', /\bhunyuan[-.\s]?video\b/i],
['Hunyuan Image', /\bhunyuan[-.\s]?image\b/i],
['Hunyuan 3D', /\bhunyuan[-.\s]?3d\b/i],
// Qwen — Image/Edit before plain Qwen
['Qwen Image Edit', /\bqwen[-.\s]?image[-.\s]?edit\b/i],
['Qwen Image', /\bqwen[-.\s]?image\b/i],
['Qwen', /\bqwen\b/i],
// SDXL-derivative bases — community treats as their own family
['Pony', /\bpony\b/i],
['Illustrious', /\billustrious\b/i],
// Other diffusion families — variants before family root
['HiDream I1', /\bhi[-_.\s]?dream[-_.\s]?i1\b/i],
['HiDream O1', /\bhi[-_.\s]?dream[-_.\s]?o1\b/i],
['HiDream', /\bhi[-.\s]?dream\b/i],
['Chroma1 Radiance', /\bchroma\d*[-_.\s]?radiance\b/i],
['Chroma1 HD', /\bchroma\d*[-_.\s]?hd\b/i],
['Chroma', /\bchroma\d*\b/i],
// Captioner / VLM families — placed before LTX so LTXV-packaged
// captioner files (e.g. `ltxv_florence2_promptgen_…`) classify by their
// actual model family, not the packaging prefix.
['CogFlorence', /\bcog[-_.\s]?florence\b/i],
['Florence-2', /\bflorence[-_.\s]?2\b/i],
['JoyCaption', /\bjoy[-_.\s]?caption\d*\b/i],
['LLaVA', /\bllava\b/i],
['SmolVLM', /\bsmol[-_.\s]?vlm\b/i],
['SmolLM2', /\bsmol[-_.\s]?lm\d*\b/i],
['SuperPrompt', /\bsuper[-_.\s]?prompt\b/i],
// Voice / TTS — Chatterbox Turbo before bare Chatterbox
['Chatterbox Turbo', /\bchatterbox[-_.\s]?turbo\b/i],
['Chatterbox', /\bchatterbox\b/i],
// Depth — V2 before V1
['Depth Anything V2', /\bdepth[-_.\s]?anything[-_.\s]?v?2\b/i],
['Depth Anything', /\bdepth[-_.\s]?anything\b/i],
// Other utility / motion / upscale families
['SegFormer', /\bsegformer\b/i],
['LivePortrait', /\blive[-_.\s]?portrait\b/i],
['DynamiCrafter', /\bdynami[-_.\s]?crafter\b/i],
['SeedVR2', /\bseed[-_.\s]?vr\d*\b/i],
['FlashVSR', /\bflash[-_.\s]?vsr\b/i],
['MimicMotion', /\bmimic[-_.\s]?motion\b/i],
['LatentSync', /\blatent[-_.\s]?sync\b/i],
// Vision encoders — SigLIP before CLIP so CLIP-only matches don't swallow siglip-*
['SigLIP', /\bsiglip\b/i],
['CLIP-ViT', /\bclip[-_.\s]?vit\b/i],
['Llama 3.2', /\bllama[-_.\s]?3[-_.\s]?\.?2\b/i],
['LTX 2.3', /\bltx[-.\s]?v?2[-.\s]?\.?3\b/i],
['LTX 2', /\bltx[-.\s]?v?2\b/i],
['LTX Video', /\bltx\b/i],
// Upscalers / restoration
['UltraSharp', /\bultrasharp\b/i],
['Real-ESRGAN', /\breal[-_.\s]?esrgan\b/i],
// Depth / normal estimation
['Lotus', /\blotus\b/i],
// Matting / background
['ViTMatte', /\bvit[-_.\s]?matte\b/i],
['LayerDiffusion', /\blayer[-_.\s]?diffusion\b|\blayer[-_.\s]?xl\b/i],
// Motion / interpolation
['RIFE', /\brife\b/i],
// Detection / pose
['GroundingDINO', /\bgrounding[-_.\s]?dino\b/i],
['DWPose', /\bdwpose\b|\bdw[-_.\s]?ll[-_.\s]?ucoco\b/i],
['Face Parsing', /\bface[-_.\s]?parsing\b/i],
// Additional language models
['ChatGLM3', /\bchat[-_.\s]?glm\d*\b/i],
['Gemma', /\bgemma\d*\b/i],
['Cosmos', /\bcosmos\b/i],
['Mochi', /\bmochi\b/i],
['Stable Audio', /\bstable[-.\s]?audio\b/i],
['AuraFlow', /\bauraflow\b/i],
['PixArt', /\bpixart\b/i],
['Kandinsky', /\bkandinsky\b/i],
['Playground', /\bplayground\b/i],
['Kolors', /\bkolors\b/i],
['Z-Image', /\bz[-_.\s]?image(?:[-_.\s]?turbo)?\b/i],
['Lumina', /\blumina\b/i],
['CogVideo', /\bcogvideo\b/i],
['AnimateDiff', /\banimatediff\b/i],
['ERNIE', /\bernie\b/i],
['Omnigen', /\bomnigen\d*\b/i],
['Ovis', /\bovis\b/i],
['ACE-Step', /\bace[-.\s]?step\b/i],
['HuMo', /\bhumo\b/i],
['LongCat', /\blongcat\b/i],
['Trellis', /\btrellis\b/i],
['USO', /\buso\b/i],
['OneReward', /\bone[-.\s]?reward\b/i],
['MoGe', /\bmoge\b/i],
['UltraShape', /\bultrashape\b/i],
['NewBie', /\bnewbie\b/i],
['PixelDiT', /\bpixel[-.\s]?dit\b/i],
['SAM 3D', /\bsam[-.\s]?3d\b/i],
['SAM 3', /\bsam[-.\s]?3(?!d)\b/i],
['SAM 2', /\bsam[-.\s]?2\b/i],
['SAM', /\bsam\b/i],
['BiRefNet', /\bbirefnet\b/i]
] as const
export function inferBaseModelFromText(text: string): string | null {
if (!text) return null
// Underscores are word chars to regex \b — swap to hyphens so things like
// "Qwen-Image_ComfyUI" or "flux1-foo" match cleanly.
const normalized = text.replace(/_/g, '-')
for (const [label, pattern] of CANONICAL_RULES) {
if (pattern.test(normalized)) return label
}
return null
}
const CANONICAL_LABELS: ReadonlySet<string> = new Set(
CANONICAL_RULES.map(([label]) => label)
)
/**
* Family-prefix rules. Maps labels (canonical and common non-canonical
* variants like `LTXV2`) onto a family bucket so refinement can spot when a
* filename suggests a more specific variant of the same family.
*/
const FAMILY_PREFIX_RULES: ReadonlyArray<readonly [RegExp, string]> = [
[/^(?:ltxv|ltx)/i, 'ltx'],
[/^(?:sdxl|sd|stable[-.\s]?diffusion)/i, 'sd'],
[/^flux/i, 'flux'],
[/^wan/i, 'wan'],
[/^hunyuan/i, 'hunyuan'],
[/^qwen/i, 'qwen'],
[/^z[-_.\s]?image/i, 'zimage'],
[/^hi[-_.\s]?dream/i, 'hidream'],
[/^sam/i, 'sam']
] as const
function familyOf(label: string): string {
for (const [pattern, family] of FAMILY_PREFIX_RULES) {
if (pattern.test(label)) return family
}
return label.toLowerCase().match(/^[a-z]+/)?.[0] ?? label.toLowerCase()
}
/**
* Refines metadata-derived base-model labels using filename inference. When
* the filename suggests a more specific variant of the same family — e.g.
* `LTX_2.3_…` whose HuggingFace card says only `Lightricks/LTX-Video` —
* promote to the specific variant.
*
* Rules per existing label:
* 1. If a filename-inferred label shares its family AND the existing label
* is non-canonical, replace with the canonical inferred label.
* 2. If both are canonical and same family, prefer the one with a version
* digit when the other has none.
*/
export function refineBaseModelLabels(
labels: readonly string[],
filenameSources: readonly string[]
): string[] {
if (labels.length === 0) return [...labels]
const inferences = filenameSources
.map((s) => inferBaseModelFromText(s))
.filter((x): x is string => Boolean(x))
if (inferences.length === 0) return [...labels]
return labels.map((existing) => {
const family = familyOf(existing)
for (const inferred of inferences) {
if (familyOf(inferred) !== family) continue
if (inferred === existing) return existing
const existingCanonical = CANONICAL_LABELS.has(existing)
const inferredCanonical = CANONICAL_LABELS.has(inferred)
if (!existingCanonical && inferredCanonical) return inferred
if (existingCanonical && inferredCanonical) {
const inferredHasDigit = /\d/.test(inferred)
const existingHasDigit = /\d/.test(existing)
if (inferredHasDigit && !existingHasDigit) return inferred
}
}
return existing
})
}

View File

@@ -0,0 +1,342 @@
/**
* Maps HuggingFace repo ids to the compatible base model(s) for any asset
* sourced from that repo. Used as a fallback when the asset itself doesn't
* carry a [[base_model]] field in its metadata.
*
* Generated one-shot from temp/scripts/scrape-base-models.py + emit-base-model-overrides.mjs
* by scraping HuggingFace cardData / tags / READMEs for every unique repo_id
* in the cloud asset list. Hand-edit entries that look wrong — the regenerator
* is destructive.
*
* Repos without a confident match are intentionally omitted; the UI falls
* back to an "Unknown base model" bucket for those.
*/
const BASE_MODEL_OVERRIDES: Readonly<Record<string, readonly string[]>> =
Object.freeze({
'100percentrobot/LTX-2.3-Audio-Reactive-LORA': ['LTX 2.3'],
'1038lab/sam3': ['SAM 3'],
'AInVFX/SeedVR2_comfyUI': ['SeedVR2'],
'alibaba-pai/Qwen-Image-2512-Fun-Controlnet-Union': ['Qwen Image'],
'alibaba-pai/Z-Image-Turbo-Fun-Controlnet-Union': ['Z-Image'],
'alibaba-pai/Z-Image-Turbo-Fun-Controlnet-Union-2.1': ['Z-Image'],
'Alissonerdx/BFS-Best-Face-Swap-Video': ['LTX 2.3'],
'Alissonerdx/LTX-LoRAs': ['LTX 2.3'],
'alvdansen/illustration-1.0-qwen-image': ['Qwen Image'],
'AviadDahan/ID-LoRA-CelebVHQ': ['LTX Video'],
'AviadDahan/ID-LoRA-TalkVid': ['LTX Video'],
'bionicman69/StarTrek_TNG_Style_LTX23': ['LTX 2.3'],
'black-forest-labs/FLUX.1-Canny-dev': ['Flux.1 dev'],
'black-forest-labs/FLUX.1-Depth-dev-lora': ['Flux.1 dev'],
'black-forest-labs/FLUX.1-dev': ['Flux.1 dev'],
'black-forest-labs/FLUX.1-Fill-dev': ['Flux.1 dev'],
'black-forest-labs/FLUX.1-Kontext-dev': ['Flux.1 Kontext'],
'black-forest-labs/FLUX.1-Redux-dev': ['Flux.1 Redux'],
'black-forest-labs/FLUX.1-schnell': ['Flux.1 Schnell'],
'black-forest-labs/FLUX.2-klein-4b-fp8': ['Flux.2 Klein'],
'black-forest-labs/FLUX.2-klein-9B': ['Flux.2 Klein'],
'black-forest-labs/FLUX.2-klein-base-4b-fp8': ['Flux.2 Klein'],
'black-forest-labs/FLUX.2-klein-base-9b-fp8': ['Flux.2 Klein'],
'black-forest-labs/FLUX.2-small-decoder': ['Flux.2 dev'],
'ByteDance/LatentSync-1.6': ['LatentSync'],
'ByteDance/SDXL-Lightning': ['SDXL'],
'ByteZSzn/Flux.2-Turbo-ComfyUI': ['Flux.2 dev'],
'clayshoaf/Make-Wojak-2511': ['Qwen Image Edit'],
'Comfy-Org/ace_step_1.5_ComfyUI_files': ['ACE-Step'],
'Comfy-Org/ACE-Step_ComfyUI_repackaged': ['ACE-Step'],
'Comfy-Org/BiRefNet': ['BiRefNet'],
'Comfy-Org/Chroma1-HD_repackaged': ['Chroma1 HD'],
'Comfy-Org/Chroma1-Radiance_Repackaged': ['Chroma1 Radiance'],
'Comfy-Org/Cosmos_Predict2_repackaged': ['Cosmos'],
'Comfy-Org/ERNIE-Image': ['ERNIE'],
'Comfy-Org/FLUX.1-Krea-dev_ComfyUI': ['Flux.1 Krea'],
'Comfy-Org/flux1-dev': ['Flux.1 dev'],
'Comfy-Org/flux1-kontext-dev_ComfyUI': ['Flux.1 Kontext'],
'Comfy-Org/flux1-schnell': ['Flux.1 Schnell'],
'Comfy-Org/flux2-dev': ['Flux.2 dev'],
'Comfy-Org/flux2-klein-4B': ['Flux.2 Klein'],
'Comfy-Org/flux2-klein-9B': ['Flux.2 Klein'],
'Comfy-Org/gemma-4': ['Gemma'],
'Comfy-Org/HiDream-I1_ComfyUI': ['HiDream I1'],
'Comfy-Org/HiDream-O1-Image': ['HiDream O1'],
'Comfy-Org/HuMo_ComfyUI': ['HuMo'],
'Comfy-Org/hunyuan3D_2.0_repackaged': ['Hunyuan 3D'],
'Comfy-Org/hunyuan3D_2.1_repackaged': ['Hunyuan 3D'],
'Comfy-Org/HunyuanVideo_1.5_repackaged': ['HunyuanVideo 1.5'],
'Comfy-Org/HunyuanVideo_repackaged': ['HunyuanVideo'],
'Comfy-Org/LongCat-Image': ['LongCat'],
'Comfy-Org/lotus': ['Lotus'],
'Comfy-Org/ltx-2': ['LTX 2'],
'Comfy-Org/ltx-2.3': ['LTX 2.3'],
'Comfy-Org/mochi_preview_repackaged': ['Mochi'],
'Comfy-Org/MoGe': ['MoGe'],
'Comfy-Org/NewBie-image-Exp0.1_repackaged': ['NewBie'],
'Comfy-Org/Omnigen2_ComfyUI_repackaged': ['Omnigen'],
'Comfy-Org/OneReward_repackaged': ['OneReward'],
'Comfy-Org/Ovis-Image': ['Ovis'],
'Comfy-Org/Qwen-Image_ComfyUI': ['Qwen Image'],
'Comfy-Org/Qwen-Image-DiffSynth-ControlNets': ['Qwen Image'],
'Comfy-Org/Qwen-Image-Edit_ComfyUI': ['Qwen Image Edit'],
'Comfy-Org/Qwen-Image-InstantX-ControlNets': ['Qwen Image'],
'Comfy-Org/Qwen-Image-Layered_ComfyUI': ['Qwen Image'],
'Comfy-Org/Real-ESRGAN_repackaged': ['Real-ESRGAN'],
'Comfy-Org/sam3.1': ['SAM 3'],
'Comfy-Org/stable-audio-3': ['Stable Audio'],
'Comfy-Org/stable-audio-open-1.0_repackaged': ['Stable Audio'],
'Comfy-Org/stable-diffusion-3.5-fp8': ['SD 3.5'],
'Comfy-Org/stable-diffusion-v1-5-archive': ['SD 1.5'],
'Comfy-Org/USO_1.0_Repackaged': ['USO'],
'Comfy-Org/vae-text-encorder-for-flux-klein-9b': ['Flux.1 dev'],
'Comfy-Org/Wan_2.1_ComfyUI_repackaged': ['Wan 2.1'],
'Comfy-Org/Wan_2.2_ComfyUI_Repackaged': ['Wan 2.2'],
'Comfy-Org/z_image': ['Z-Image'],
'Comfy-Org/z_image_turbo': ['Z-Image'],
'comfyanonymous/cosmos_1.0_text_encoder_and_VAE_ComfyUI': ['Cosmos'],
'comfyanonymous/flux_text_encoders': ['Flux.1 dev'],
'Cseti/LTX2.3-22B_IC-LoRA-Cameraman_v1': ['LTX 2.3'],
'depth-anything/DA3-BASE': ['Depth Anything'],
'depth-anything/DA3-LARGE-1.1': ['Depth Anything'],
'depth-anything/DA3-SMALL': ['Depth Anything'],
'depth-anything/DA3METRIC-LARGE': ['Depth Anything'],
'depth-anything/DA3MONO-LARGE': ['Depth Anything'],
'depth-anything/Depth-Anything-V2-Large': ['Depth Anything V2'],
'DiffSynth-Studio/Qwen-Image-Layered-Control': ['Qwen Image'],
'DoctorDiffusion/LTX-2.3-IC-LoRA-Colorizer': ['LTX 2.3'],
'duongve/NetaYume-Lumina-Image-2.0': ['Lumina'],
'dx8152/Flux2-Klein-9B-Consistency': ['Flux.2 Klein'],
'dx8152/Flux2-Klein-9B-Enhanced-Details': ['Flux.2 Klein'],
'dx8152/Qwen-Edit-2509-Light-Migration': ['Qwen Image Edit'],
'dx8152/Qwen-Edit-2509-Multiple-angles': ['Qwen Image Edit'],
'dx8152/Qwen-Image-Edit-2509-Fusion': ['Qwen Image Edit'],
'dx8152/Qwen-Image-Edit-2509-Light_restoration': ['Qwen Image Edit'],
'dx8152/Qwen-Image-Edit-2509-Relight': ['Qwen Image Edit'],
'dx8152/Qwen-Image-Edit-2509-White_to_Scene': ['Qwen Image Edit'],
'enigmatic/gummycandy_qwen': ['Qwen'],
'EQUES/qwen-image-edit-2511-lineart-interpolation': ['Qwen Image Edit'],
'fal/flux-2-klein-4B-background-remove-lora': ['Flux.2 Klein'],
'fal/flux-2-klein-4B-object-remove-lora': ['Flux.2 Klein'],
'fal/flux-2-klein-4B-outpaint-lora': ['Flux.2 Klein'],
'fal/flux-2-klein-4b-spritesheet-lora': ['Flux.2 Klein'],
'fal/flux-2-klein-4B-zoom-lora': ['Flux.2 Klein'],
'fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA': ['Qwen Image Edit'],
'fal/virtual-tryoff-lora': ['Flux.2 Klein'],
'gokaygokay/Florence-2-Flux': ['Florence-2'],
'gokaygokay/Florence-2-Flux-Large': ['Florence-2'],
'gokaygokay/Florence-2-SD3-Captioner': ['SD 3'],
'google/siglip-so400m-patch14-384': ['SigLIP'],
'guoyww/animatediff': ['AnimateDiff'],
'hr16/DWPose-TorchScript-BatchSize5': ['DWPose'],
'hr16/UnJIT-DWPose': ['DWPose'],
'HuggingFaceM4/Florence-2-DocVQA': ['Florence-2'],
'HuggingFaceTB/SmolLM2-1.7B-Instruct': ['SmolLM2'],
'HuggingFaceTB/SmolLM2-135M-Instruct': ['SmolLM2'],
'HuggingFaceTB/SmolLM2-360M-Instruct': ['SmolLM2'],
'HuggingFaceTB/SmolVLM-Instruct': ['SmolLM2', 'SigLIP'],
'hustvl/vitmatte-base-composition-1k': ['ViTMatte'],
'hustvl/vitmatte-small-composition-1k': ['ViTMatte'],
'infinith/UltraShape': ['Hunyuan 3D'],
'jetjodh/sam-3d-body-dinov3': ['SAM 3D'],
'jetjodh/sam-3d-objects': ['SAM 3D'],
'John6666/joy-caption-alpha-two-cli-mod': ['JoyCaption'],
'jonathandinu/face-parsing': ['Face Parsing'],
'joyfox/LTX2.3-ICEdit-Insight': ['LTX 2.3'],
'JunhaoZhuang/FlashVSR': ['FlashVSR'],
'JunhaoZhuang/FlashVSR-v1.1': ['FlashVSR'],
'kabachuha/ltx2-cakeify': ['LTX 2'],
'kabachuha/ltx2-eat': ['LTX 2'],
'kabachuha/ltx2-hydraulic-press': ['LTX 2'],
'kabachuha/ltx2-inflate-it': ['LTX 2'],
'kandinskylab/Kandinsky-5.0-I2V-Lite-5s': ['Kandinsky'],
'kandinskylab/Kandinsky-5.0-T2I-Lite': ['Kandinsky'],
'kandinskylab/Kandinsky-5.0-T2V-Lite-sft-5s': ['Kandinsky'],
'Kijai/ChatGLM3-safetensors': ['ChatGLM3'],
'Kijai/DepthAnythingV2-safetensors': ['Depth Anything V2'],
'Kijai/DynamiCrafter_pruned': ['DynamiCrafter'],
'Kijai/HunyuanVideo_comfy': ['HunyuanVideo'],
'Kijai/LivePortrait_safetensors': ['LivePortrait'],
'Kijai/llava-llama-3-8b-text-encoder-tokenizer': ['LLaVA'],
'Kijai/lotus-comfyui': ['Lotus'],
'Kijai/LTX2.3_comfy': ['LTX 2.3'],
'Kijai/LTXV2_comfy': ['LTX 2'],
'Kijai/MimicMotion_pruned': ['MimicMotion'],
'Kijai/sam2-safetensors': ['SAM 2'],
'Kijai/WanVideo_comfy': ['Wan 2.1'],
'Kijai/WanVideo_comfy_fp8_scaled': ['Wan 2.1'],
'Kim2091/UltraSharp': ['UltraSharp'],
'Kwai-Kolors/Kolors': ['Kolors'],
'Kwai-Kolors/Kolors-IP-Adapter-FaceID-Plus': ['Kolors'],
'Kwai-Kolors/Kolors-IP-Adapter-Plus': ['Kolors'],
'LayerDiffusion/layerdiffusion-v1': ['LayerDiffusion'],
'Lightricks/LTX-2': ['LTX 2'],
'Lightricks/LTX-2-19b-IC-LoRA-Canny-Control': ['LTX 2'],
'Lightricks/LTX-2-19b-IC-LoRA-Depth-Control': ['LTX 2'],
'Lightricks/LTX-2-19b-IC-LoRA-Detailer': ['LTX 2'],
'Lightricks/LTX-2-19b-IC-LoRA-Pose-Control': ['LTX 2'],
'Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-In': ['LTX 2'],
'Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Left': ['LTX 2'],
'Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Out': ['LTX 2'],
'Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Right': ['LTX 2'],
'Lightricks/LTX-2-19b-LoRA-Camera-Control-Jib-Down': ['LTX 2'],
'Lightricks/LTX-2-19b-LoRA-Camera-Control-Jib-Up': ['LTX 2'],
'Lightricks/LTX-2-19b-LoRA-Camera-Control-Static': ['LTX 2'],
'Lightricks/LTX-2.3': ['LTX 2.3'],
'Lightricks/LTX-2.3-22b-IC-LoRA-HDR': ['LTX 2.3'],
'Lightricks/LTX-2.3-22b-IC-LoRA-LipDub': ['LTX 2.3'],
'Lightricks/LTX-2.3-22b-IC-LoRA-Motion-Track-Control': ['LTX 2.3'],
'Lightricks/LTX-2.3-fp8': ['LTX 2.3'],
'Lightricks/LTX-Video': ['LTX Video'],
'lightx2v/Qwen-Image-2512-Lightning': ['Qwen Image'],
'lightx2v/Qwen-Image-Edit-2511-Lightning': ['Qwen Image Edit'],
'lightx2v/Qwen-Image-Lightning': ['Qwen Image'],
'lightx2v/Wan2.2-Distill-Loras': ['Wan 2.2'],
'lilylilith/AnyPose': ['Qwen Image Edit'],
'lilylilith/QIE-2511-MP-AnyLight': ['Qwen Image Edit'],
'lkeab/hq-sam': ['SAM'],
'lodestones/Chroma': ['Chroma'],
'lodestones/Chroma1-HD': ['Chroma1 HD'],
'lovis93/crt-animation-terminal-ltx-2.3-lora': ['LTX Video'],
'lovis93/next-scene-qwen-image-lora-2509': ['Qwen Image Edit'],
'lrzjason/Anything2Real_2601': ['Qwen Image Edit'],
'lrzjason/ObjectRemovalFluxFill': ['Flux.1 dev'],
'lrzjason/QwenEdit-Anything2Real_Alpha': ['Qwen Image Edit'],
'lym00/Wan2.2_T2V_A14B_VACE-test': ['Wan 2.2'],
'MachineDelusions/LTX-2_Image2Video_Adapter_LoRa': ['LTX 2'],
'marduk191/rife': ['RIFE'],
'mattmdjaga/segformer_b2_clothes': ['SegFormer'],
'MiaoshouAI/Florence-2-base-PromptGen': ['Florence-2'],
'MiaoshouAI/Florence-2-base-PromptGen-v1.5': ['Florence-2'],
'MiaoshouAI/Florence-2-base-PromptGen-v2.0': ['Florence-2'],
'MiaoshouAI/Florence-2-large-PromptGen-v1.5': ['Florence-2'],
'MiaoshouAI/Florence-2-large-PromptGen-v2.0': ['Florence-2'],
'microsoft/Florence-2-base': ['Florence-2'],
'microsoft/Florence-2-base-ft': ['Florence-2'],
'microsoft/Florence-2-large': ['Florence-2'],
'microsoft/Florence-2-large-ft': ['Florence-2'],
'Nap/depth_anything_v2_vitg': ['Depth Anything V2'],
'Nebsh/LTX2_Animatediff_Lora': ['LTX 2'],
'Nebsh/LTX2_AtomicExplosion': ['LTX 2'],
'Nebsh/LTX2_Lora_Outfitcheck': ['LTX 2'],
'Nebsh/LTX2_Lora_TimelapseHuman': ['LTX 2'],
'Nebsh/LTX2_Outfitswitch': ['LTX 2'],
'numz/SeedVR2_comfyUI': ['SeedVR2'],
'OmerHagage/ltx2-greenscreen-avatar-ic-lora-vertical-v1': ['LTX 2.3'],
'openai/clip-vit-large-patch14': ['CLIP-ViT'],
'ostris/flux2_berthe_morisot': ['Flux.2 dev'],
'oumoumad/LTX-2.3-22b-IC-LoRA-Deinterlace': ['LTX 2.3'],
'oumoumad/LTX-2.3-22b-IC-LoRA-MotionDeblur': ['LTX 2.3'],
'oumoumad/LTX-2.3-22b-IC-LoRA-Outpaint': ['LTX 2.3'],
'oumoumad/LTX-2.3-22b-IC-LoRA-ReFocus': ['LTX 2.3'],
'oumoumad/LTX-2.3-22b-IC-LoRA-Uncompress': ['LTX 2.3'],
'oumoumad/LTX-2.3-22b-IC-LoRA-Ungrade': ['LTX 2.3'],
'oumoumad/ltx-2.3-dearchive-lora': ['LTX 2.3'],
'oumoumad/LumiPic': ['Qwen Image Edit', 'Flux.2 Klein'],
'ovi054/QIE-2511-Color-Grade-Transfer-LoRA': ['Qwen Image Edit'],
'Owen777/UltraFlux-v1': ['Flux.1 dev'],
'peteromallet/Qwen-Image-Edit-InSubject': ['Qwen Image Edit'],
'Phr00t/WAN2.2-14B-Rapid-AllInOne': ['Wan 2.2'],
'PixArt-alpha/PixArt-Sigma-XL-2-1024-MS': ['PixArt'],
'prithivMLmods/QIE-2511-Extract-Outfit': ['Qwen Image Edit'],
'prithivMLmods/QIE-2511-Object-Remover-v2': ['Qwen Image Edit'],
'prithivMLmods/QIE-2511-Studio-DeLight': ['Qwen Image Edit'],
'prithivMLmods/QIE-2511-Zoom-Master': ['Qwen Image Edit'],
'prithivMLmods/Qwen-Image-Edit-2511-Midnight-Noir-Eyes-Spotlight': [
'Qwen Image Edit'
],
'prithivMLmods/Qwen-Image-Edit-2511-Noir-Comic-Book-Panel': [
'Qwen Image Edit'
],
'prithivMLmods/Qwen-Image-Edit-2511-Pixar-Inspired-3D': ['Qwen Image Edit'],
'prithivMLmods/Qwen-Image-Edit-2511-Ultra-Realistic-Portrait': [
'Qwen Image Edit'
],
'ProGamerGov/qwen-360-diffusion': ['Qwen Image'],
'Qwen/Qwen2.5-VL-3B-Instruct': ['Qwen'],
'Qwen/Qwen2.5-VL-7B-Instruct': ['Qwen'],
'Qwen/Qwen3-0.6B': ['Qwen'],
'Qwen/Qwen3-4B-Instruct-2507': ['Qwen'],
'Qwen/Qwen3-TTS-12Hz-0.6B-Base': ['Qwen'],
'Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice': ['Qwen'],
'Qwen/Qwen3-TTS-12Hz-1.7B-Base': ['Qwen'],
'Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice': ['Qwen'],
'Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign': ['Qwen'],
'Qwen/Qwen3-TTS-Tokenizer-12Hz': ['Qwen'],
'Qwen/Qwen3-VL-2B-Instruct': ['Qwen'],
'Qwen/Qwen3-VL-2B-Thinking': ['Qwen'],
'Qwen/Qwen3-VL-32B-Instruct': ['Qwen'],
'Qwen/Qwen3-VL-32B-Thinking': ['Qwen'],
'Qwen/Qwen3-VL-4B-Instruct': ['Qwen'],
'Qwen/Qwen3-VL-4B-Thinking': ['Qwen'],
'Qwen/Qwen3-VL-8B-Instruct': ['Qwen'],
'Qwen/Qwen3-VL-8B-Thinking': ['Qwen'],
'ResembleAI/chatterbox': ['Chatterbox'],
'ResembleAI/chatterbox-turbo': ['Chatterbox Turbo'],
'roborovski/superprompt-v1': ['SuperPrompt'],
'Ruicheng/moge-vitl': ['MoGe'],
'RunDiffusion/Juggernaut-XL-v9': ['SDXL'],
'sayeed99/segformer_b3_clothes': ['SegFormer'],
'sayeed99/segformer-b3-fashion': ['SegFormer'],
'Shakker-Labs/AWPortrait-QW': ['Qwen Image'],
'Shakker-Labs/AWPortrait-Z': ['Z-Image'],
'ShilongLiu/GroundingDINO': ['GroundingDINO'],
'stabilityai/sdxl-turbo': ['SDXL'],
'stabilityai/stable-audio-open-1.0': ['Stable Audio'],
'stabilityai/stable-diffusion-3.5-controlnets': ['SD 3.5'],
'stabilityai/stable-diffusion-xl-base-1.0': ['SDXL'],
'stabilityai/stable-diffusion-xl-refiner-1.0': ['SDXL'],
'StableDiffusionVN/Flux': ['Flux.1 dev'],
'systms/SYSTMS-ACTION-LoRA-Qwen-Image-Edit-2511': ['Qwen Image Edit'],
'systms/SYSTMS-FLW-IC-LORA-LTX-2.3': ['LTX Video'],
'systms/SYSTMS-INFL8-LoRA-Qwen-Image-Edit-2511': ['Qwen Image Edit'],
'systms/SYSTMS-TRNS-LoRA-Wan22': ['Wan 2.2'],
'TalmajM/LongCat-Image-Edit_ComfyUI_repackaged': ['LongCat'],
'tarn59/apply_texture_qwen_image_edit_2509': ['Qwen Image Edit'],
'tarn59/pixel_art_style_lora_z_image_turbo': ['Z-Image'],
'tencent/Hunyuan3D-2': ['Hunyuan 3D'],
'tencent/Hunyuan3D-2mv': ['Hunyuan 3D'],
'TencentARC/t2i-adapter-lineart-sdxl-1.0': ['SDXL'],
'TheBurgstall/ltx-2.3-googlyeyes-lora': ['LTX 2.3'],
'TheDenk/wan2.1-t2v-1.3b-controlnet-canny-v1': ['Wan 2.1'],
'TheDenk/wan2.1-t2v-1.3b-controlnet-depth-v1': ['Wan 2.1'],
'TheDenk/wan2.1-t2v-1.3b-controlnet-hed-v1': ['Wan 2.1'],
'TheDenk/wan2.1-t2v-14b-controlnet-canny-v1': ['Wan 2.1'],
'TheDenk/wan2.1-t2v-14b-controlnet-depth-v1': ['Wan 2.1'],
'TheDenk/wan2.1-t2v-14b-controlnet-hed-v1': ['Wan 2.1'],
'thwri/CogFlorence-2-Large-Freeze': ['Florence-2'],
'thwri/CogFlorence-2.1-Large': ['Florence-2'],
'unsloth/Llama-3.2-3B-Instruct': ['Llama 3.2'],
'vafipas663/Qwen-Edit-2509-Upscale-LoRA': ['Qwen Image Edit'],
'valiantcat/LTX-2.3-Transition-LORA': ['LTX 2.3'],
'valiantcat/LTX2-I2V-Smooth-LORA': ['LTX 2'],
'valiantcat/Qwen-Image-Edit-2509-Passionate-kiss': ['Qwen Image Edit'],
'valiantcat/Qwen-Image-Edit-2509-photous': ['Qwen Image Edit'],
'valiantcat/Qwen-Image-Edit-2511-Upscale2K': ['Qwen Image Edit'],
'vrgamedevgirl84/LTX_2.3_90s_Animation_Style_LoRa': ['LTX Video'],
'vrgamedevgirl84/LTX_2.3_Cinematic_Sci-fi-Cyberpunk_Style_LoRa': [
'LTX Video'
],
'vrgamedevgirl84/LTX_2.3_Clay_Mation_Style_LoRa': ['LTX Video'],
'vrgamedevgirl84/LTX_2.3_Crisp_Enhance_Style_LoRa': ['LTX Video'],
'vrgamedevgirl84/LTX_2.3_Fantasy_Anime_Style_LoRa': ['LTX Video'],
'vrgamedevgirl84/LTX_2.3_Fantasy_Painterly_Style_LoRa': ['LTX Video'],
'vrgamedevgirl84/LTX_2.3_Fantasy_Puppet_Style_LoRa': ['LTX Video'],
'vrgamedevgirl84/LTX_2.3_Fantasy_Realism_Style_LoRa': ['LTX Video'],
'vrgamedevgirl84/LTX_2.3_Paper_Cut_Out_Style_LoRa': ['LTX Video'],
'vrgamedevgirl84/LTX_2.3_Pixar_Toon_Style_LoRa': ['LTX Video'],
'vrgamedevgirl84/LTX_2.3_Post_Apocalyptic_Style_LoRa': ['LTX Video'],
'vrgamedevgirl84/LTX_2.3_Soft_Enhance_Style_LoRa': ['LTX Video'],
'vrgamedevgirl84/LTX_2.3_Wild_West_Style_LoRa': ['LTX Video'],
'vrgamedevgirl84/LTX2.3_Cozy_Felt_Style_LoRa': ['LTX Video'],
'Wan-AI/Wan2.2-Animate-14B': ['Wan 2.2'],
'Wuli-art/Qwen-Image-2512-Turbo-LoRA-2-Steps': ['Qwen Image'],
'xtuner/llava-llama-3-8b-v1_1-transformers': ['LLaVA'],
'YaoJiefu/multiple-characters': ['Qwen Image Edit'],
'YxZhang/evf-sam': ['SAM'],
'YxZhang/evf-sam2': ['SAM 2'],
'yzd-v/DWPose': ['DWPose'],
'ZhengPeng7/BiRefNet': ['BiRefNet'],
'Zlikwid/LTX_2.3_Upscale_IC_Lora': ['LTX 2.3'],
'zooeyy/Qwen-Edit-2511_LightingRemap_Alpha0.2': ['Qwen Image Edit']
})
export function getBaseModelOverrides(repoId: string): readonly string[] {
return BASE_MODEL_OVERRIDES[repoId] ?? []
}

View File

@@ -0,0 +1,80 @@
/**
* Maps `Comfy-Org/<repo>` ids to the actual upstream provider.
*
* The Comfy-Org HuggingFace organisation hosts ~65 repackaged copies of
* third-party models. Showing "Comfy-Org" as the provider is misleading —
* users want to know the real upstream author (e.g. Black Forest Labs for
* FLUX, NVIDIA for Cosmos).
*
* Built one-shot from a scrape of every Comfy-Org HF README (see
* `temp/scripts/scrape-comfy-org-providers.py`). Entries omitted from this
* map fall back to the default `Comfy-Org` provider string — keep that
* behaviour for repos whose true upstream we couldn't identify with
* confidence.
*/
export const COMFY_ORG_PROVIDER_OVERRIDES: Readonly<Record<string, string>> =
Object.freeze({
'Comfy-Org/ACE-Step_ComfyUI_repackaged': 'ACE-Step',
'Comfy-Org/BiRefNet': 'ZhengPeng7',
'Comfy-Org/CLIP-ViT-H-14-laion2B-s32B-b79K_repackaged': 'laion',
'Comfy-Org/Chroma1-HD_repackaged': 'lodestones',
'Comfy-Org/Chroma1-Radiance_Repackaged': 'lodestones',
'Comfy-Org/Cosmos_Predict2_repackaged': 'nvidia',
'Comfy-Org/ERNIE-Image': 'baidu',
'Comfy-Org/FLUX.1-Krea-dev_ComfyUI': 'black-forest-labs',
'Comfy-Org/Flux1-Redux-Dev': 'black-forest-labs',
'Comfy-Org/HiDream-I1_ComfyUI': 'HiDream-ai',
'Comfy-Org/HiDream-O1-Image': 'HiDream-ai',
'Comfy-Org/HuMo_ComfyUI': 'bytedance-research',
'Comfy-Org/HunyuanImage_2.1_ComfyUI': 'tencent',
'Comfy-Org/HunyuanVideo_1.5_repackaged': 'tencent',
'Comfy-Org/HunyuanVideo_repackaged': 'tencent',
'Comfy-Org/Lens': 'microsoft',
'Comfy-Org/LongCat-Image': 'meituan-longcat',
'Comfy-Org/Lumina_Image_2.0_Repackaged': 'Alpha-VLLM',
'Comfy-Org/MoGe': 'microsoft',
'Comfy-Org/NewBie-image-Exp0.1_repackaged': 'NewBie-AI',
'Comfy-Org/OneReward_repackaged': 'bytedance-research',
'Comfy-Org/Omnigen2_ComfyUI_repackaged': 'OmniGen2',
'Comfy-Org/Ovis-Image': 'AIDC-AI',
'Comfy-Org/PixelDiT': 'nvidia',
'Comfy-Org/Qwen-Image-DiffSynth-ControlNets': 'DiffSynth-Studio',
'Comfy-Org/Qwen-Image-Edit_ComfyUI': 'dx8152',
'Comfy-Org/Qwen-Image-InstantX-ControlNets': 'InstantX',
'Comfy-Org/Qwen-Image-Layered_ComfyUI': 'Qwen',
'Comfy-Org/Qwen-Image_ComfyUI': 'Qwen',
'Comfy-Org/Qwen3.5': 'Qwen',
'Comfy-Org/Real-ESRGAN_repackaged': 'xinntao',
'Comfy-Org/T2I-Adapter_ComfyUI_Repackaged': 'TencentARC',
'Comfy-Org/TRELLIS.2': 'microsoft',
'Comfy-Org/USO_1.0_Repackaged': 'bytedance-research',
'Comfy-Org/Wan_2.1_ComfyUI_repackaged': 'Wan-AI',
'Comfy-Org/Wan_2.2_ComfyUI_Repackaged': 'Wan-AI',
'Comfy-Org/ace_step_1.5_ComfyUI_files': 'ACE-Step',
'Comfy-Org/flux1-dev': 'black-forest-labs',
'Comfy-Org/flux1-kontext-dev_ComfyUI': 'black-forest-labs',
'Comfy-Org/flux1-schnell': 'black-forest-labs',
'Comfy-Org/flux2-dev': 'black-forest-labs',
'Comfy-Org/frame_interpolation': 'google-research',
'Comfy-Org/gemma-4': 'google',
'Comfy-Org/hunyuan3D_2.0_repackaged': 'tencent',
'Comfy-Org/hunyuan3D_2.1_repackaged': 'tencent',
'Comfy-Org/lotus': 'jingheya',
'Comfy-Org/ltx-2': 'ovi054',
'Comfy-Org/ltx-2.3': 'Lightricks',
'Comfy-Org/mediapipe': 'google',
'Comfy-Org/mochi_preview_repackaged': 'genmo',
'Comfy-Org/sam3.1': 'facebook',
'Comfy-Org/sigclip_vision_384': 'google',
'Comfy-Org/stable-audio-3': 'stabilityai',
'Comfy-Org/stable-audio-open-1.0_repackaged': 'stabilityai',
'Comfy-Org/stable-diffusion-3.5-controlnets_ComfyUI_repackaged':
'stabilityai',
'Comfy-Org/stable-diffusion-3.5-fp8': 'stabilityai',
'Comfy-Org/stable-diffusion-v1-5-archive': 'runwayml',
'Comfy-Org/stable_diffusion_2.1_repackaged': 'stabilityai',
'Comfy-Org/stable_diffusion_2.1_unclip_repackaged': 'stabilityai',
'Comfy-Org/void-model': 'netflix',
'Comfy-Org/z_image': 'Tongyi-MAI',
'Comfy-Org/z_image_turbo': 'Tongyi-MAI'
})

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isLikelyModelFile } from './modelFileFilter'
function makeAsset(name: string, filename?: string): AssetItem {
return {
id: 'a1',
name,
tags: ['models'],
...(filename ? { metadata: { filename } } : {})
}
}
describe('isLikelyModelFile', () => {
it('keeps recognised model formats', () => {
expect(isLikelyModelFile(makeAsset('sd_xl_base_1.0.safetensors'))).toBe(
true
)
expect(isLikelyModelFile(makeAsset('mm_sdxl_v10_beta.ckpt'))).toBe(true)
expect(isLikelyModelFile(makeAsset('yolov10m.onnx'))).toBe(true)
expect(isLikelyModelFile(makeAsset('llama-3.2.Q4_K_M.gguf'))).toBe(true)
expect(isLikelyModelFile(makeAsset('pytorch_model.bin'))).toBe(true)
})
it('drops sidecar files that live next to models on disk', () => {
expect(isLikelyModelFile(makeAsset('v1-inference.yaml'))).toBe(false)
expect(isLikelyModelFile(makeAsset('tokenizer_config.json'))).toBe(false)
expect(isLikelyModelFile(makeAsset('modeling_florence2.py'))).toBe(false)
expect(isLikelyModelFile(makeAsset('FreeMono.ttf'))).toBe(false)
expect(isLikelyModelFile(makeAsset('intrinsic_loras.txt'))).toBe(false)
})
it('drops junk basenames regardless of extension', () => {
expect(isLikelyModelFile(makeAsset('LICENSE'))).toBe(false)
expect(isLikelyModelFile(makeAsset('README.md'))).toBe(false)
})
it('keeps display names without a parseable extension', () => {
expect(isLikelyModelFile(makeAsset('Flux.1 [dev]'))).toBe(true)
expect(isLikelyModelFile(makeAsset('Stable Diffusion XL'))).toBe(true)
})
it('prefers the real filename from metadata over the display name', () => {
expect(
isLikelyModelFile(makeAsset('Florence 2 processing', 'processing.py'))
).toBe(false)
expect(
isLikelyModelFile(makeAsset('SDXL Base', 'sd_xl_base_1.0.safetensors'))
).toBe(true)
})
})

View File

@@ -0,0 +1,57 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
const MODEL_FILE_EXTENSIONS = new Set([
'safetensors',
'sft',
'ckpt',
'pt',
'pt2',
'pth',
'bin',
'onnx',
'gguf',
'ggml',
'pkl',
'h5',
'pb',
'tflite',
'engine',
'trt'
])
const JUNK_BASENAMES = new Set(['license', 'readme', 'notice', 'changelog'])
function extensionOf(name: string): string | null {
const dotIndex = name.lastIndexOf('.')
if (dotIndex <= 0 || dotIndex === name.length - 1) return null
const candidate = name.slice(dotIndex + 1)
// Display names like "Flux.1 [dev]" contain dots that aren't extensions;
// only treat short alphanumeric trailing segments as one.
if (!/^[a-zA-Z0-9]{1,12}$/.test(candidate)) return null
return candidate.toLowerCase()
}
function basenameOf(name: string): string {
const segments = name.split('/')
const last = segments[segments.length - 1]
const dotIndex = last.lastIndexOf('.')
return dotIndex > 0 ? last.slice(0, dotIndex) : last
}
/**
* Whether a library entry looks like an actual model file rather than a
* sidecar shipped alongside one (configs, tokenizers, fonts, licenses).
* Names without a parseable extension are kept: cloud display names often
* omit the file extension, so only a recognised non-model extension or a
* known junk basename excludes an entry.
*/
export function isLikelyModelFile(asset: AssetItem): boolean {
const fileName =
typeof asset.metadata?.filename === 'string'
? asset.metadata.filename
: asset.name
if (JUNK_BASENAMES.has(basenameOf(fileName).toLowerCase())) return false
const extension = extensionOf(fileName)
if (!extension) return true
return MODEL_FILE_EXTENSIONS.has(extension)
}

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { UNKNOWN_PROVIDER, getAssetProvider } from './modelGroups'
function makeAsset(metadata: Record<string, unknown>): AssetItem {
return { metadata } as unknown as AssetItem
}
describe('getAssetProvider', () => {
it('returns the override when the repo_id is a known Comfy-Org repackage', () => {
expect(
getAssetProvider(
makeAsset({ repo_id: 'Comfy-Org/Wan_2.2_ComfyUI_Repackaged' })
)
).toBe('Wan-AI')
expect(
getAssetProvider(makeAsset({ repo_id: 'Comfy-Org/flux1-dev' }))
).toBe('black-forest-labs')
})
it('falls back to the bare org for Comfy-Org repos without an override', () => {
expect(getAssetProvider(makeAsset({ repo_id: 'Comfy-Org/SDPose' }))).toBe(
'Comfy-Org'
)
})
it('returns the org prefix verbatim for non-Comfy-Org repos', () => {
expect(
getAssetProvider(makeAsset({ repo_id: 'black-forest-labs/FLUX.1-dev' }))
).toBe('black-forest-labs')
})
it('falls back to user_metadata.repo_id when metadata is missing', () => {
const asset = {
metadata: {},
user_metadata: { repo_id: 'Comfy-Org/TRELLIS.2' }
} as unknown as AssetItem
expect(getAssetProvider(asset)).toBe('microsoft')
})
it('returns the unknown sentinel when no repo_id is available', () => {
expect(getAssetProvider(makeAsset({}))).toBe(UNKNOWN_PROVIDER)
})
})

View File

@@ -0,0 +1,212 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { COMFY_ORG_PROVIDER_OVERRIDES } from './comfyOrgProviderOverrides'
export const PARTNER_NODES_GROUP_ID = 'partner-nodes'
export const UNKNOWN_PROVIDER = '—'
interface ModelGroupDef {
id: string
label: string
/** Raw category tags from the assets API that belong in this group. */
tags: readonly string[]
}
export const MODEL_GROUPS: readonly ModelGroupDef[] = [
{ id: 'loras', label: 'LoRAs', tags: ['loras'] },
{
id: 'diffusion',
label: 'Diffusion models',
tags: ['diffusion_models', 'checkpoints', 'diffusers', 'UltraShape']
},
{ id: 'language', label: 'Language models', tags: ['LLM', 'smol'] },
{
id: 'captioning',
label: 'Captioning / VLM',
tags: ['florence2', 'Joy_caption', 'superprompt-v1']
},
{
id: 'audio',
label: 'TTS & audio',
tags: ['qwen-tts', 'chatterbox', 'audio_encoders']
},
{
id: 'encoders',
label: 'Encoders',
tags: ['text_encoders', 'clip', 'clip_vision']
},
{
id: 'conditioning',
label: 'Conditioning',
tags: [
'controlnet',
'ipadapter',
'gligen',
'style_models',
'model_patches',
'inpaint'
]
},
{
id: 'segmentation',
label: 'Segmentation',
tags: [
'sams',
'sam2',
'sam3',
'sam3d',
'sam3dbody',
'EVF-SAM',
'segformer_b3_fashion',
'segformer_b3_clothes',
'segformer_b2_clothes',
'face_parsing'
]
},
{
id: 'video',
label: 'Video & motion',
tags: [
'CogVideo',
'liveportrait',
'mimicmotion',
'latentsync',
'animatediff_models',
'animatediff_motion_lora'
]
},
{
id: 'upscale',
label: 'Upscale / restore / interpolate',
tags: [
'upscale_models',
'latent_upscale_models',
'FlashVSR',
'FlashVSR-v1.1',
'SEEDVR2',
'rife',
'film',
'frame_interpolation',
'interpolation',
'optical_flow',
'onnx',
'sharp'
]
},
{
id: 'background',
label: 'Background, matting & layers',
tags: [
'BiRefNet',
'BEN',
'transparent-background',
'lama',
'rmbg',
'background_removal',
'vitmatte',
'vitmatte-base-composition-1k',
'layerstyle',
'layer_model'
]
},
{ id: 'vae', label: 'VAEs', tags: ['vae', 'vae_approx'] },
{
id: 'depth',
label: 'Depth & geometry',
tags: ['depthanything', 'depthanything3', 'geometry_estimation']
},
{
id: 'detection',
label: 'Detection / pose',
tags: [
'yolo',
'dwpose',
'ultralytics',
'detection',
'mediapipe',
'grounding-dino',
'nlf'
]
},
{ id: PARTNER_NODES_GROUP_ID, label: 'Partner nodes', tags: [] }
] as const
const TAG_TO_GROUP_ID = (() => {
const map = new Map<string, string>()
for (const group of MODEL_GROUPS) {
for (const tag of group.tags) map.set(tag.toLowerCase(), group.id)
}
return map
})()
/**
* Maps a raw asset category tag (e.g. "loras", "sam3d") to a group id.
* Matching is case-insensitive — backends disagree on casing (`cogvideo`
* vs `CogVideo`, `llm` vs `LLM`). Returns null if the tag is unmapped —
* caller should render a fallback section keyed on the raw tag so new
* categories surface immediately.
*/
export function groupIdForRawTag(rawTag: string): string | null {
return TAG_TO_GROUP_ID.get(rawTag.toLowerCase()) ?? null
}
/**
* Extracts the provider segment from a partner-node category string.
* Example: "api node/image/BFL" -> "BFL".
*/
export function formatPartnerProvider(category: string | undefined): string {
if (!category) return ''
const parts = category.split('/')
return parts[parts.length - 1] ?? ''
}
export function isPartnerNodeCategory(category: string | undefined): boolean {
if (!category) return false
return category.toLowerCase().startsWith('api node')
}
/**
* Compact display name for a row:
* - Drops anything before the first '/' (provider prefix like "microsoft/").
* - Replaces hyphens between non-space characters with spaces.
* "Florence-2-large" -> "Florence 2 large"
* - Hyphens with a space on either side (" - ") are preserved.
* - Replaces underscores with spaces ("t5gemma_b_b_ul2" -> "t5gemma b b ul2").
*/
export function formatRowDisplayName(raw: string): string {
const slashIdx = raw.indexOf('/')
const afterProvider = slashIdx >= 0 ? raw.slice(slashIdx + 1) : raw
return afterProvider.replace(/(?<=\S)-(?=\S)/g, ' ').replace(/_/g, ' ')
}
/**
* Returns the HuggingFace-style organisation prefix from an asset's repo_id
* (e.g. "Comfy-Org/stable-audio-3" -> "Comfy-Org"), or [[UNKNOWN_PROVIDER]] if
* no provider can be inferred.
*/
export function getAssetProvider(asset: AssetItem): string {
return (
resolveProvider(asset.metadata?.['repo_id']) ??
resolveProvider(asset.user_metadata?.['repo_id']) ??
resolveAuthorField(asset.metadata?.['author']) ??
resolveAuthorField(asset.user_metadata?.['author']) ??
UNKNOWN_PROVIDER
)
}
function resolveAuthorField(author: unknown): string | null {
if (typeof author !== 'string') return null
const trimmed = author.trim()
return trimmed.length > 0 ? trimmed : null
}
function resolveProvider(repoId: unknown): string | null {
if (typeof repoId !== 'string' || !repoId) return null
return COMFY_ORG_PROVIDER_OVERRIDES[repoId] ?? getRepoOrg(repoId)
}
function getRepoOrg(repoId: unknown): string | null {
if (typeof repoId !== 'string' || !repoId) return null
const org = repoId.split('/')[0]
return org && org.length > 0 ? org : null
}

View File

@@ -0,0 +1,179 @@
import { describe, expect, it } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
directoryForAsset,
firstNonModelsTag,
groupAsset,
groupLabelForAsset,
partnerKind,
rawTagTopLevel
} from './modelLibraryGrouping'
function makeAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'a1',
name: 'companion.safetensors',
tags: ['models'],
...overrides
}
}
describe('firstNonModelsTag', () => {
it('returns the first tag that is not the models tag', () => {
expect(firstNonModelsTag(makeAsset({ tags: ['models', 'loras'] }))).toBe(
'loras'
)
})
it('returns null when the only tag is the models tag', () => {
expect(firstNonModelsTag(makeAsset({ tags: ['models'] }))).toBeNull()
})
})
describe('rawTagTopLevel', () => {
it('takes the segment before the first slash', () => {
expect(rawTagTopLevel('CogVideo/VAE')).toBe('CogVideo')
expect(rawTagTopLevel('loras')).toBe('loras')
})
})
describe('partnerKind', () => {
it('extracts the modality segment of a partner category', () => {
expect(partnerKind('api node/image/BFL')).toBe('image')
})
it('returns empty string when absent', () => {
expect(partnerKind(undefined)).toBe('')
expect(partnerKind('api node')).toBe('')
})
})
describe('groupAsset', () => {
it('keeps cross-base file types (loras, vae, conditioning) in their bucket', () => {
expect(
groupAsset(makeAsset({ tags: ['models', 'loras'] })).groupIds
).toEqual(['loras'])
expect(groupAsset(makeAsset({ tags: ['models', 'vae'] })).groupIds).toEqual(
['vae']
)
expect(
groupAsset(makeAsset({ tags: ['models', 'controlnet'] })).groupIds
).toEqual(['conditioning'])
})
it('places an asset in every group its folder tags map to', () => {
const shared = makeAsset({ tags: ['models', 'checkpoints', 'loras'] })
expect(groupAsset(shared).groupIds.sort()).toEqual(['diffusion', 'loras'])
})
it('deduplicates groups when several tags map to the same bucket', () => {
const asset = makeAsset({
tags: ['models', 'checkpoints/sdxl', 'checkpoints']
})
expect(groupAsset(asset).groupIds).toEqual(['diffusion'])
})
it('groups by the top-level folder tag, not nested segments', () => {
expect(
groupAsset(makeAsset({ tags: ['models', 'CogVideo/VAE'] })).groupIds
).toEqual(['video'])
})
it('ignores base-model metadata entirely — tags are the only input', () => {
const clipEncoder = makeAsset({
tags: ['models', 'text_encoders'],
metadata: { base_model: 'SDXL' }
})
expect(groupAsset(clipEncoder).groupIds).toEqual(['encoders'])
const videoTransformer = makeAsset({
tags: ['models', 'diffusion_models'],
metadata: { base_model: 'LTX 2.3' }
})
expect(groupAsset(videoTransformer).groupIds).toEqual(['diffusion'])
})
it('leaves unmapped tags unmapped regardless of metadata', () => {
const result = groupAsset(
makeAsset({
tags: ['models', 'intrinsic_loras'],
metadata: { base_model: 'SD 1.5' }
})
)
expect(result.groupIds).toEqual([])
expect(result.unmappedTags).toEqual(['intrinsic_loras'])
})
it('matches folder tags case-insensitively', () => {
expect(
groupAsset(makeAsset({ tags: ['models', 'cogvideo'] })).groupIds
).toEqual(['video'])
expect(
groupAsset(makeAsset({ tags: ['models', 'llm/florence-2-base'] }))
.groupIds
).toEqual(['language'])
})
it('surfaces unmapped tags verbatim by top-level folder', () => {
const result = groupAsset(
makeAsset({ tags: ['models', 'kjnodes_fonts', 'loras'] })
)
expect(result.groupIds).toEqual(['loras'])
expect(result.unmappedTags).toEqual(['kjnodes_fonts'])
})
it('returns nothing for an asset with only the models tag', () => {
expect(groupAsset(makeAsset({ tags: ['models'] }))).toEqual({
groupIds: [],
unmappedTags: []
})
})
})
describe('directoryForAsset', () => {
it('derives the directory from the reported file path, dropping the models root', () => {
expect(
directoryForAsset(
makeAsset({ file_path: 'models/checkpoints/sdxl/foo.safetensors' })
)
).toBe('checkpoints/sdxl')
})
it('uses the single disk location even when folder tags are plural', () => {
const shared = makeAsset({
file_path: 'models/extra/foo.safetensors',
tags: ['models', 'checkpoints', 'loras']
})
expect(directoryForAsset(shared)).toBe('extra')
})
it('falls back to metadata paths, then the folder tag', () => {
expect(
directoryForAsset(
makeAsset({ metadata: { filepath: 'loras/flux1/foo.safetensors' } })
)
).toBe('loras/flux1')
expect(
directoryForAsset(makeAsset({ tags: ['models', 'checkpoints'] }))
).toBe('checkpoints')
})
it('returns null when nothing locates the asset', () => {
expect(directoryForAsset(makeAsset({ tags: ['models'] }))).toBeNull()
})
})
describe('groupLabelForAsset', () => {
it('uses the model group label when the asset maps to a known group', () => {
expect(groupLabelForAsset(makeAsset({ tags: ['models', 'loras'] }))).toBe(
'LoRAs'
)
})
it('falls back to the verbatim folder name for an unmapped tag', () => {
expect(
groupLabelForAsset(makeAsset({ tags: ['models', 'kjnodes_fonts'] }))
).toBe('kjnodes_fonts')
})
})

View File

@@ -0,0 +1,85 @@
import {
MODEL_GROUPS,
groupIdForRawTag
} from '@/components/sidebar/tabs/cloudModelLibrary/modelGroups'
import { MODELS_TAG } from '@/platform/assets/services/assetService'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
export function firstNonModelsTag(asset: AssetItem): string | null {
for (const tag of asset.tags) {
if (tag && tag !== MODELS_TAG) return tag
}
return null
}
export function rawTagTopLevel(tag: string): string {
return tag.split('/')[0]
}
function categoryTagsForAsset(asset: AssetItem): string[] {
return asset.tags.filter((tag) => tag && tag !== MODELS_TAG)
}
// The on-disk location of an asset. Folder tags describe what a model could
// be (a shared-folder file carries several); the reported file path records
// where it actually lives, which is what the disk view groups by.
export function directoryForAsset(asset: AssetItem): string | null {
const candidates = [
asset.file_path,
asset.metadata?.file_path,
asset.metadata?.filepath
]
for (const candidate of candidates) {
if (typeof candidate !== 'string' || !candidate) continue
const segments = candidate.split('/').slice(0, -1)
if (segments[0] === 'models') segments.shift()
const directory = segments.join('/')
if (directory) return directory
}
return firstNonModelsTag(asset)
}
export interface AssetGrouping {
/** Curated group ids the asset belongs to (deduplicated). */
groupIds: string[]
/** Top-level raw tags with no curated mapping (deduplicated). */
unmappedTags: string[]
}
/**
* Resolves every group an asset belongs to — a pure remapping of its folder
* tags onto the curated groups. An asset may carry multiple category tags
* (the backend tags a file with every model folder it could belong to, e.g.
* a file in a shared folder gets both `checkpoints` and `loras`), and
* membership in multiple groups is the intended behavior. Tags with no
* curated mapping surface verbatim so new categories and user folders stay
* visible.
*/
export function groupAsset(asset: AssetItem): AssetGrouping {
const groupIds = new Set<string>()
const unmappedTags = new Set<string>()
for (const tag of categoryTagsForAsset(asset)) {
const groupId = groupIdForRawTag(rawTagTopLevel(tag))
if (groupId) {
groupIds.add(groupId)
} else {
unmappedTags.add(rawTagTopLevel(tag))
}
}
return { groupIds: [...groupIds], unmappedTags: [...unmappedTags] }
}
export function groupLabelForAsset(asset: AssetItem): string {
const { groupIds, unmappedTags } = groupAsset(asset)
if (groupIds.length > 0) {
const group = MODEL_GROUPS.find((g) => g.id === groupIds[0])
if (group) return group.label
}
return unmappedTags[0] ?? ''
}
export function partnerKind(category: string | undefined): string {
if (!category) return ''
const parts = category.split('/')
return parts[1] ?? ''
}

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { buildProviderGroups } from './modelLibrarySort'
import type { SidebarItem } from './modelLibrarySort'
function assetItem(
name: string,
overrides: Partial<AssetItem> = {}
): SidebarItem {
return {
kind: 'asset',
asset: { id: name, name, tags: ['models'], ...overrides }
}
}
const names = (items: SidebarItem[]) =>
items.map((i) => (i.kind === 'asset' ? i.asset.name : i.nodeDef.name))
describe('buildProviderGroups — flat (non base-model) modes', () => {
const items = [assetItem('Zebra'), assetItem('apple'), assetItem('Mango')]
it('sorts a single group AZ for nameAsc (case-insensitive)', () => {
const [group] = buildProviderGroups(items, 'nameAsc', false)
expect(group.provider).toBe('')
expect(names(group.items)).toEqual(['apple', 'Mango', 'Zebra'])
})
it('reverses for nameDesc', () => {
const [group] = buildProviderGroups(items, 'nameDesc', false)
expect(names(group.items)).toEqual(['Zebra', 'Mango', 'apple'])
})
it('orders by timestamp for recent, newest first', () => {
const dated = [
assetItem('old', { created_at: '2020-01-01T00:00:00Z' }),
assetItem('new', { created_at: '2024-01-01T00:00:00Z' }),
assetItem('mid', { created_at: '2022-01-01T00:00:00Z' })
]
const [group] = buildProviderGroups(dated, 'recent', false)
expect(names(group.items)).toEqual(['new', 'mid', 'old'])
})
})
describe('buildProviderGroups — search active', () => {
it('preserves input order and does not re-sort', () => {
const items = [assetItem('Zebra'), assetItem('apple')]
const [group] = buildProviderGroups(items, 'nameAsc', true)
expect(group.provider).toBe('')
expect(names(group.items)).toEqual(['Zebra', 'apple'])
})
})
describe('buildProviderGroups — base-model grouping', () => {
it('buckets by base model with the unknown bucket anchored last', () => {
const items = [
assetItem('sdxl-model', { metadata: { base_model: 'SDXL' } }),
assetItem('sd15-model', { metadata: { base_model: 'SD 1.5' } }),
assetItem('no-base-model')
]
const groups = buildProviderGroups(items, 'baseModelAsc', false)
expect(groups.map((g) => g.provider)).toEqual(['SD 1.5', 'SDXL', '—'])
expect(names(groups[2].items)).toEqual(['no-base-model'])
})
it('reverses bucket order for baseModelDesc but keeps unknown last', () => {
const items = [
assetItem('sdxl-model', { metadata: { base_model: 'SDXL' } }),
assetItem('sd15-model', { metadata: { base_model: 'SD 1.5' } }),
assetItem('no-base-model')
]
const groups = buildProviderGroups(items, 'baseModelDesc', false)
expect(groups.map((g) => g.provider)).toEqual(['SDXL', 'SD 1.5', '—'])
})
})

View File

@@ -0,0 +1,126 @@
import { formatRowDisplayName } from '@/components/sidebar/tabs/cloudModelLibrary/modelGroups'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getAssetBaseModels,
getAssetDisplayName
} from '@/platform/assets/utils/assetMetadataUtils'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
type AssetEntry = { kind: 'asset'; asset: AssetItem }
type PartnerEntry = { kind: 'partner'; nodeDef: ComfyNodeDefImpl }
export type SidebarItem = AssetEntry | PartnerEntry
export type ProviderGroup = { provider: string; items: SidebarItem[] }
export type Section = {
id: string
label: string
providers: ProviderGroup[]
totalCount: number
/** Unmapped user folder shown verbatim, rendered after curated groups. */
isUserFolder?: boolean
}
export type SortMode =
| 'recent'
| 'oldest'
| 'nameAsc'
| 'nameDesc'
| 'baseModelAsc'
| 'baseModelDesc'
const UNKNOWN_BASE_MODEL_LABEL = '—'
function itemSortKey(item: SidebarItem): string {
return item.kind === 'asset'
? formatRowDisplayName(getAssetDisplayName(item.asset))
: (item.nodeDef.display_name ?? item.nodeDef.name)
}
function itemTimestamp(item: SidebarItem): number {
if (item.kind !== 'asset') return 0
const ts = item.asset.created_at ?? item.asset.updated_at
if (!ts) return 0
const parsed = Date.parse(ts)
return Number.isNaN(parsed) ? 0 : parsed
}
function compareByName(a: SidebarItem, b: SidebarItem): number {
return itemSortKey(a).localeCompare(itemSortKey(b), undefined, {
sensitivity: 'base'
})
}
function compareByMode(a: SidebarItem, b: SidebarItem, mode: SortMode): number {
switch (mode) {
case 'recent':
return itemTimestamp(b) - itemTimestamp(a) || compareByName(a, b)
case 'oldest':
return itemTimestamp(a) - itemTimestamp(b) || compareByName(a, b)
case 'nameDesc':
case 'baseModelDesc':
return -compareByName(a, b)
case 'nameAsc':
case 'baseModelAsc':
default:
return compareByName(a, b)
}
}
function isBaseModelMode(mode: SortMode): boolean {
return mode === 'baseModelAsc' || mode === 'baseModelDesc'
}
function itemBaseModels(item: SidebarItem): string[] {
if (item.kind === 'asset') return getAssetBaseModels(item.asset)
return []
}
export function buildProviderGroups(
items: SidebarItem[],
mode: SortMode,
isSearching: boolean
): ProviderGroup[] {
// When a search is active, preserve Fuse's relevance ranking instead of
// re-sorting by the user's chosen sort mode.
if (isSearching) {
return [{ provider: '', items: items.slice() }]
}
if (!isBaseModelMode(mode)) {
return [
{
provider: '',
items: items.slice().sort((a, b) => compareByMode(a, b, mode))
}
]
}
// Items with multiple compatible base models show under each. Items with
// no known base land in a trailing "—" bucket.
const buckets = new Map<string, SidebarItem[]>()
for (const item of items) {
const bases = itemBaseModels(item)
if (bases.length === 0) {
const list = buckets.get(UNKNOWN_BASE_MODEL_LABEL) ?? []
list.push(item)
buckets.set(UNKNOWN_BASE_MODEL_LABEL, list)
continue
}
for (const base of bases) {
const list = buckets.get(base) ?? []
list.push(item)
buckets.set(base, list)
}
}
const direction = mode === 'baseModelDesc' ? -1 : 1
const labels = Array.from(buckets.keys()).sort((a, b) => {
if (a === UNKNOWN_BASE_MODEL_LABEL && b !== UNKNOWN_BASE_MODEL_LABEL)
return 1
if (b === UNKNOWN_BASE_MODEL_LABEL && a !== UNKNOWN_BASE_MODEL_LABEL)
return -1
return direction * a.localeCompare(b, undefined, { sensitivity: 'base' })
})
return labels.map((label) => ({
provider: label,
items: (buckets.get(label) ?? []).slice().sort(compareByName)
}))
}

View File

@@ -0,0 +1,40 @@
import type { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'
import { getCurrentInstance, h, render } from 'vue'
import NodePreview from '@/components/node/NodePreview.vue'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
type DragPreviewArgs = Parameters<
NonNullable<Parameters<typeof draggable>[0]['onGenerateDragPreview']>
>[0]
/**
* Renders a [[NodePreview]] under the cursor while the row is being dragged.
* Returns an [[onGenerateDragPreview]] handler ready to pass to
* [[usePragmaticDraggable]]; if [[resolveNodeDef]] yields null the browser's
* default drag image is used.
*/
export function useNodePreviewDragImage(
resolveNodeDef: () => ComfyNodeDefV2 | null
) {
const appContext = getCurrentInstance()?.appContext ?? null
return function onGenerateDragPreview({
nativeSetDragImage
}: DragPreviewArgs) {
const nodeDef = resolveNodeDef()
if (!nodeDef) return
setCustomNativeDragPreview({
nativeSetDragImage,
render: ({ container }) => {
const vnode = h(NodePreview, { nodeDef, position: 'relative' })
if (appContext) vnode.appContext = appContext
render(vnode, container)
return () => {
render(null, container)
}
}
})
}
}

View File

@@ -0,0 +1,57 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getAssetModelType } from '@/platform/assets/utils/assetMetadataUtils'
// Three-color gradient placeholders, one per category. Used in the model
// library hover popover when neither a native nor a curated thumbnail is
// available so the user still gets a visual cue tied to the model type.
type Palette = readonly [string, string, string]
const CATEGORY_PALETTES: Record<string, Palette> = {
loras: ['#ec4899', '#a855f7', '#6366f1'],
vae: ['#06b6d4', '#0891b2', '#0e7490'],
text_encoders: ['#f59e0b', '#dc2626', '#7c2d12'],
diffusion_models: ['#10b981', '#059669', '#064e3b'],
checkpoints: ['#8b5cf6', '#7c3aed', '#5b21b6'],
controlnet: ['#0ea5e9', '#0284c7', '#075985'],
ipadapter: ['#f43f5e', '#e11d48', '#9f1239'],
upscale_models: ['#eab308', '#ca8a04', '#854d0e'],
depthanything: ['#84cc16', '#65a30d', '#365314'],
florence2: ['#a78bfa', '#7c3aed', '#4c1d95'],
sam3d: ['#34d399', '#14b8a6', '#0f766e'],
geometry_estimation: ['#fb923c', '#f97316', '#9a3412'],
model_patches: ['#94a3b8', '#64748b', '#334155'],
smol: ['#fde047', '#facc15', '#a16207'],
LLM: ['#f97316', '#ea580c', '#7c2d12']
}
function hashString(value: string): number {
let hash = 0
for (let i = 0; i < value.length; i++) {
hash = (hash * 31 + value.charCodeAt(i)) | 0
}
return Math.abs(hash)
}
function paletteFromHash(category: string): Palette {
const base = hashString(category) % 360
return [
`hsl(${base}, 70%, 55%)`,
`hsl(${(base + 40) % 360}, 65%, 45%)`,
`hsl(${(base + 80) % 360}, 60%, 35%)`
]
}
function topLevel(category: string): string {
return category.split('/')[0]
}
export function placeholderGradientForCategory(category: string): string {
const key = topLevel(category)
const palette = CATEGORY_PALETTES[key] ?? paletteFromHash(key)
return `linear-gradient(135deg, ${palette[0]}, ${palette[1]}, ${palette[2]})`
}
export function placeholderCategoryForAsset(asset: AssetItem): string {
return getAssetModelType(asset) ?? 'unknown'
}

View File

@@ -0,0 +1,18 @@
import { markRaw } from 'vue'
import CloudModelLibrarySidebarTab from '@/components/sidebar/tabs/cloudModelLibrary/CloudModelLibrarySidebarTab.vue'
import type { SidebarTabExtension } from '@/types/extensionTypes'
const CLOUD_MODEL_LIBRARY_TAB_ID = 'model-library'
export const useCloudModelLibrarySidebarTab = (): SidebarTabExtension => {
return {
id: CLOUD_MODEL_LIBRARY_TAB_ID,
icon: 'icon-[comfy--ai-model]',
title: 'sideToolbar.modelLibrary',
tooltip: 'sideToolbar.modelLibrary',
label: 'sideToolbar.labels.models',
component: markRaw(CloudModelLibrarySidebarTab),
type: 'vue'
}
}

View File

@@ -0,0 +1,117 @@
import { computed, ref, watch } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { ComfyModelDef } from '@/stores/modelStore'
import { useModelStore } from '@/stores/modelStore'
// Local "Model Library" data source for desktop/localhost distributions. Wraps
// the legacy useModelStore (which lists folders via /models and files via
// /models/{folder}) and adapts each ComfyModelDef into the AssetItem shape so
// the existing cloud library UI can render local files without forking.
//
// AssetItem shape mapping:
// id local:<directory>/<file_name> (stable, collision-safe)
// name normalized file_name (path within folder, e.g. sdxl/foo)
// display_name leaf filename without .safetensors
// tags ['models', <directory>] (drives category grouping)
// metadata { filepath, directory, path_index } (used downstream)
//
// Cloud-only fields like preview_url, base_model, repo_id stay undefined until
// the enrichment layers (sibling image / safetensors header / Civitai) land.
function adaptModelToAsset(model: ComfyModelDef): AssetItem {
const filepath = `${model.directory}/${model.normalized_file_name}`
const tags = ['models', model.directory]
for (const t of model.tags) {
if (t && !tags.includes(t)) tags.push(t)
}
const id = `local:${filepath}`
return {
id,
name: model.normalized_file_name,
display_name:
model.title?.trim() ||
model.simplified_file_name ||
model.normalized_file_name,
tags,
is_immutable: false,
metadata: {
filepath,
directory: model.directory,
path_index: model.path_index,
base_model: model.architecture_id || undefined,
author: model.author || undefined,
description: model.description || undefined,
trigger_phrase: model.trigger_phrase || undefined,
resolution: model.resolution || undefined,
usage_hint: model.usage_hint || undefined,
preview_image: model.image || undefined
}
}
}
export interface LocalModelLibrarySource {
assets: ComputedRef<AssetItem[]>
isLoading: Ref<boolean>
refresh: () => Promise<void>
}
// Module-level shared state so calling useLocalModelLibrarySource() from
// multiple sites (sidebar tab, widget picker, etc.) shares one fetch lifecycle
// instead of clobbering useModelStore's folder map on each call.
let cached: LocalModelLibrarySource | null = null
export function useLocalModelLibrarySource(): LocalModelLibrarySource {
if (cached) return cached
const modelStore = useModelStore()
const isLoading = ref(false)
// ComfyModelDef fields are mutated on plain class instances after load() —
// Vue can't reliably observe that. Bumping enrichmentTick after each load
// forces the assets computed to re-read the (now-populated) fields.
const enrichmentTick = ref(0)
let inflight: Promise<void> | null = null
async function refresh(): Promise<void> {
if (inflight) return inflight
isLoading.value = true
inflight = (async () => {
try {
await modelStore.loadModelFolders()
await modelStore.loadModels()
} finally {
isLoading.value = false
inflight = null
}
})()
return inflight
}
void refresh()
const assets = computed<AssetItem[]>(() => {
// Touch the tick so this recomputes when new metadata lands.
void enrichmentTick.value
return modelStore.models.map(adaptModelToAsset)
})
// Trigger per-file safetensors metadata loading lazily. After each load
// resolves we bump enrichmentTick so the computed picks up the new fields.
watch(
() => modelStore.models.length,
() => {
for (const m of modelStore.models) {
if (!m.has_loaded_metadata && !m.is_load_requested) {
void m.load().then(() => {
enrichmentTick.value++
})
}
}
},
{ immediate: true }
)
cached = { assets, isLoading, refresh }
return cached
}

View File

@@ -0,0 +1,123 @@
import { useEventListener, useResizeObserver } from '@vueuse/core'
import type { CSSProperties, Ref } from 'vue'
import { nextTick, onBeforeUnmount, ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
// Single shared hover popover, owned by the sidebar tab. Leaves emit
// `hover-change` with their row rect; we position the popover next to the
// row, swap content as the user moves between rows (no stacking), and
// support the row → popover mouse bridge with a short hide delay.
const HOVER_BRIDGE_DELAY_MS = 120
const HOVER_GAP_PX = 12
const HOVER_VIEWPORT_MARGIN_PX = 8
type HoveredItem =
| { kind: 'asset'; asset: AssetItem; rect: DOMRect }
| { kind: 'partner'; nodeDef: ComfyNodeDefImpl; rect: DOMRect }
export function useModelLibraryHoverPopover(
hoverPopoverRef: Ref<HTMLElement | null>
) {
const hoveredItem = ref<HoveredItem | null>(null)
const hoverPopoverStyle = ref<CSSProperties>({ top: '0px', left: '0px' })
let hoverHideTimer: ReturnType<typeof setTimeout> | null = null
function cancelHoverHide() {
if (hoverHideTimer !== null) {
clearTimeout(hoverHideTimer)
hoverHideTimer = null
}
}
function scheduleHoverHide() {
cancelHoverHide()
hoverHideTimer = setTimeout(() => {
hoveredItem.value = null
hoverHideTimer = null
}, HOVER_BRIDGE_DELAY_MS)
}
async function updateHoverPopoverPosition() {
const rect = hoveredItem.value?.rect
if (!rect) return
await nextTick()
const el = hoverPopoverRef.value
const popoverHeight = el?.offsetHeight ?? 240
const minTop = HOVER_VIEWPORT_MARGIN_PX
const maxTop = Math.max(
minTop,
window.innerHeight - popoverHeight - HOVER_VIEWPORT_MARGIN_PX
)
const top = Math.max(minTop, Math.min(rect.top, maxTop))
hoverPopoverStyle.value = {
top: `${top}px`,
left: `${rect.right + HOVER_GAP_PX}px`
}
}
function handleAssetHoverChange(
payload: { asset: AssetItem; rect: DOMRect } | { asset: null }
) {
if (payload.asset) {
cancelHoverHide()
hoveredItem.value = {
kind: 'asset',
asset: payload.asset,
rect: payload.rect
}
void updateHoverPopoverPosition()
} else {
scheduleHoverHide()
}
}
function handlePartnerHoverChange(
payload: { nodeDef: ComfyNodeDefImpl; rect: DOMRect } | { nodeDef: null }
) {
if (payload.nodeDef) {
cancelHoverHide()
hoveredItem.value = {
kind: 'partner',
nodeDef: payload.nodeDef,
rect: payload.rect
}
void updateHoverPopoverPosition()
} else {
scheduleHoverHide()
}
}
function handlePopoverEnter() {
cancelHoverHide()
}
function handlePopoverLeave() {
scheduleHoverHide()
}
useResizeObserver(hoverPopoverRef, () => {
void updateHoverPopoverPosition()
})
useEventListener(window, 'resize', () => {
void updateHoverPopoverPosition()
})
useEventListener(
window,
'scroll',
() => {
void updateHoverPopoverPosition()
},
true
)
onBeforeUnmount(() => {
cancelHoverHide()
})
return {
hoveredItem,
hoverPopoverStyle,
handleAssetHoverChange,
handlePartnerHoverChange,
handlePopoverEnter,
handlePopoverLeave
}
}

View File

@@ -0,0 +1,42 @@
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
export const LEAF_ROW_CLASS =
'group/tree-node flex w-full min-w-0 cursor-grab items-center gap-2 overflow-hidden rounded-sm py-1.5 pr-2 pl-8 outline-none select-none hover:bg-comfy-input'
export const LEAF_MENU_CONTENT_CLASS =
'z-9999 min-w-44 overflow-hidden rounded-md border border-border-default bg-comfy-menu-bg p-1 shadow-md'
export const LEAF_MENU_ITEM_CLASS =
'flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none hover:bg-highlight focus:bg-highlight'
// Shared row wiring for a Model Library leaf (asset or partner node): the row
// ref, context-menu open state, and the mouseenter/leave bridge that drives the
// parent's shared hover popover via onShow(rect)/onHide().
export function useModelLibraryLeaf(options: {
onShow: (rect: DOMRect) => void
onHide: () => void
}) {
const rowRef = ref<HTMLElement | null>(null)
const isContextMenuOpen = ref(false)
// Opening the context menu dismisses the hover popover so the two don't stack.
watch(isContextMenuOpen, (open) => {
if (open) options.onHide()
})
const handleMouseEnter = () => {
const rect = rowRef.value?.getBoundingClientRect()
if (rect) options.onShow(rect)
}
const handleMouseLeave = () => options.onHide()
onMounted(() => {
rowRef.value?.addEventListener('mouseenter', handleMouseEnter)
rowRef.value?.addEventListener('mouseleave', handleMouseLeave)
})
onBeforeUnmount(() => {
rowRef.value?.removeEventListener('mouseenter', handleMouseEnter)
rowRef.value?.removeEventListener('mouseleave', handleMouseLeave)
options.onHide()
})
return { rowRef, isContextMenuOpen }
}

View File

@@ -1,28 +0,0 @@
import { markRaw } from 'vue'
import ModelLibrarySidebarTab from '@/components/sidebar/tabs/ModelLibrarySidebarTab.vue'
import { isDesktop } from '@/platform/distribution/types'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useModelLibrarySidebarTab = (): SidebarTabExtension => {
return {
id: 'model-library',
icon: 'icon-[comfy--ai-model]',
title: 'sideToolbar.modelLibrary',
tooltip: 'sideToolbar.modelLibrary',
label: 'sideToolbar.labels.models',
component: markRaw(ModelLibrarySidebarTab),
type: 'vue',
iconBadge: () => {
if (isDesktop) {
const electronDownloadStore = useElectronDownloadStore()
if (electronDownloadStore.inProgressDownloads.length > 0) {
return electronDownloadStore.inProgressDownloads.length.toString()
}
}
return null
}
}
}

View File

@@ -0,0 +1,33 @@
import { computed } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import { MODELS_TAG } from '@/platform/assets/services/assetService'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetsStore } from '@/stores/assetsStore'
// Model Library data source. Reads the assets API on every distribution —
// the library assumes an assets-enabled backend and renders whatever it
// serves; behavioral differences come from the data, not the distribution.
export interface ModelLibrarySource {
assets: ComputedRef<AssetItem[]>
isLoading: ComputedRef<boolean> | Ref<boolean>
refresh: () => Promise<void>
}
const CACHE_KEY = `tag:${MODELS_TAG}`
export function useModelLibrarySource(): ModelLibrarySource {
const assetsStore = useAssetsStore()
async function refresh(): Promise<void> {
await assetsStore.updateModelsForTag(MODELS_TAG)
}
const assets = computed<AssetItem[]>(() => assetsStore.getAssets(CACHE_KEY))
const isLoading = computed(
() => assetsStore.isModelLoading(CACHE_KEY) && assets.value.length === 0
)
return { assets, isLoading, refresh }
}

View File

@@ -0,0 +1,31 @@
import { useStorage } from '@vueuse/core'
import type { Ref } from 'vue'
// localStorage-backed MRU list of model identifiers (asset filenames) the user
// has picked from a node's model widget. Surfaced as a "Recently used" section
// at the top of the model dropdown so users can jump back to recent picks.
//
// Stored as a flat array; most recently used first. Capped to keep storage
// bounded and the popover scannable.
const STORAGE_KEY = 'Comfy.NodeModelWidget.RecentlyUsed.v1'
const MAX_ENTRIES = 16
const TOP_DISPLAY = 3
const recentNames: Ref<string[]> = useStorage<string[]>(STORAGE_KEY, [])
export function useRecentlyUsedModels() {
function markUsed(name: string): void {
const trimmed = name?.trim()
if (!trimmed) return
const next = [trimmed, ...recentNames.value.filter((n) => n !== trimmed)]
recentNames.value = next.slice(0, MAX_ENTRIES)
}
return {
recentNames,
/** Names to render in the "Recently used" section, most recent first. */
topNames: () => recentNames.value.slice(0, TOP_DISPLAY),
markUsed
}
}

View File

@@ -4,6 +4,8 @@ import { useSharedCanvasPositionConversion } from '@/composables/element/useCanv
import { usePragmaticDroppable } from '@/composables/usePragmaticDragAndDrop'
import type { LGraphNode, Point } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
@@ -15,6 +17,11 @@ import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
const isCopyDropSource = (type: unknown) =>
type === 'tree-explorer-node' ||
type === 'cloud-model-asset' ||
type === 'partner-node'
export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
const modelToNodeStore = useModelToNodeStore()
const litegraphService = useLitegraphService()
@@ -22,11 +29,29 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
usePragmaticDroppable(() => canvasRef.value, {
getDropEffect: (args): Exclude<DataTransfer['dropEffect'], 'none'> =>
args.source.data.type === 'tree-explorer-node' ? 'copy' : 'move',
isCopyDropSource(args.source.data.type) ? 'copy' : 'move',
onDrop: async (event) => {
const loc = event.location.current.input
const dndData = event.source.data
if (dndData.type === 'cloud-model-asset') {
const asset = dndData.asset as AssetItem
const conv = useSharedCanvasPositionConversion()
const basePos = conv.clientPosToCanvasPos([loc.clientX, loc.clientY])
createModelNodeFromAsset(asset, { position: basePos })
return
}
if (dndData.type === 'partner-node') {
const nodeDef = dndData.nodeDef as ComfyNodeDefImpl
const conv = useSharedCanvasPositionConversion()
const basePos = conv.clientPosToCanvasPos([loc.clientX, loc.clientY])
const pos: Point = [...basePos]
pos[1] += LiteGraph.NODE_TITLE_HEIGHT
litegraphService.addNodeOnGraph(nodeDef, { pos })
return
}
if (dndData.type === 'tree-explorer-node') {
const node = dndData.data as RenderedTreeExplorerNode
const conv = useSharedCanvasPositionConversion()

View File

@@ -21,6 +21,7 @@ import type { Point } from '@/lib/litegraph/src/litegraph'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildSupportUrl } from '@/platform/support/config'
import { useTelemetry } from '@/platform/telemetry'
@@ -53,6 +54,7 @@ import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { ensureWorkflowSuffix, getWorkflowSuffix } from '@/utils/formatUtil'
import {
@@ -1300,6 +1302,11 @@ export function useCoreCommands(): ComfyCommand[] {
await settingStore.set('Comfy.Assets.UseAssetAPI', true)
await workflowService.reloadCurrentWorkflow()
}
if (isCloud) {
const sidebarTabStore = useSidebarTabStore()
sidebarTabStore.toggleSidebarTab('model-library')
return
}
const assetBrowserDialog = useAssetBrowserDialog()
await assetBrowserDialog.browse({
assetType: 'models',

View File

@@ -2769,14 +2769,15 @@
"selectModel": "Select model",
"uploadSelect": {
"placeholder": "Select...",
"placeholderImage": "Select image...",
"placeholderImage": "Search media assets",
"placeholderAudio": "Select audio...",
"placeholderVideo": "Select video...",
"placeholderMesh": "Select mesh...",
"placeholderModel": "Select model...",
"placeholderUnknown": "Select media...",
"maxSelectionReached": "Maximum selection limit reached",
"topResult": "Top result: {result}"
"topResult": "Top result: {result}",
"importMedia": "Import media"
},
"valueControl": {
"header": {
@@ -3018,6 +3019,44 @@
"cloudSurvey_steps_familiarity": "How familiar are you with ComfyUI?",
"cloudSurvey_steps_intent": "What do you want to create with ComfyUI?",
"cloudSurvey_steps_source": "Where did you hear about ComfyUI?",
"assets": {
"settings": {
"tooltip": "Settings"
},
"groupBy": {
"category": "Group by category",
"directory": "Group by directory",
"ungrouped": "Ungrouped"
},
"sort": {
"tooltip": "Sort",
"recent": "Most recent first",
"oldest": "Oldest first",
"nameAsc": "Name AZ",
"nameDesc": "Name ZA",
"baseModelAsc": "Base model AZ",
"baseModelDesc": "Base model ZA"
},
"searchResults": "Search results",
"otherModels": "Other models"
},
"cloudModelLibrary": {
"preview": {
"createsNode": "Creates node",
"triggerWords": "Trigger words",
"description": "Description",
"nodePreview": "Node preview",
"noLoader": "No node available to load this model",
"url": "URL",
"openUrl": "Open URL"
},
"contextMenu": {
"addToGraph": "Add to graph",
"copyFilename": "Copy filename",
"copyNodeName": "Copy node name",
"openOnHuggingFace": "Open on Hugging Face"
}
},
"assetBrowser": {
"allCategory": "All {category}",
"allModels": "All Models",
@@ -3111,6 +3150,7 @@
"selectFrameworks": "Select Frameworks",
"selectModelType": "Select model type",
"selectProjects": "Select Projects",
"recentlyUsed": "Recently used",
"sortAZ": "A-Z",
"sortDefault": "Default",
"sortBy": "Sort by",

View File

@@ -150,6 +150,7 @@ const mockCreateAssetExport = vi.hoisted(() =>
vi.fn().mockResolvedValue({ task_id: 'test-task-id', status: 'pending' })
)
vi.mock('../services/assetService', () => ({
MODELS_TAG: 'models',
assetService: {
deleteAsset: mockDeleteAsset,
createAssetExport: mockCreateAssetExport

View File

@@ -8,6 +8,7 @@ import type { AsyncUploadResponse } from '@/platform/assets/schemas/assetSchema'
import { useUploadModelWizard } from './useUploadModelWizard'
vi.mock('@/platform/assets/services/assetService', () => ({
MODELS_TAG: 'models',
assetService: {
uploadAssetAsync: vi.fn(),
uploadAssetPreviewImage: vi.fn()

View File

@@ -0,0 +1,8 @@
// SPIKE — do not merge. Forces the in-node model picker down its isCloud
// codepaths (asset-browser widgets, assets-store data source, base-model
// sort/filters) so a desktop/localhost build can run against a Core that
// implements the assets API. The sidebar Model Library no longer needs
// forcing (assets API on every distribution since #12634). Media branches
// (cloud input/output assets) stay distribution-gated because they depend
// on cloud-only data.
export const forceModelPickerAssetMode = true

View File

@@ -8,6 +8,7 @@ const zAsset = z.object({
hash: z.string().nullish(),
size: z.number().optional(), // TBD: Will be provided by history API in the future
mime_type: z.string().nullish(),
file_path: z.string().nullish(),
tags: z.array(z.string()).optional().default([]),
preview_id: z.string().nullable().optional(),
display_name: z.string().optional(),

View File

@@ -99,12 +99,20 @@ describe(assetService.shouldUseAssetBrowser, () => {
mockSettingStoreGet.mockReturnValue(false)
})
it('returns false when not on cloud', () => {
it('returns true on local for an eligible model widget regardless of the asset API setting', () => {
mockDistributionState.isCloud = false
mockSettingStoreGet.mockReturnValue(true)
mockSettingStoreGet.mockReturnValue(false)
expect(
assetService.shouldUseAssetBrowser('CheckpointLoaderSimple', 'ckpt_name')
).toBe(true)
})
it('returns false on local for an ineligible widget', () => {
mockDistributionState.isCloud = false
expect(
assetService.shouldUseAssetBrowser('UnknownNode', 'some_input')
).toBe(false)
})

View File

@@ -20,6 +20,7 @@ import type {
ModelFolder,
TagsOperationResult
} from '@/platform/assets/schemas/assetSchema'
import { forceModelPickerAssetMode } from '@/platform/assets/forceAssetMode'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
@@ -396,23 +397,27 @@ function createAssetService() {
* Checks if the asset API is enabled (cloud environment + user setting).
*/
function isAssetAPIEnabled(): boolean {
if (forceModelPickerAssetMode) return true
if (!isCloud) return false
return !!useSettingStore().get('Comfy.Assets.UseAssetAPI')
}
/**
* Checks if the asset browser should be used for a given node input.
* Combines the cloud environment check, user setting, and eligibility check.
*
* @param nodeType - The ComfyUI node comfyClass
* @param widgetName - The name of the widget to check
* @returns true if this input should use the asset browser
* Activates in two cases:
* - cloud: when the user has opted into the Assets API and the input is
* a recognised model widget on a registered loader node.
* - desktop / localhost: any registered model loader widget, since the
* local Model Library source already enumerates /models/<folder>.
*/
function shouldUseAssetBrowser(
nodeType: string | undefined,
widgetName: string
): boolean {
return isAssetAPIEnabled() && isAssetBrowserEligible(nodeType, widgetName)
if (!isAssetBrowserEligible(nodeType, widgetName)) return false
if (isCloud) return isAssetAPIEnabled()
return true
}
/**

View File

@@ -35,7 +35,14 @@ export interface OwnershipFilterOption {
* - 'name-asc': Sort by display name A-Z
* - 'name-desc': Sort by display name Z-A
*/
export type AssetSortOption = 'default' | 'recent' | 'name-asc' | 'name-desc'
export type AssetSortOption =
| 'default'
| 'recent'
| 'oldest'
| 'name-asc'
| 'name-desc'
| 'author-asc'
| 'author-desc'
/**
* Filter state for asset browser and filter bar

View File

@@ -145,12 +145,30 @@ describe('assetMetadataUtils', () => {
name: 'filters non-string values from array',
trained_words: ['valid', 123, 'also valid', null],
expected: ['valid', 'also valid']
},
{
name: 'strips trailing-comma artifacts from each phrase',
trained_words: ['freckles,', 'detailed eyes,', 'perfect skin texture'],
expected: ['freckles', 'detailed eyes', 'perfect skin texture']
},
{
name: 'splits a comma-joined string into separate phrases',
trained_words: 'detailed eyes, perfect eyes, freckles',
expected: ['detailed eyes', 'perfect eyes', 'freckles']
}
])('$name', ({ trained_words, expected }) => {
const asset = { ...mockAsset, user_metadata: { trained_words } }
expect(getAssetTriggerPhrases(asset)).toEqual(expected)
})
it('falls back to the local trigger_phrase when no trained_words', () => {
const asset = {
...mockAsset,
metadata: { trigger_phrase: 'magic word' }
}
expect(getAssetTriggerPhrases(asset)).toEqual(['magic word'])
})
it('should return empty array when no metadata', () => {
expect(getAssetTriggerPhrases(mockAsset)).toEqual([])
})

View File

@@ -1,3 +1,8 @@
import {
inferBaseModelFromText,
refineBaseModelLabels
} from '@/components/sidebar/tabs/cloudModelLibrary/baseModelInference'
import { getBaseModelOverrides } from '@/components/sidebar/tabs/cloudModelLibrary/baseModelOverrides'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isCivitaiUrl } from '@/utils/formatUtil'
@@ -46,13 +51,38 @@ export function getAssetBaseModel(asset: AssetItem): string | null {
* @returns Array of base model strings
*/
export function getAssetBaseModels(asset: AssetItem): string[] {
const filenameSources = [
asset.name,
typeof asset.metadata?.filename === 'string'
? asset.metadata.filename
: undefined,
typeof asset.metadata?.filepath === 'string'
? asset.metadata.filepath
: undefined
].filter((s): s is string => Boolean(s))
const baseModel =
asset.user_metadata?.base_model ?? asset.metadata?.base_model
let labels: string[] = []
if (Array.isArray(baseModel)) {
return baseModel.filter((m): m is string => typeof m === 'string')
labels = baseModel.filter((m): m is string => typeof m === 'string')
} else if (typeof baseModel === 'string' && baseModel) {
labels = [baseModel]
} else {
const repoId = asset.metadata?.repo_id
if (typeof repoId === 'string' && repoId) {
labels = [...getBaseModelOverrides(repoId)]
}
}
if (typeof baseModel === 'string' && baseModel) {
return [baseModel]
// base_model can name the family root (e.g. `Lightricks/LTX-Video`) while the
// filename names a specific variant (`LTX_2.3_…`); let inference refine it.
if (labels.length > 0) return refineBaseModelLabels(labels, filenameSources)
// Civitai LoRAs etc. carry no repo_id or base_model — infer from filename.
for (const source of filenameSources) {
const inferred = inferBaseModelFromText(source)
if (inferred) return [inferred]
}
return []
}
@@ -93,19 +123,38 @@ export function getAssetSourceUrl(asset: AssetItem): string | null {
}
/**
* Extracts trigger phrases from asset metadata
* Checks user_metadata first, then metadata, then returns empty array
* Extracts trigger phrases from asset metadata.
*
* Cloud assets expose Civitai-style `trained_words` (an array). Local assets
* read from safetensors expose a single `trigger_phrase` string (from the
* `modelspec.trigger_phrase` header), so fall back to that when no
* `trained_words` are present.
*
* Values are comma-delimited in the source data, often with trailing-comma
* artifacts (e.g. `"freckles,"`). Splitting on commas and trimming yields
* clean phrases for both display and copy-to-clipboard.
*
* Checks user_metadata first, then metadata.
* @param asset - The asset to extract trigger phrases from
* @returns Array of trigger phrases
*/
export function getAssetTriggerPhrases(asset: AssetItem): string[] {
const phrases =
asset.user_metadata?.trained_words ?? asset.metadata?.trained_words
if (Array.isArray(phrases)) {
return phrases.filter((p): p is string => typeof p === 'string')
const raw = Array.isArray(phrases)
? phrases.filter((p): p is string => typeof p === 'string')
: typeof phrases === 'string' && phrases
? [phrases]
: []
if (raw.length === 0) {
const single =
asset.user_metadata?.trigger_phrase ?? asset.metadata?.trigger_phrase
if (typeof single === 'string') raw.push(single)
}
if (typeof phrases === 'string') return [phrases]
return []
return raw
.flatMap((entry) => entry.split(','))
.map((phrase) => phrase.trim())
.filter((phrase) => phrase.length > 0)
}
/**

View File

@@ -12,6 +12,7 @@ import type { AssetSortOption } from '../types/filterTypes'
export interface SortableItem {
name: string
label?: string
author?: string
created_at?: string | null
}
@@ -19,6 +20,10 @@ function getDisplayName(item: SortableItem): string {
return item.label ?? item.name
}
function getAuthorKey(item: SortableItem): string {
return item.author?.trim() ?? ''
}
/**
* Sort items by the specified sort option
* @param items - Array of sortable items
@@ -49,6 +54,34 @@ export function sortAssets<T extends SortableItem>(
new Date(b.created_at ?? 0).getTime() -
new Date(a.created_at ?? 0).getTime()
)
case 'oldest':
return sorted.sort(
(a, b) =>
new Date(a.created_at ?? 0).getTime() -
new Date(b.created_at ?? 0).getTime()
)
case 'author-asc':
case 'author-desc': {
const direction = sortBy === 'author-desc' ? -1 : 1
const hasAuthor = (i: SortableItem) => !!i.author?.trim()
return sorted.sort((a, b) => {
const ah = hasAuthor(a)
const bh = hasAuthor(b)
// Always sink unknown-author rows to the bottom, irrespective of
// direction — keeps the "Other" bucket visually anchored at the end.
if (ah !== bh) return ah ? -1 : 1
const authorCmp =
direction *
getAuthorKey(a).localeCompare(getAuthorKey(b), undefined, {
sensitivity: 'base'
})
if (authorCmp !== 0) return authorCmp
return getDisplayName(a).localeCompare(getDisplayName(b), undefined, {
numeric: true,
sensitivity: 'base'
})
})
}
case 'name-asc':
default:
return sorted.sort((a, b) =>

View File

@@ -3,7 +3,7 @@
:data-node-id="nodeData.id"
:class="
cn(
'lg-node flex w-[350px] touch-none flex-col rounded-2xl border border-solid border-node-stroke bg-component-node-background pb-1 outline-2 outline-transparent contain-layout contain-style',
'lg-node flex w-[350px] touch-none flex-col rounded-2xl border border-solid border-node-stroke bg-node-component-header-surface outline-2 outline-transparent contain-layout contain-style',
position
)
"
@@ -14,7 +14,7 @@
<NodeHeader :node-data="nodeData" />
</div>
<div
class="pointer-events-none flex flex-1 flex-col gap-1 pb-2"
class="pointer-events-none flex flex-1 flex-col gap-1 rounded-b-2xl bg-component-node-background pb-2"
:data-testid="`node-body-${nodeData.id}`"
>
<NodeSlots :node-data="nodeData" />

View File

@@ -17,6 +17,7 @@ const flushPromises = () =>
new Promise<void>((resolve) => setTimeout(resolve, 0))
vi.mock('@/platform/assets/services/assetService', () => ({
MODELS_TAG: 'models',
assetService: {
shouldUseAssetBrowser: vi.fn(() => true),
isAssetAPIEnabled: vi.fn(() => true)

View File

@@ -20,6 +20,7 @@ const mockShouldUseAssetBrowser = vi.hoisted(() => vi.fn(() => false))
const mockIsAssetAPIEnabled = vi.hoisted(() => vi.fn(() => false))
vi.mock('@/platform/assets/services/assetService', () => ({
MODELS_TAG: 'models',
assetService: {
shouldUseAssetBrowser: mockShouldUseAssetBrowser,
isAssetAPIEnabled: mockIsAssetAPIEnabled

View File

@@ -24,6 +24,8 @@
import { computed } from 'vue'
import { assetService } from '@/platform/assets/services/assetService'
import { forceModelPickerAssetMode } from '@/platform/assets/forceAssetMode'
import { isCloud } from '@/platform/distribution/types'
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue'
@@ -115,7 +117,11 @@ const isAssetMode = computed(
(assetService.isAssetAPIEnabled() && props.widget.type === 'asset')
)
const assetKind = computed(() => specDescriptor.value.kind)
const assetKind = computed(() =>
isAssetMode.value && specDescriptor.value.kind === 'unknown'
? 'model'
: specDescriptor.value.kind
)
const isDropdownUIWidget = computed(
() => isAssetMode.value || assetKind.value !== 'unknown'
)
@@ -125,6 +131,8 @@ const uploadFolder = computed<ResultItemType>(() => {
})
const uploadSubfolder = computed(() => specDescriptor.value.subfolder)
const defaultLayoutMode = computed<LayoutMode>(() => {
return isAssetMode.value ? 'list' : 'grid'
if (!isAssetMode.value) return 'grid'
// Local builds use the compact name-only row; cloud uses the standard list.
return isCloud || forceModelPickerAssetMode ? 'list' : 'list-small'
})
</script>

View File

@@ -1,7 +1,9 @@
<script setup lang="ts">
import { computed, provide, ref, toRef } from 'vue'
import { computed, provide, ref, toRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRecentlyUsedModels } from '@/composables/sidebarTabs/useRecentlyUsedModels'
import { forceModelPickerAssetMode } from '@/platform/assets/forceAssetMode'
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
import { useFlatOutputAssets } from '@/platform/assets/composables/media/useFlatOutputAssets'
@@ -13,6 +15,10 @@ import WidgetLayoutField from '@/renderer/extensions/vueNodes/widgets/components
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
import { useWidgetSelectActions } from '@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectActions'
import { useWidgetSelectItems } from '@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems'
import {
getDefaultSortOptions,
getModelSortOptions
} from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/shared'
import type { ResultItemType } from '@/schemas/apiSchema'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { AssetKind } from '@/types/widgetTypes'
@@ -52,6 +58,10 @@ const outputMediaAssets = isCloud
? useFlatOutputAssets()
: useAssetsApi('output')
// Imported media, loaded so the "All" tab can order items by recency (imported
// assets carry their creation timestamp; the widget's plain value list doesn't).
const inputMediaAssets = useAssetsApi('input')
const combinedProps = computed(() =>
filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS)
)
@@ -145,10 +155,41 @@ const acceptTypes = computed(() => {
const layoutMode = ref<LayoutMode>(props.defaultLayoutMode ?? 'grid')
const isModel = computed(() => props.assetKind === 'model')
const modelAssetMode = isCloud || forceModelPickerAssetMode
// Models sort/group by base model; other pickers use the recency/name options.
// Local builds lack reliable base-model metadata, so they drop the base-model
// sort and list A-Z like the sidebar.
const sortOptions = computed(() => {
if (!isModel.value) return getDefaultSortOptions()
const options = getModelSortOptions()
if (modelAssetMode) return options
return options.filter(
(option) =>
option.id !== 'base-model-asc' && option.id !== 'base-model-desc'
)
})
// Cloud models default to base-model grouping; local defaults to A-Z.
const sortSelected = ref(
isModel.value ? (modelAssetMode ? 'base-model-asc' : 'name-asc') : 'recent'
)
// Surface recently-picked models at the top of the grouped model picker.
const { topNames, markUsed } = useRecentlyUsedModels()
const pinTopNames = computed(() => (isModel.value ? topNames() : undefined))
watch(modelValue, (value) => {
if (isModel.value && value) markUsed(value)
})
function handleIsOpenUpdate(isOpen: boolean) {
if (isOpen && !outputMediaAssets.loading.value) {
if (!isOpen) return
if (!outputMediaAssets.loading.value) {
void outputMediaAssets.refresh()
}
if (!props.isAssetMode && !inputMediaAssets.loading.value) {
void inputMediaAssets.refresh()
}
}
</script>
@@ -157,6 +198,7 @@ function handleIsOpenUpdate(isOpen: boolean) {
<FormDropdown
v-model:filter-selected="filterSelected"
v-model:layout-mode="layoutMode"
v-model:sort-selected="sortSelected"
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:selected="selectedSet"
@@ -167,10 +209,12 @@ function handleIsOpenUpdate(isOpen: boolean) {
:uploadable
:accept="acceptTypes"
:filter-options
:show-ownership-filter
:sort-options="sortOptions"
:show-ownership-filter="modelAssetMode && showOwnershipFilter"
:ownership-options
:show-base-model-filter
:show-base-model-filter="modelAssetMode && showBaseModelFilter"
:base-model-options
:pin-top-names="pinTopNames"
v-bind="combinedProps"
class="w-full"
@update:selected="updateSelectedItems"

View File

@@ -1,7 +1,15 @@
<script setup lang="ts">
import { computedAsync, refDebounced } from '@vueuse/core'
import Popover from 'primevue/popover'
import { computed, ref, useTemplateRef } from 'vue'
import {
computed,
nextTick,
onBeforeUnmount,
ref,
useId,
useTemplateRef,
watch
} from 'vue'
import { useI18n } from 'vue-i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -37,6 +45,8 @@ interface Props {
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
baseModelOptions?: FilterOption[]
/** Names pinned to the top of the menu under a "Recently used" heading. */
pinTopNames?: string[]
isSelected?: (
selected: Set<string>,
item: FormDropdownItem,
@@ -63,6 +73,7 @@ const {
ownershipOptions,
showBaseModelFilter,
baseModelOptions,
pinTopNames,
isSelected = (selected, item, _index) => selected.has(item.id),
searcher = defaultSearcher,
items
@@ -93,10 +104,21 @@ const baseModelSelected = defineModel<Set<string>>('baseModelSelected', {
const isOpen = defineModel<boolean>('isOpen', { default: false })
const toastStore = useToastStore()
// Mount to body so the popover escapes the canvas's TransformPane and the
// node's own translate. Inside those transforms, position:fixed re-applies
// the ancestor transforms to our viewport coords, producing a drift that
// grows with the node's viewport X. Body-mounting sidesteps that entirely,
// then placePopover() scales the panel back to match the canvas zoom.
const popoverRef = ref<InstanceType<typeof Popover>>()
const triggerAnchorRef = useTemplateRef<HTMLElement>('triggerAnchorRef')
const triggerRef =
useTemplateRef<InstanceType<typeof FormDropdownInput>>('triggerRef')
// PrimeVue Popover with appendTo:'body' teleports the overlay outside this
// component; popoverRef.$el points at the empty anchor, not the visible
// overlay. We tag the overlay root with a unique id via :pt so we can look
// it up in the DOM and write inline position styles directly on it.
const popoverElementId = useId()
const displayedSearchQuery = ref('')
const isFiltering = ref(false)
@@ -176,11 +198,140 @@ function internalIsSelected(item: FormDropdownItem, index: number): boolean {
return isSelected(selected.value, item, index)
}
// Position tracking for the body-mounted popover. The popover lives outside
// the canvas transform, but we want it to follow the node when the user pans
// or zooms. Re-read the node's screen rect every animation frame while open.
let positionRaf: number | null = null
function placePopover(nodeEl: HTMLElement) {
const popoverEl = document.getElementById(popoverElementId)
if (!popoverEl) return
const rect = nodeEl.getBoundingClientRect()
// Cumulative canvas scale: node screen width / its local CSS width. Captures
// the canvas TransformPane's scale (and any other ancestor scales) without
// needing to parse transform matrices.
const scale = nodeEl.offsetWidth > 0 ? rect.width / nodeEl.offsetWidth : 1
const popoverScreenWidth =
(popoverEl.offsetWidth || nodeEl.offsetWidth) * scale
const popoverScreenHeight = popoverEl.offsetHeight * scale
// Anchor to the node's right edge, top-aligned. Flip to the left side when
// there isn't room on the right, then clamp into the viewport.
let left = rect.right + 4
if (left + popoverScreenWidth > window.innerWidth - 8) {
const flipped = rect.left - popoverScreenWidth - 4
left =
flipped >= 8
? flipped
: Math.max(8, window.innerWidth - popoverScreenWidth - 8)
}
const maxTop = Math.max(8, window.innerHeight - popoverScreenHeight - 8)
const top = Math.max(8, Math.min(rect.top, maxTop))
popoverEl.style.position = 'fixed'
popoverEl.style.top = `${top}px`
popoverEl.style.left = `${left}px`
popoverEl.style.right = 'auto'
popoverEl.style.bottom = 'auto'
// Scale the popover so it visually matches the node's current size on the
// canvas — full-size at zoom 1, smaller as the user zooms out, etc.
popoverEl.style.transformOrigin = 'top left'
popoverEl.style.transform = `scale(${scale})`
popoverEl.style.minWidth = `${nodeEl.offsetWidth}px`
}
function stopPositionTracking() {
if (positionRaf !== null) {
cancelAnimationFrame(positionRaf)
positionRaf = null
}
}
function startPositionTracking(nodeEl: HTMLElement) {
const tick = () => {
placePopover(nodeEl)
positionRaf = requestAnimationFrame(tick)
}
// Run once on nextTick to claim styles before PrimeVue's onEnter
// absolutePosition() lands, then again on rAF to win the race, then loop.
void nextTick(() => placePopover(nodeEl))
stopPositionTracking()
positionRaf = requestAnimationFrame(tick)
}
// Custom outside-click dismissal that distinguishes a click from a drag.
// PrimeVue's built-in `dismissable` closes on any pointerdown outside the
// popover — which fires on canvas pan, accidentally closing the picker.
// Track pointerdown→pointerup and only dismiss if the pointer didn't travel
// far (a real click), and the down/up both landed outside the popover.
const OUTSIDE_DRAG_THRESHOLD_PX = 5
let pointerDownInfo: { x: number; y: number; outside: boolean } | null = null
function isInsidePopover(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return false
return target.closest('[data-form-dropdown-portal]') !== null
}
function isInsideTrigger(target: EventTarget | null): boolean {
const triggerEl = triggerAnchorRef.value
if (!triggerEl || !(target instanceof Node)) return false
return triggerEl.contains(target)
}
function handleDocumentPointerDown(event: PointerEvent) {
if (!isOpen.value) return
const insidePopover = isInsidePopover(event.target)
const insideTrigger = isInsideTrigger(event.target)
pointerDownInfo = {
x: event.clientX,
y: event.clientY,
outside: !insidePopover && !insideTrigger
}
}
function handleDocumentPointerUp(event: PointerEvent) {
if (!isOpen.value) return
const info = pointerDownInfo
pointerDownInfo = null
if (!info || !info.outside) return
if (isInsidePopover(event.target) || isInsideTrigger(event.target)) return
const dx = event.clientX - info.x
const dy = event.clientY - info.y
if (dx * dx + dy * dy > OUTSIDE_DRAG_THRESHOLD_PX ** 2) return
closeDropdown()
}
watch(isOpen, (open) => {
if (open) {
document.addEventListener('pointerdown', handleDocumentPointerDown, true)
document.addEventListener('pointerup', handleDocumentPointerUp, true)
} else {
document.removeEventListener('pointerdown', handleDocumentPointerDown, true)
document.removeEventListener('pointerup', handleDocumentPointerUp, true)
pointerDownInfo = null
stopPositionTracking()
}
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', handleDocumentPointerDown, true)
document.removeEventListener('pointerup', handleDocumentPointerUp, true)
stopPositionTracking()
})
const toggleDropdown = (event: Event) => {
if (disabled) return
if (popoverRef.value && triggerAnchorRef.value) {
popoverRef.value.toggle?.(event, triggerAnchorRef.value)
isOpen.value = !isOpen.value
const nodeAnchor =
triggerAnchorRef.value.closest<HTMLElement>('[data-node-id]')
if (nodeAnchor && isOpen.value) {
startPositionTracking(nodeAnchor)
} else if (!isOpen.value) {
stopPositionTracking()
}
}
}
@@ -282,12 +433,19 @@ function handleSearchEnter() {
/>
<Popover
ref="popoverRef"
:dismissable="true"
:dismissable="false"
:close-on-escape="true"
append-to="body"
:auto-z-index="false"
unstyled
:pt="{
root: {
class: 'absolute z-50'
id: popoverElementId,
'data-form-dropdown-portal': 'true',
// Sit below the app chrome (side panel/splitter z-999, top bar
// z-1001) so panning the canvas tucks the picker under them, while
// still floating above the canvas.
class: 'absolute z-[998]'
},
content: {
class: ['bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg']
@@ -308,6 +466,9 @@ function handleSearchEnter() {
:ownership-options
:show-base-model-filter
:base-model-options
:uploadable
:accept
:pin-top-names="pinTopNames"
:disabled
:items="sortedItems"
:candidate-index
@@ -317,6 +478,7 @@ function handleSearchEnter() {
@close="closeDropdown"
@search-enter="handleSearchEnter"
@item-click="handleSelection"
@file-change="handleFileChange"
/>
</Popover>
</div>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
import { useTemplateRef } from 'vue'
// CSS-positioned dropdown for the action row. The picker panel is body-mounted
// and CSS-scaled to the canvas zoom; PrimeVue overlays position via screen
// coordinates, which the panel's transform then scales again — pushing the
// overlay off by an amount that grows with the trigger's x. Positioning the
// content with plain CSS inside the panel inherits that transform, so both
// placement and scale stay correct at any zoom.
const open = defineModel<boolean>('open', { default: false })
const rootRef = useTemplateRef<HTMLElement>('rootRef')
const contentRef = useTemplateRef<HTMLElement>('contentRef')
onClickOutside(
contentRef,
() => {
open.value = false
},
{ ignore: [rootRef] }
)
function toggle() {
open.value = !open.value
}
</script>
<template>
<div ref="rootRef" class="relative inline-flex shrink-0">
<slot name="trigger" :toggle :open />
<div
v-if="open"
ref="contentRef"
class="absolute top-full right-0 z-50 mt-2"
>
<slot />
</div>
</div>
</template>

View File

@@ -80,6 +80,26 @@ describe('FormDropdownMenu', () => {
expect(virtualItems[1]).toHaveProperty('key', '2')
})
it('keeps pinned items in the flat list when ungrouped', () => {
const items = [createItem('1', 'Item 1'), createItem('2', 'Item 2')]
render(FormDropdownMenu, {
props: {
...defaultProps,
items,
pinTopNames: ['Item 1']
},
global: globalConfig
})
const virtualGrid = screen.getByTestId('virtual-grid')
const virtualItems = JSON.parse(virtualGrid.getAttribute('data-items')!)
expect(virtualItems.map((i: { name: string }) => i.name)).toEqual([
'Item 1',
'Item 2'
])
})
it('uses single column layout for list modes', () => {
render(FormDropdownMenu, {
props: {

View File

@@ -2,9 +2,8 @@
import type { CSSProperties } from 'vue'
import { computed } from 'vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import { isCanvasGestureWheel } from '@/base/wheelGestures'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import type {
FilterOption,
OwnershipFilterOption,
@@ -25,8 +24,12 @@ interface Props {
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
baseModelOptions?: FilterOption[]
uploadable?: boolean
accept?: string
candidateIndex?: number
candidateLabel?: string
/** Names pinned to the top under a "Recently used" heading. */
pinTopNames?: string[]
}
const {
@@ -38,12 +41,16 @@ const {
ownershipOptions,
showBaseModelFilter,
baseModelOptions,
uploadable,
accept,
candidateIndex = -1,
candidateLabel
candidateLabel,
pinTopNames
} = defineProps<Props>()
const emit = defineEmits<{
(e: 'item-click', item: FormDropdownItem, index: number): void
(e: 'search-enter'): void
(e: 'file-change', event: Event): void
}>()
const filterSelected = defineModel<string>('filterSelected')
@@ -65,19 +72,19 @@ const LAYOUT_CONFIGS: Record<LayoutMode, LayoutConfig> = {
maxColumns: 4,
itemHeight: 120,
itemWidth: 89,
gap: 'var(--spacing-4) var(--spacing-2)'
gap: '2px'
},
list: {
maxColumns: 1,
itemHeight: 64,
itemWidth: 380,
gap: 'var(--spacing-2)'
gap: '2px'
},
'list-small': {
maxColumns: 1,
itemHeight: 40,
itemWidth: 380,
gap: 'var(--spacing-1)'
gap: '2px'
}
}
@@ -92,7 +99,31 @@ const gridStyle = computed<CSSProperties>(() => ({
width: '100%'
}))
// "Recently used" pinned items at the top. Order follows pinTopNames; items
// missing from the current pool are silently dropped. The non-pinned tail
// retains the upstream sort order with pinned items removed to avoid dupes.
const pinnedItems = computed<FormDropdownItem[]>(() => {
if (!pinTopNames?.length) return []
const byName = new Map<string, FormDropdownItem>()
for (const it of items) byName.set(it.name, it)
const out: FormDropdownItem[] = []
for (const name of pinTopNames) {
const hit = byName.get(name)
if (hit) out.push(hit)
}
return out
})
const remainingItems = computed<FormDropdownItem[]>(() => {
if (pinnedItems.value.length === 0) return items.slice()
const pinned = new Set(pinnedItems.value.map((i) => i.name))
return items.filter((i) => !pinned.has(i.name))
})
type VirtualDropdownItem = FormDropdownItem & { key: string }
// The flat (ungrouped) list has no "Recently used" section to host pinned
// items, so it renders the full set. Only the grouped path splits pinned out
// (via pinnedItems + remainingItems) to avoid showing them twice.
const virtualItems = computed<VirtualDropdownItem[]>(() =>
items.map((item) => ({
...item,
@@ -100,6 +131,74 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
}))
)
const UNKNOWN_BASE_MODEL_LABEL = '—'
// Order within a bucket starts at the first alphanumeric character, so leading
// punctuation ("-", "[", "/") doesn't drag a name out of place. Numbers sort
// before letters (localeCompare numeric ordering).
const LEADING_NON_ALPHANUMERIC = /^[^\p{L}\p{N}]+/u
function bucketSortKey(item: FormDropdownItem): string {
return (item.label ?? item.name).replace(LEADING_NON_ALPHANUMERIC, '')
}
function compareBucketItems(a: FormDropdownItem, b: FormDropdownItem): number {
return bucketSortKey(a).localeCompare(bucketSortKey(b), undefined, {
numeric: true,
sensitivity: 'base'
})
}
// Base-model sort buckets items under per-base-model headings so the dropdown
// matches the Model Library sidebar. Items compatible with multiple base
// models appear under each; items with none fall into a trailing "—" bucket.
// Bucket headings order by the asc/desc id; items within a bucket order AZ
// from their first alphanumeric character.
const groupedByBaseModel = computed<
{ baseModel: string; items: FormDropdownItem[] }[] | null
>(() => {
if (
sortSelected.value !== 'base-model-asc' &&
sortSelected.value !== 'base-model-desc'
)
return null
const buckets = new Map<string, FormDropdownItem[]>()
for (const item of remainingItems.value) {
const bases = item.base_models ?? []
if (bases.length === 0) {
const list = buckets.get(UNKNOWN_BASE_MODEL_LABEL) ?? []
list.push(item)
buckets.set(UNKNOWN_BASE_MODEL_LABEL, list)
continue
}
for (const base of bases) {
const list = buckets.get(base) ?? []
list.push(item)
buckets.set(base, list)
}
}
const direction = sortSelected.value === 'base-model-desc' ? -1 : 1
const labels = Array.from(buckets.keys()).sort((a, b) => {
if (a === UNKNOWN_BASE_MODEL_LABEL && b !== UNKNOWN_BASE_MODEL_LABEL)
return 1
if (b === UNKNOWN_BASE_MODEL_LABEL && a !== UNKNOWN_BASE_MODEL_LABEL)
return -1
return direction * a.localeCompare(b, undefined, { sensitivity: 'base' })
})
return labels.map((baseModel) => ({
baseModel,
items: (buckets.get(baseModel) ?? []).slice().sort(compareBucketItems)
}))
})
function flatIndex(sectionIdx: number, itemIdx: number): number {
const groups = groupedByBaseModel.value
if (!groups) return itemIdx
let n = 0
for (let i = 0; i < sectionIdx; i++) n += groups[i].items.length
return n + itemIdx
}
/**
* The dropdown content is teleported to `document.body` by PrimeVue Popover,
* detaching it from the LGraphNode subtree where the canvas wheel guard lives.
@@ -114,7 +213,7 @@ const onWheel = (event: WheelEvent) => {
<template>
<div
class="flex h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline -outline-offset-1 outline-node-component-border"
class="flex max-h-[640px] w-103 flex-col rounded-lg bg-base-background pt-4 outline -outline-offset-1 outline-node-component-border"
data-capture-wheel="true"
data-testid="form-dropdown-menu"
@wheel="onWheel"
@@ -123,9 +222,11 @@ const onWheel = (event: WheelEvent) => {
v-if="filterOptions.length > 0"
v-model:filter-selected="filterSelected"
:filter-options
:uploadable
:accept
@file-change="emit('file-change', $event)"
/>
<FormDropdownMenuActions
v-model:layout-mode="layoutMode"
v-model:sort-selected="sortSelected"
v-model:search-query="searchQuery"
v-model:ownership-selected="ownershipSelected"
@@ -148,6 +249,80 @@ const onWheel = (event: WheelEvent) => {
class="icon-[lucide--circle-off] size-30 text-muted-foreground/20"
/>
</div>
<div
v-else-if="groupedByBaseModel"
class="mt-2 flex min-h-0 flex-auto flex-col overflow-y-auto px-4 pb-4"
>
<section v-if="pinnedItems.length > 0" class="flex flex-col">
<h3
class="bg-base-background pt-1 pb-0.5 text-2xs tracking-wide text-muted-foreground uppercase"
>
{{ $t('assetBrowser.recentlyUsed') }}
</h3>
<div
:style="{
display: 'grid',
gap: layoutConfig.gap,
gridTemplateColumns:
layoutMode === 'grid'
? `repeat(${layoutConfig.maxColumns}, minmax(0, 1fr))`
: 'minmax(0, 1fr)'
}"
>
<FormDropdownMenuItem
v-for="(item, pinIdx) in pinnedItems"
:key="`pinned-${item.id}`"
:index="pinIdx"
:selected="isSelected(item, pinIdx)"
:preview-url="item.preview_url ?? ''"
:name="item.name"
:label="item.label"
:author="item.author"
:base-models="item.base_models"
:placeholder-category="item.placeholder_category"
:layout="layoutMode"
@click="emit('item-click', item, pinIdx)"
/>
</div>
</section>
<section
v-for="(group, sectionIdx) in groupedByBaseModel"
:key="group.baseModel"
class="flex flex-col"
>
<h3
class="sticky -top-px z-10 bg-base-background pt-1 pb-0.5 text-2xs tracking-wide text-muted-foreground uppercase"
>
{{ group.baseModel }}
</h3>
<div
:style="{
display: 'grid',
gap: layoutConfig.gap,
gridTemplateColumns:
layoutMode === 'grid'
? `repeat(${layoutConfig.maxColumns}, minmax(0, 1fr))`
: 'minmax(0, 1fr)'
}"
>
<FormDropdownMenuItem
v-for="(item, itemIdx) in group.items"
:key="item.id"
:index="flatIndex(sectionIdx, itemIdx)"
:candidate="flatIndex(sectionIdx, itemIdx) === candidateIndex"
:selected="isSelected(item, flatIndex(sectionIdx, itemIdx))"
:preview-url="item.preview_url ?? ''"
:name="item.name"
:label="item.label"
:author="item.author"
:base-models="item.base_models"
:placeholder-category="item.placeholder_category"
:layout="layoutMode"
@click="emit('item-click', item, flatIndex(sectionIdx, itemIdx))"
/>
</div>
</section>
</div>
<VirtualGrid
v-else
:key="layoutMode"
@@ -157,7 +332,7 @@ const onWheel = (event: WheelEvent) => {
:default-item-height="layoutConfig.itemHeight"
:default-item-width="layoutConfig.itemWidth"
:buffer-rows="2"
class="mt-2 min-h-0 flex-1"
class="mt-1 min-h-0 flex-auto"
>
<template #item="{ item, index }">
<FormDropdownMenuItem
@@ -167,6 +342,9 @@ const onWheel = (event: WheelEvent) => {
:preview-url="item.preview_url ?? ''"
:name="item.name"
:label="item.label"
:author="item.author"
:base-models="item.base_models"
:placeholder-category="item.placeholder_category"
:layout="layoutMode"
@click="emit('item-click', item, index)"
/>

View File

@@ -1,18 +1,14 @@
import { render, screen, within } from '@testing-library/vue'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type {
FilterOption,
OwnershipFilterOption,
OwnershipOption
} from '@/platform/assets/types/filterTypes'
import type { OwnershipFilterOption } from '@/platform/assets/types/filterTypes'
import FormDropdownMenuActions from './FormDropdownMenuActions.vue'
import type { LayoutMode, SortOption } from './types'
import type { SortOption } from './types'
const i18n = createI18n({
legacy: false,
@@ -20,32 +16,14 @@ const i18n = createI18n({
messages: { en: enMessages }
})
const popoverHide = vi.fn()
const ButtonStub = defineComponent({
inheritAttrs: false,
template: '<button v-bind="$attrs" type="button"><slot /></button>'
// The async search input pulls in network-y deps; stub it down to an input
// that re-emits the Enter key the way the real component does.
const AsyncSearchInputStub = defineComponent({
emits: ['enter', 'update:modelValue'],
template:
'<input data-testid="search" @keydown.enter="$emit(\'enter\', $event)" />'
})
const PopoverStub = defineComponent({
inheritAttrs: false,
data() {
return { open: false }
},
methods: {
toggle() {
this.open = !this.open
},
hide() {
popoverHide()
this.open = false
}
},
template: '<div data-testid="popover-body" v-if="open"><slot /></div>'
})
// Synthetic fixtures: the component is prop-driven, so we deliberately
// avoid mirroring production data (which can silently drift).
const sortOptions: SortOption[] = [
{ id: 'sort-a', name: 'Sort A', sorter: ({ items }) => [...items] },
{ id: 'sort-b', name: 'Sort B', sorter: ({ items }) => [...items] }
@@ -56,273 +34,61 @@ const ownershipOptions: OwnershipFilterOption[] = [
{ name: 'Mine', value: 'my-models' }
]
const baseModelOptions: FilterOption[] = [
{ name: 'Model A', value: 'model-a' },
{ name: 'Model B', value: 'model-b' }
]
type MenuProps = {
showOwnershipFilter?: boolean
showBaseModelFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
baseModelOptions?: FilterOption[]
layoutMode?: LayoutMode
searchQuery?: string
sortSelected?: string
ownershipSelected?: OwnershipOption
baseModelSelected?: Set<string>
candidateLabel?: string
onSearchEnter?: () => void
}
function renderMenu(props: MenuProps = {}) {
const layoutMode = ref<LayoutMode>(props.layoutMode ?? 'list')
const searchQuery = ref<string>(props.searchQuery ?? '')
const sortSelected = ref<string>(props.sortSelected ?? 'default')
const ownershipSelected = ref<OwnershipOption>(
props.ownershipSelected ?? 'all'
)
const baseModelSelected = ref<Set<string>>(
props.baseModelSelected ?? new Set()
)
const ownershipOptionsProp = props.ownershipOptions ?? ownershipOptions
const baseModelOptionsProp = props.baseModelOptions ?? baseModelOptions
const Harness = defineComponent({
components: { FormDropdownMenuActions },
setup: () => ({
layoutMode,
searchQuery,
sortSelected,
ownershipSelected,
baseModelSelected,
sortOptions,
ownershipOptions: ownershipOptionsProp,
baseModelOptions: baseModelOptionsProp,
showOwnershipFilter: props.showOwnershipFilter ?? false,
showBaseModelFilter: props.showBaseModelFilter ?? false,
candidateLabel: props.candidateLabel,
onSearchEnter: () => props.onSearchEnter?.()
}),
template: `
<FormDropdownMenuActions
v-model:layout-mode="layoutMode"
v-model:search-query="searchQuery"
v-model:sort-selected="sortSelected"
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:sort-options
:show-ownership-filter
:ownership-options
:show-base-model-filter
:base-model-options
:candidate-label
@search-enter="onSearchEnter"
/>
`
})
const user = userEvent.setup()
const utils = render(Harness, {
function renderActions(
props: Record<string, unknown> = {},
handlers: Record<string, unknown> = {}
) {
return render(FormDropdownMenuActions, {
global: {
plugins: [i18n],
stubs: { Button: ButtonStub, Popover: PopoverStub }
}
stubs: { AsyncSearchInput: AsyncSearchInputStub }
},
props: {
sortOptions,
sortSelected: 'sort-a',
...props
},
attrs: handlers
})
return {
...utils,
user,
layoutMode,
searchQuery,
sortSelected,
ownershipSelected,
baseModelSelected
}
}
type TestUser = ReturnType<typeof userEvent.setup>
async function openPopover(user: TestUser, triggerName: string) {
await user.click(screen.getByRole('button', { name: triggerName }))
return screen.getByTestId('popover-body')
}
describe('FormDropdownMenuActions', () => {
beforeEach(() => {
popoverHide.mockClear()
it('opens the settings menu with sort options and no view modes', async () => {
renderActions()
await userEvent
.setup()
.click(screen.getByRole('button', { name: 'Settings' }))
expect(screen.getByText('Sort A')).toBeInTheDocument()
expect(screen.getByText('Sort B')).toBeInTheDocument()
expect(screen.queryByText('List view')).toBeNull()
expect(screen.queryByText('Grid view')).toBeNull()
})
describe('Search', () => {
it('binds search input to v-model on initial render', () => {
renderMenu({ searchQuery: 'seed' })
expect(screen.getByRole('textbox')).toHaveValue('seed')
})
it('propagates typed input up to searchQuery v-model', async () => {
const { searchQuery, user } = renderMenu({ searchQuery: '' })
await user.type(screen.getByRole('textbox'), 'abc')
expect(searchQuery.value).toBe('abc')
})
it('clears searchQuery when the user clears the textbox', async () => {
const { searchQuery, user } = renderMenu({ searchQuery: 'seed' })
await user.clear(screen.getByRole('textbox'))
expect(searchQuery.value).toBe('')
})
it('emits search-enter when Enter is pressed in the textbox', async () => {
const onSearchEnter = vi.fn()
const { user } = renderMenu({ onSearchEnter })
await user.type(screen.getByRole('textbox'), '{Enter}')
expect(onSearchEnter).toHaveBeenCalledTimes(1)
})
it('announces the current top result to screen readers', () => {
renderMenu({ candidateLabel: 'alpha.ckpt' })
const status = screen.getByRole('status')
expect(status).toHaveAttribute('aria-live', 'polite')
expect(status).toHaveTextContent('Top result: alpha.ckpt')
})
it('updates sortSelected when a sort option is clicked', async () => {
const onUpdate = vi.fn()
renderActions({}, { 'onUpdate:sortSelected': onUpdate })
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Settings' }))
await user.click(screen.getByText('Sort B'))
expect(onUpdate).toHaveBeenCalledWith('sort-b')
})
describe('Sort popover', () => {
it('is closed by default', () => {
renderMenu()
expect(screen.queryByTestId('popover-body')).toBeNull()
})
it('opens the options list after the sort trigger is clicked', async () => {
const { user } = renderMenu()
const body = await openPopover(user, 'Sort by')
expect(
within(body).getByRole('button', { name: 'Sort A' })
).toBeInTheDocument()
expect(
within(body).getByRole('button', { name: 'Sort B' })
).toBeInTheDocument()
})
it('updates sortSelected when a sort option is clicked', async () => {
const { sortSelected, user } = renderMenu({ sortSelected: 'sort-a' })
const body = await openPopover(user, 'Sort by')
await user.click(within(body).getByRole('button', { name: 'Sort B' }))
expect(sortSelected.value).toBe('sort-b')
})
it('calls popover hide() after a sort option is selected', async () => {
const { user } = renderMenu({ sortSelected: 'sort-a' })
const body = await openPopover(user, 'Sort by')
await user.click(within(body).getByRole('button', { name: 'Sort B' }))
expect(popoverHide).toHaveBeenCalled()
})
it('updates ownershipSelected when an ownership option is clicked', async () => {
const onUpdate = vi.fn()
renderActions(
{ showOwnershipFilter: true, ownershipOptions },
{ 'onUpdate:ownershipSelected': onUpdate }
)
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Ownership' }))
await user.click(screen.getByText('Mine'))
expect(onUpdate).toHaveBeenCalledWith('my-models')
})
describe('Ownership popover', () => {
it('is hidden when showOwnershipFilter is false', () => {
renderMenu({ showOwnershipFilter: false })
expect(screen.queryByLabelText('Ownership')).toBeNull()
})
it('is hidden when showOwnershipFilter is true but options are empty', () => {
renderMenu({ showOwnershipFilter: true, ownershipOptions: [] })
expect(screen.queryByLabelText('Ownership')).toBeNull()
})
it('is shown when showOwnershipFilter is true and options exist', () => {
renderMenu({ showOwnershipFilter: true })
expect(screen.getByLabelText('Ownership')).toBeInTheDocument()
})
it('updates ownershipSelected when an option is clicked', async () => {
const { ownershipSelected, user } = renderMenu({
showOwnershipFilter: true,
ownershipSelected: 'all'
})
const body = await openPopover(user, 'Ownership')
await user.click(within(body).getByRole('button', { name: 'Mine' }))
expect(ownershipSelected.value).toBe('my-models')
})
it('calls popover hide() after an ownership option is selected', async () => {
const { user } = renderMenu({
showOwnershipFilter: true,
ownershipSelected: 'all'
})
const body = await openPopover(user, 'Ownership')
await user.click(within(body).getByRole('button', { name: 'Mine' }))
expect(popoverHide).toHaveBeenCalled()
})
})
describe('Base model popover', () => {
it('is hidden when showBaseModelFilter is false', () => {
renderMenu({ showBaseModelFilter: false })
expect(screen.queryByLabelText('Base model')).toBeNull()
})
it('is hidden when showBaseModelFilter is true but options are empty', () => {
renderMenu({ showBaseModelFilter: true, baseModelOptions: [] })
expect(screen.queryByLabelText('Base model')).toBeNull()
})
it('is shown when showBaseModelFilter is true and options exist', () => {
renderMenu({ showBaseModelFilter: true })
expect(screen.getByLabelText('Base model')).toBeInTheDocument()
})
it('adds a value to baseModelSelected when an option is clicked', async () => {
const { baseModelSelected, user } = renderMenu({
showBaseModelFilter: true
})
const body = await openPopover(user, 'Base model')
await user.click(within(body).getByRole('button', { name: 'Model A' }))
expect(baseModelSelected.value).toEqual(new Set(['model-a']))
})
it('removes a value from baseModelSelected when clicked again', async () => {
const { baseModelSelected, user } = renderMenu({
showBaseModelFilter: true,
baseModelSelected: new Set(['model-a', 'model-b'])
})
const body = await openPopover(user, 'Base model')
await user.click(within(body).getByRole('button', { name: 'Model A' }))
expect(baseModelSelected.value).toEqual(new Set(['model-b']))
})
it('adds additional values alongside existing selections', async () => {
const { baseModelSelected, user } = renderMenu({
showBaseModelFilter: true,
baseModelSelected: new Set(['model-a'])
})
const body = await openPopover(user, 'Base model')
await user.click(within(body).getByRole('button', { name: 'Model B' }))
expect(baseModelSelected.value).toEqual(new Set(['model-a', 'model-b']))
})
it('clears all selections when Clear Filters is clicked', async () => {
const { baseModelSelected, user } = renderMenu({
showBaseModelFilter: true,
baseModelSelected: new Set(['model-a', 'model-b'])
})
const body = await openPopover(user, 'Base model')
await user.click(
within(body).getByRole('button', { name: 'Clear Filters' })
)
expect(baseModelSelected.value.size).toBe(0)
})
})
describe('Layout switch', () => {
it('updates layoutMode to "list" when list view is clicked', async () => {
const { layoutMode, user } = renderMenu({ layoutMode: 'grid' })
await user.click(screen.getByRole('button', { name: 'List view' }))
expect(layoutMode.value).toBe('list')
})
it('updates layoutMode to "grid" when grid view is clicked', async () => {
const { layoutMode, user } = renderMenu({ layoutMode: 'list' })
await user.click(screen.getByRole('button', { name: 'Grid view' }))
expect(layoutMode.value).toBe('grid')
})
it('emits search-enter when the search input fires Enter', async () => {
const onSearchEnter = vi.fn()
renderActions({}, { onSearchEnter })
await userEvent.setup().type(screen.getByTestId('search'), '{enter}')
expect(onSearchEnter).toHaveBeenCalled()
})
})

View File

@@ -1,18 +1,19 @@
<script setup lang="ts">
import Popover from 'primevue/popover'
import { ref, useTemplateRef } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import type {
FilterOption,
OwnershipFilterOption,
OwnershipOption
} from '@/platform/assets/types/filterTypes'
import { cn } from '@comfyorg/tailwind-utils'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import type { LayoutMode, SortOption } from './types'
import FormDropdownActionPopover from './FormDropdownActionPopover.vue'
import type { SortOption } from './types'
const { t } = useI18n()
@@ -24,11 +25,11 @@ defineProps<{
baseModelOptions?: FilterOption[]
candidateLabel?: string
}>()
const emit = defineEmits<{
(e: 'search-enter'): void
}>()
const layoutMode = defineModel<LayoutMode>('layoutMode')
const searchQuery = defineModel<string>('searchQuery')
const sortSelected = defineModel<string>('sortSelected')
const ownershipSelected = defineModel<OwnershipOption>('ownershipSelected', {
@@ -38,59 +39,31 @@ const baseModelSelected = defineModel<Set<string>>('baseModelSelected', {
default: () => new Set()
})
const actionButtonStyle = cn(
'h-8 rounded-lg bg-zinc-500/20 outline-1 -outline-offset-1 outline-node-component-border transition-all duration-150'
const actionButtonStyle =
'h-8 rounded-lg bg-secondary-background transition-all duration-150'
const triggerButtonStyle = cn(
actionButtonStyle,
'relative w-8 hover:outline-component-node-widget-background-highlighted active:scale-95'
)
const layoutSwitchItemStyle =
'size-6 flex justify-center items-center rounded-sm cursor-pointer transition-all duration-150 hover:scale-108 hover:text-base-foreground active:scale-95'
const menuOptionStyle =
'flex h-8 w-full items-center justify-start gap-2 rounded-sm p-2 text-left text-sm font-normal'
const sortPopoverRef = useTemplateRef('sortPopoverRef')
const sortTriggerRef = useTemplateRef('sortTriggerRef')
const isSortPopoverOpen = ref(false)
const filterOptionStyle = cn('flex h-6 items-center justify-between text-left')
function toggleSortPopover(event: Event) {
if (!sortPopoverRef.value || !sortTriggerRef.value) return
isSortPopoverOpen.value = !isSortPopoverOpen.value
sortPopoverRef.value.toggle(event, sortTriggerRef.value.$el)
}
function closeSortPopover() {
isSortPopoverOpen.value = false
sortPopoverRef.value?.hide()
}
const isSettingsOpen = ref(false)
const isOwnershipOpen = ref(false)
const isBaseModelOpen = ref(false)
function handleSortSelected(item: SortOption) {
sortSelected.value = item.id
closeSortPopover()
}
const ownershipPopoverRef = useTemplateRef('ownershipPopoverRef')
const ownershipTriggerRef = useTemplateRef('ownershipTriggerRef')
const isOwnershipPopoverOpen = ref(false)
function toggleOwnershipPopover(event: Event) {
if (!ownershipPopoverRef.value || !ownershipTriggerRef.value) return
isOwnershipPopoverOpen.value = !isOwnershipPopoverOpen.value
ownershipPopoverRef.value.toggle(event, ownershipTriggerRef.value.$el)
}
function closeOwnershipPopover() {
isOwnershipPopoverOpen.value = false
ownershipPopoverRef.value?.hide()
isSettingsOpen.value = false
}
function handleOwnershipSelected(item: OwnershipFilterOption) {
ownershipSelected.value = item.value
closeOwnershipPopover()
}
const baseModelPopoverRef = useTemplateRef('baseModelPopoverRef')
const baseModelTriggerRef = useTemplateRef('baseModelTriggerRef')
const isBaseModelPopoverOpen = ref(false)
function toggleBaseModelPopover(event: Event) {
if (!baseModelPopoverRef.value || !baseModelTriggerRef.value) return
isBaseModelPopoverOpen.value = !isBaseModelPopoverOpen.value
baseModelPopoverRef.value.toggle(event, baseModelTriggerRef.value.$el)
isOwnershipOpen.value = false
}
function toggleBaseModelSelection(item: FilterOption) {
@@ -100,8 +73,7 @@ function toggleBaseModelSelection(item: FilterOption) {
: new Set([...current, item.value])
}
function handleSearchEnter(event: KeyboardEvent) {
event.preventDefault()
function handleSearchEnter() {
emit('search-enter')
}
</script>
@@ -111,13 +83,7 @@ function handleSearchEnter(event: KeyboardEvent) {
<AsyncSearchInput
v-model="searchQuery"
autofocus
:class="
cn(
actionButtonStyle,
'hover:outline-component-node-widget-background-highlighted/80',
'focus-within:ring-0 focus-within:outline-component-node-widget-background-highlighted/80'
)
"
:class="actionButtonStyle"
@enter="handleSearchEnter"
/>
<span
@@ -130,47 +96,29 @@ function handleSearchEnter(event: KeyboardEvent) {
{{ t('widgets.uploadSelect.topResult', { result: candidateLabel }) }}
</span>
<Button
ref="sortTriggerRef"
:aria-label="t('assetBrowser.sortBy')"
:title="t('assetBrowser.sortBy')"
variant="textonly"
size="icon"
:class="
cn(
actionButtonStyle,
'relative w-8 hover:outline-component-node-widget-background-highlighted active:scale-95'
)
"
@click="toggleSortPopover"
>
<div
v-if="sortSelected !== 'default'"
class="absolute top-[-2px] left-[-2px] size-2 rounded-full bg-component-node-widget-background-highlighted"
/>
<i class="icon-[lucide--arrow-up-down] size-4" />
</Button>
<Popover
ref="sortPopoverRef"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class: 'absolute z-50'
},
content: {
class: ['bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg']
}
}"
@hide="isSortPopoverOpen = false"
>
<FormDropdownActionPopover v-model:open="isSettingsOpen">
<template #trigger="{ toggle }">
<Button
:aria-label="t('g.settings')"
:title="t('g.settings')"
variant="textonly"
size="icon"
:class="triggerButtonStyle"
@click="toggle"
>
<div
v-if="sortSelected !== sortOptions[0]?.id"
class="absolute top-[-2px] left-[-2px] size-2 rounded-full bg-component-node-widget-background-highlighted"
/>
<i class="icon-[lucide--settings-2] size-4" />
</Button>
</template>
<div
:class="
cn(
'flex min-w-32 flex-col gap-2 p-2',
'bg-component-node-background',
'rounded-lg outline -outline-offset-1 outline-component-node-border'
'flex w-56 flex-col gap-1 px-2 py-3',
'bg-base-background',
'rounded-lg shadow-lg outline -outline-offset-1 outline-border-default'
)
"
>
@@ -179,60 +127,44 @@ function handleSearchEnter(event: KeyboardEvent) {
:key="item.name"
variant="textonly"
size="unset"
:class="cn('flex h-6 items-center justify-between text-left')"
:class="menuOptionStyle"
@click="handleSortSelected(item)"
>
<span>{{ item.name }}</span>
<span class="flex-1 truncate">{{ item.name }}</span>
<i
v-if="sortSelected === item.id"
class="icon-[lucide--check] size-4"
class="icon-[lucide--check] size-4 shrink-0"
/>
</Button>
</div>
</Popover>
</FormDropdownActionPopover>
<Button
<FormDropdownActionPopover
v-if="showOwnershipFilter && ownershipOptions?.length"
ref="ownershipTriggerRef"
:aria-label="t('assetBrowser.ownership')"
:title="t('assetBrowser.ownership')"
variant="textonly"
size="icon"
:class="
cn(
actionButtonStyle,
'relative w-8 hover:outline-component-node-widget-background-highlighted active:scale-95'
)
"
@click="toggleOwnershipPopover"
>
<div
v-if="ownershipSelected !== 'all'"
class="absolute top-[-2px] left-[-2px] size-2 rounded-full bg-component-node-widget-background-highlighted"
/>
<i class="icon-[lucide--user] size-4" />
</Button>
<Popover
ref="ownershipPopoverRef"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class: 'absolute z-50'
},
content: {
class: ['bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg']
}
}"
@hide="isOwnershipPopoverOpen = false"
v-model:open="isOwnershipOpen"
>
<template #trigger="{ toggle }">
<Button
:aria-label="t('assetBrowser.ownership')"
:title="t('assetBrowser.ownership')"
variant="textonly"
size="icon"
:class="triggerButtonStyle"
@click="toggle"
>
<div
v-if="ownershipSelected !== 'all'"
class="absolute top-[-2px] left-[-2px] size-2 rounded-full bg-component-node-widget-background-highlighted"
/>
<i class="icon-[lucide--user] size-4" />
</Button>
</template>
<div
:class="
cn(
'flex min-w-32 flex-col gap-2 p-2',
'bg-component-node-background',
'rounded-lg outline -outline-offset-1 outline-component-node-border'
'rounded-lg shadow-lg outline -outline-offset-1 outline-component-node-border'
)
"
>
@@ -241,7 +173,7 @@ function handleSearchEnter(event: KeyboardEvent) {
:key="item.value"
variant="textonly"
size="unset"
:class="cn('flex h-6 items-center justify-between text-left')"
:class="filterOptionStyle"
@click="handleOwnershipSelected(item)"
>
<span>{{ item.name }}</span>
@@ -251,117 +183,65 @@ function handleSearchEnter(event: KeyboardEvent) {
/>
</Button>
</div>
</Popover>
</FormDropdownActionPopover>
<Button
<FormDropdownActionPopover
v-if="showBaseModelFilter && baseModelOptions?.length"
ref="baseModelTriggerRef"
:aria-label="t('assetBrowser.baseModel')"
:title="t('assetBrowser.baseModel')"
variant="textonly"
size="icon"
:class="
cn(
actionButtonStyle,
'relative w-8 hover:outline-component-node-widget-background-highlighted active:scale-95'
)
"
@click="toggleBaseModelPopover"
>
<div
v-if="baseModelSelected.size > 0"
class="absolute top-[-2px] left-[-2px] size-2 rounded-full bg-component-node-widget-background-highlighted"
/>
<i class="icon-[comfy--ai-model] size-4" />
</Button>
<Popover
ref="baseModelPopoverRef"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class: 'absolute z-50'
},
content: {
class: ['bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg']
}
}"
@hide="isBaseModelPopoverOpen = false"
v-model:open="isBaseModelOpen"
>
<template #trigger="{ toggle }">
<Button
:aria-label="t('assetBrowser.baseModel')"
:title="t('assetBrowser.baseModel')"
variant="textonly"
size="icon"
:class="triggerButtonStyle"
@click="toggle"
>
<div
v-if="baseModelSelected.size > 0"
class="absolute top-[-2px] left-[-2px] size-2 rounded-full bg-component-node-widget-background-highlighted"
/>
<i class="icon-[comfy--ai-model] size-4" />
</Button>
</template>
<div
:class="
cn(
'flex min-w-32 flex-col gap-2 p-2',
'bg-component-node-background',
'rounded-lg outline -outline-offset-1 outline-component-node-border'
'rounded-lg shadow-lg outline -outline-offset-1 outline-component-node-border'
)
"
>
<Button
v-for="item of baseModelOptions"
:key="item.value"
variant="textonly"
size="unset"
:class="cn('flex h-6 items-center justify-between text-left')"
@click="toggleBaseModelSelection(item)"
<div
class="flex max-h-64 scrollbar-thin flex-col gap-2 overflow-y-auto"
>
<span>{{ item.name }}</span>
<i
v-if="baseModelSelected.has(item.value)"
class="icon-[lucide--check] size-4"
/>
</Button>
<Button
v-for="item of baseModelOptions"
:key="item.value"
variant="textonly"
size="unset"
:class="filterOptionStyle"
@click="toggleBaseModelSelection(item)"
>
<span>{{ item.name }}</span>
<i
v-if="baseModelSelected.has(item.value)"
class="icon-[lucide--check] size-4"
/>
</Button>
</div>
<span class="h-0 w-full border-b border-border-default" />
<Button
variant="textonly"
size="unset"
:class="cn('flex h-6 items-center justify-between text-left')"
:class="filterOptionStyle"
@click="baseModelSelected = new Set()"
>
{{ t('g.clearFilters') }}
</Button>
</div>
</Popover>
<div
:class="
cn(
actionButtonStyle,
'flex items-center justify-center gap-1 p-1 hover:outline-component-node-widget-background-highlighted'
)
"
>
<Button
:aria-label="t('assetBrowser.listView')"
:title="t('assetBrowser.listView')"
variant="textonly"
size="unset"
:class="
cn(
layoutSwitchItemStyle,
layoutMode === 'list' && 'bg-neutral-500/50 text-base-foreground'
)
"
@click="layoutMode = 'list'"
>
<i class="icon-[lucide--list] size-4" />
</Button>
<Button
:aria-label="t('assetBrowser.gridView')"
:title="t('assetBrowser.gridView')"
variant="textonly"
size="unset"
:class="
cn(
layoutSwitchItemStyle,
layoutMode === 'grid' && 'bg-neutral-500/50 text-base-foreground'
)
"
@click="layoutMode = 'grid'"
>
<i class="icon-[lucide--layout-grid] size-4" />
</Button>
</div>
</FormDropdownActionPopover>
</div>
</template>

View File

@@ -34,7 +34,12 @@ function getUploadMock() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { g: { import: 'Import' } } }
messages: {
en: {
g: { import: 'Import' },
widgets: { uploadSelect: { importMedia: 'Import media' } }
}
}
})
const ButtonStub = {
@@ -52,14 +57,16 @@ const singleOption: FilterOption[] = [{ value: 'all', name: 'All' }]
function renderMenu(
filterOptions: FilterOption[] = options,
modelValue: string | undefined = 'all'
modelValue: string | undefined = 'all',
extraProps: Record<string, unknown> = {},
onFileChange: (event: Event) => void = () => {}
) {
const value = ref<string | undefined>(modelValue)
const Harness = defineComponent({
components: { FormDropdownMenuFilter },
setup: () => ({ value, filterOptions }),
setup: () => ({ value, filterOptions, extraProps, onFileChange }),
template:
'<FormDropdownMenuFilter v-model:filter-selected="value" :filter-options="filterOptions" />'
'<FormDropdownMenuFilter v-model:filter-selected="value" :filter-options="filterOptions" v-bind="extraProps" @file-change="onFileChange" />'
})
const utils = render(Harness, {
global: {
@@ -92,9 +99,10 @@ describe('FormDropdownMenuFilter', () => {
expect(value.value).toBe('mine')
})
it('disables option buttons when there is only one option', () => {
it('renders the single option as a non-interactive title', () => {
renderMenu(singleOption)
expect(screen.getByRole('button', { name: 'All' })).toBeDisabled()
expect(screen.getByText('All')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'All' })).toBeNull()
})
it('does not disable buttons when there are multiple options', () => {
@@ -134,4 +142,44 @@ describe('FormDropdownMenuFilter', () => {
expect(upload.showUploadDialog).toHaveBeenCalledTimes(1)
})
})
describe('Media import button', () => {
it('shows an Import media button when uploadable with multiple options', () => {
renderMenu(options, 'all', { uploadable: true })
expect(
screen.getByRole('button', { name: 'Import media' })
).toBeInTheDocument()
})
it('is hidden when not uploadable', () => {
renderMenu(options, 'all', { uploadable: false })
expect(screen.queryByRole('button', { name: 'Import media' })).toBeNull()
})
it('defers to the model import button for a single filter option', () => {
getUploadMock().isUploadButtonEnabled.value = true
renderMenu(singleOption, 'all', { uploadable: true })
expect(screen.queryByRole('button', { name: 'Import media' })).toBeNull()
expect(
screen.getByRole('button', { name: /Import/i })
).toBeInTheDocument()
})
it('emits file-change when a file is selected', async () => {
const onFileChange = vi.fn()
renderMenu(
options,
'all',
{ uploadable: true, accept: 'image/*' },
onFileChange
)
await userEvent
.setup()
.upload(
screen.getByTestId('media-upload-input'),
new File(['x'], 'a.png', { type: 'image/png' })
)
expect(onFileChange).toHaveBeenCalled()
})
})
})

View File

@@ -1,52 +1,85 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, useTemplateRef } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
import type { FilterOption } from '@/platform/assets/types/filterTypes'
import { cn } from '@comfyorg/tailwind-utils'
const { filterOptions } = defineProps<{
const { filterOptions, uploadable = false } = defineProps<{
filterOptions: FilterOption[]
uploadable?: boolean
accept?: string
}>()
const filterSelected = defineModel<string>('filterSelected')
const emit = defineEmits<{
'file-change': [event: Event]
}>()
const { isUploadButtonEnabled, showUploadDialog } = useModelUpload()
const singleFilterOption = computed(() => filterOptions.length === 1)
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
function triggerImport() {
fileInputRef.value?.click()
}
</script>
<template>
<div class="text-secondary mb-4 flex justify-start gap-1 px-4">
<button
v-for="option in filterOptions"
:key="option.value"
type="button"
:disabled="singleFilterOption"
:class="
cn(
'inline-flex appearance-none items-center justify-center rounded-md border-0 px-4 py-2 text-base-foreground select-none',
!singleFilterOption &&
'cursor-pointer transition-all duration-150 hover:bg-interface-menu-component-surface-hovered hover:text-base-foreground active:scale-95',
!singleFilterOption && filterSelected === option.value
? 'bg-interface-menu-component-surface-selected! text-base-foreground'
: 'bg-transparent'
)
"
@click="filterSelected = option.value"
<div class="text-secondary mb-4 flex items-center justify-between gap-2 px-4">
<!-- Model picker: single non-interactive category title -->
<span
v-if="singleFilterOption"
class="text-base font-semibold text-base-foreground"
>
{{ option.name }}
</button>
{{ filterOptions[0]?.name }}
</span>
<!-- Media picker: tab buttons -->
<div v-else class="flex min-w-0 items-center gap-2">
<Button
v-for="option in filterOptions"
:key="option.value"
size="md"
:variant="
filterSelected === option.value ? 'secondary' : 'muted-textonly'
"
class="text-sm font-normal"
@click="filterSelected = option.value"
>
{{ option.name }}
</Button>
</div>
<Button
v-if="isUploadButtonEnabled && singleFilterOption"
class="ml-auto"
size="md"
variant="textonly"
variant="inverted"
@click="showUploadDialog"
>
<i class="icon-[lucide--folder-input]" />
<i class="icon-[lucide--folder-input] size-4" />
<span>{{ $t('g.import') }}</span>
</Button>
<Button
v-else-if="uploadable"
class="ml-auto"
size="md"
variant="inverted"
@click="triggerImport"
>
<i class="icon-[lucide--folder-search] size-4" />
<span>{{ $t('widgets.uploadSelect.importMedia') }}</span>
</Button>
<input
ref="fileInputRef"
type="file"
class="hidden"
data-testid="media-upload-input"
:accept
@change="emit('file-change', $event)"
/>
</div>
</template>

View File

@@ -119,12 +119,15 @@ describe('FormDropdownMenuItem', () => {
expect(screen.queryByLabelText('item_name')).toBeNull()
})
it('omits media area entirely for list-small layout', () => {
it('renders a compact leading thumbnail for list-small layout', () => {
renderItem(
{ previewUrl: '/p.png', layout: 'list-small' },
{ assetKind: 'image' }
)
expect(screen.queryByRole('img', { name: 'item_name' })).toBeNull()
// list-small shows a compact leading thumbnail instead of the full
// aspect-square media area.
const thumb = screen.getByRole('img', { name: 'item_name' })
expect(thumb).toHaveAttribute('src', '/p.png')
})
it('does not look up mesh preview when kind is image', async () => {

View File

@@ -5,6 +5,8 @@ import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import CategoryPlaceholder from '@/components/sidebar/tabs/cloudModelLibrary/CategoryPlaceholder.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import {
findServerPreviewUrl,
isAssetPreviewSupported
@@ -22,13 +24,14 @@ const emit = defineEmits<{
mediaLoad: [event: Event]
}>()
const actualDimensions = ref<string | null>(null)
const assetKind = inject(AssetKindKey)
const isVideo = computed(() => assetKind?.value === 'video')
const isMesh = computed(() => assetKind?.value === 'mesh')
const isModel = computed(() => assetKind?.value === 'model')
// Mesh previews aren't served inline; resolve them lazily once the row is
// scrolled into view.
const mediaContainerRef = ref<HTMLElement>()
const resolvedMeshPreview = ref<string | null>(null)
const meshPreviewAttempted = ref(false)
@@ -52,6 +55,54 @@ useIntersectionObserver(mediaContainerRef, ([entry]) => {
void resolveMeshPreview()
})
const displayedPreviewUrl = computed(() =>
isMesh.value ? resolvedMeshPreview.value : props.previewUrl
)
const baseModelLabel = computed(() => props.baseModels?.join(' · ') ?? '')
const metaLabel = computed(() =>
[baseModelLabel.value, props.author].filter(Boolean).join(' · ')
)
// Media values carry a trailing source annotation like " [output]". It isn't
// part of the file type, and grid cards are too narrow to show it in the name.
const SOURCE_LABEL_RE = /\s*\[[^\]]+\]\s*$/
const fileType = computed(() => {
const fileName = props.name.replace(SOURCE_LABEL_RE, '')
const dot = fileName.lastIndexOf('.')
return dot > 0 ? fileName.slice(dot + 1).toUpperCase() : ''
})
const displayName = computed(() => {
const base = props.label ?? props.name
return props.layout === 'grid' ? base.replace(SOURCE_LABEL_RE, '') : base
})
const mediaLoaded = ref(false)
const dimensions = ref('')
// Secondary line under the name. Models surface their base model and author;
// media surfaces the file type and pixel dimensions.
const detailItems = computed(() =>
metaLabel.value
? [metaLabel.value]
: [fileType.value, dimensions.value].filter(Boolean)
)
const hasDetails = computed(() => detailItems.value.length > 0)
// Cloud model rows let long names run to a second line; media rows stay
// single-line when they carry a file-type/dimensions subheading.
const wrapTitle = computed(() => isModel.value || !hasDetails.value)
watch(
() => props.previewUrl,
() => {
mediaLoaded.value = false
dimensions.value = ''
}
)
watch(
() => props.name,
() => {
@@ -60,30 +111,19 @@ watch(
}
)
const displayedPreviewUrl = computed(() =>
isMesh.value ? resolvedMeshPreview.value : props.previewUrl
)
function handleClick() {
emit('click', props.index)
}
function handleImageLoad(event: Event) {
emit('mediaLoad', event)
if (!event.target || !(event.target instanceof HTMLImageElement)) return
const img = event.target
if (img.naturalWidth && img.naturalHeight) {
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
function handleMediaLoad(event: Event) {
mediaLoaded.value = true
const target = event.target
if (target instanceof HTMLImageElement && target.naturalWidth > 0) {
dimensions.value = `${target.naturalWidth}×${target.naturalHeight}`
} else if (target instanceof HTMLVideoElement && target.videoWidth > 0) {
dimensions.value = `${target.videoWidth}×${target.videoHeight}`
}
}
function handleVideoLoad(event: Event) {
emit('mediaLoad', event)
if (!event.target || !(event.target instanceof HTMLVideoElement)) return
const video = event.target
if (video.videoWidth && video.videoHeight) {
actualDimensions.value = `${video.videoWidth} x ${video.videoHeight}`
}
}
</script>
@@ -91,17 +131,16 @@ function handleVideoLoad(event: Event) {
<div
:class="
cn(
'group/item flex cursor-pointer gap-1 bg-component-node-widget-background select-none',
'group/item relative flex cursor-pointer gap-1 select-none',
'transition-[transform,box-shadow,background-color] duration-150',
{
'flex-col text-center': layout === 'grid',
'max-h-16 flex-row rounded-lg text-left hover:scale-102 active:scale-98':
'flex-col pb-2 text-left': layout === 'grid',
'flex-row items-center rounded-lg p-1 text-left hover:bg-component-node-widget-background':
layout === 'list',
'flex-row rounded-lg text-left hover:bg-component-node-widget-background-hovered':
'h-10 flex-row items-center rounded-lg text-left hover:bg-component-node-widget-background':
layout === 'list-small',
// selection
'ring-2 ring-component-node-widget-background-highlighted':
layout === 'list' && selected
'bg-component-node-widget-background-selected':
(layout === 'list' || layout === 'list-small') && selected
},
candidate &&
!selected &&
@@ -111,54 +150,65 @@ function handleVideoLoad(event: Event) {
"
@click="handleClick"
>
<!-- Screen-reader selection cue for list rows (grid uses the check badge) -->
<span
v-if="selected && layout !== 'grid'"
:aria-label="t('g.selected')"
role="img"
class="sr-only"
/>
<!-- Image -->
<div
v-if="layout !== 'list-small'"
ref="mediaContainerRef"
:class="
cn(
'relative',
'aspect-square w-full overflow-hidden outline-1 -outline-offset-1 outline-interface-stroke',
'transition-[transform,box-shadow] duration-150',
'relative overflow-hidden transition-[transform,box-shadow] duration-150',
{
'max-w-16 min-w-16 rounded-l-lg': layout === 'list',
'rounded-sm group-hover/item:scale-108 group-active/item:scale-95':
'aspect-square w-full rounded-sm outline-1 -outline-offset-1 outline-interface-stroke group-hover/item:ring-2 group-hover/item:ring-component-node-widget-background-highlighted group-active/item:scale-95':
layout === 'grid',
// selection
'ring-2 ring-component-node-widget-background-highlighted':
layout === 'grid' && (selected || candidate)
layout === 'grid' && (selected || candidate),
'size-14 shrink-0 rounded-sm': layout === 'list',
'border-2 border-base-foreground': layout === 'list' && selected
}
)
"
>
<!-- Selected Icon -->
<!-- Selected check badge (grid) -->
<div
v-if="selected"
v-if="selected && layout === 'grid'"
:aria-label="t('g.selected')"
role="img"
class="absolute top-1 left-1 size-4 rounded-full border border-base-foreground bg-primary-background"
class="absolute top-1 left-1 flex size-4 items-center justify-center rounded-full border border-base-foreground bg-primary-background"
>
<i
class="bold icon-[lucide--check] size-3 translate-y-[-0.5px] text-base-foreground"
class="icon-[lucide--check] size-3 text-base-foreground"
aria-hidden="true"
/>
</div>
<Skeleton
v-if="displayedPreviewUrl && !mediaLoaded"
class="absolute inset-0"
/>
<video
v-if="previewUrl && isVideo"
:src="previewUrl"
v-if="displayedPreviewUrl && isVideo"
:src="displayedPreviewUrl"
:aria-label="label ?? name"
class="size-full object-cover"
class="size-full object-cover transition-opacity duration-150"
:class="mediaLoaded ? 'opacity-100' : 'opacity-0'"
preload="metadata"
muted
@loadeddata="handleVideoLoad"
@loadeddata="handleMediaLoad"
/>
<img
v-else-if="displayedPreviewUrl"
:src="displayedPreviewUrl"
:alt="name"
draggable="false"
class="size-full object-cover"
@load="handleImageLoad"
class="size-full object-cover transition-opacity duration-150"
:class="mediaLoaded ? 'opacity-100' : 'opacity-0'"
@load="handleMediaLoad"
/>
<div
v-else-if="isMesh"
@@ -167,40 +217,80 @@ function handleVideoLoad(event: Event) {
>
<i class="icon-[lucide--box] text-3xl text-muted-foreground" />
</div>
<CategoryPlaceholder
v-else-if="placeholderCategory"
:category="placeholderCategory"
/>
<div
v-else
data-testid="dropdown-item-media-placeholder"
class="size-full bg-linear-to-tr from-blue-400 via-teal-500 to-green-400"
/>
class="flex size-full items-center justify-center bg-muted-background text-muted-foreground"
>
<i class="icon-[comfy--ai-model] size-6" />
</div>
</div>
<!-- Name -->
<!-- Compact leading icon for list-small rows (e.g. the local model picker) -->
<div
v-if="layout === 'list-small'"
class="ml-1.5 flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-md bg-component-node-widget-background text-muted-foreground group-hover/item:bg-node-component-surface"
>
<img
v-if="displayedPreviewUrl"
:src="displayedPreviewUrl"
:alt="name"
draggable="false"
class="size-full object-cover"
/>
<i v-else class="icon-[comfy--ai-model] size-4" />
</div>
<!-- Name + details -->
<div
:class="
cn('flex gap-1', {
'flex-col': layout === 'grid',
'w-full min-w-0 flex-col justify-center px-4 py-1': layout === 'list',
'w-full flex-row items-center justify-between p-2':
'w-full min-w-0 flex-col': layout === 'grid',
'min-w-0 flex-1 flex-col justify-center pr-1 pl-2': layout === 'list',
'min-w-0 flex-1 flex-row items-center pr-3 pl-2':
layout === 'list-small'
})
"
>
<span
v-tooltip="layout === 'grid' ? (label ?? name) : undefined"
v-tooltip="
layout === 'grid' || layout === 'list' ? displayName : undefined
"
:class="
cn(
'line-clamp-2 block overflow-hidden text-xs wrap-break-word',
'transition-colors duration-150',
// selection
layout === 'list-small'
? 'line-clamp-2 min-w-0 text-xs font-normal break-all'
: 'w-full text-xs font-normal text-base-foreground',
layout === 'grid' && 'block truncate pr-1',
layout === 'list' &&
(wrapTitle ? 'line-clamp-2' : 'block truncate'),
!!selected && 'text-base-foreground'
)
"
>
{{ label ?? name }}
</span>
<!-- Meta Data -->
<span v-if="actualDimensions" class="text-secondary block text-xs">
{{ actualDimensions }}
{{ displayName }}
</span>
<div
v-if="(layout === 'grid' || layout === 'list') && detailItems.length"
:class="
cn(
'flex w-full items-center text-muted-foreground',
layout === 'grid'
? 'justify-between gap-2 pr-1 text-2xs'
: 'gap-1 text-xs'
)
"
>
<span
v-for="(detail, i) in detailItems"
:key="i"
:class="i === 0 ? 'truncate' : 'shrink-0'"
>
{{ detail }}
</span>
</div>
</div>
</div>
</template>

View File

@@ -81,18 +81,28 @@ describe('defaultSearcher', () => {
describe('getDefaultSortOptions', () => {
const sortOptions = getDefaultSortOptions()
describe('Default sorter', () => {
const defaultSorter = sortOptions.find((o) => o.id === 'default')!.sorter
describe('Recent sorter', () => {
const recentSorter = sortOptions.find((o) => o.id === 'recent')!.sorter
it('returns items in original order', () => {
it('sorts items by created_at descending', () => {
const items = [
{ ...createItem('old'), created_at: '2026-01-01T00:00:00Z' },
{ ...createItem('new'), created_at: '2026-06-01T00:00:00Z' },
{ ...createItem('mid'), created_at: '2026-03-01T00:00:00Z' }
]
const result = recentSorter({ items })
expect(result.map((i) => i.name)).toEqual(['new', 'mid', 'old'])
})
it('preserves original order for items without timestamps', () => {
const items = [createItem('z'), createItem('a'), createItem('m')]
const result = defaultSorter({ items })
const result = recentSorter({ items })
expect(result.map((i) => i.name)).toEqual(['z', 'a', 'm'])
})
it('does not mutate original array', () => {
const items = [createItem('z'), createItem('a')]
const result = defaultSorter({ items })
const result = recentSorter({ items })
expect(result).not.toBe(items)
})
})

View File

@@ -33,7 +33,31 @@ function createSortOption(
export function getDefaultSortOptions(): SortOption<AssetSortOption>[] {
return [
createSortOption('default', t('assetBrowser.sortUnsorted')),
createSortOption('recent', t('assetBrowser.sortRecent')),
createSortOption('name-asc', t('assetBrowser.sortAZ'))
]
}
// Model picker sort options, matching the Model Library sidebar. FormDropdownMenu
// buckets items under per-base-model headings and decides bucket ORDER from the
// asc/desc id, so both base-model options share one sorter that only clamps a
// stable within-bucket order by name.
const sortBucketByName: SortOption['sorter'] = ({ items }) =>
sortAssets(items, 'name-asc')
export function getModelSortOptions(): SortOption[] {
return [
{
id: 'base-model-asc',
name: t('assets.sort.baseModelAsc'),
sorter: sortBucketByName
},
{
id: 'base-model-desc',
name: t('assets.sort.baseModelDesc'),
sorter: sortBucketByName
},
createSortOption('name-asc', t('assets.sort.nameAsc')),
createSortOption('name-desc', t('assets.sort.nameDesc'))
]
}

View File

@@ -18,6 +18,12 @@ export interface FormDropdownItem {
is_immutable?: boolean
/** Base models this item is compatible with - used for base model filtering */
base_models?: string[]
/** Author / publisher, shown after the base model on model cards */
author?: string
/** Category key used to render a gradient placeholder when no preview_url exists */
placeholder_category?: string
/** ISO creation timestamp, used to order the media picker's "All" tab by recency */
created_at?: string
}
export interface SortOption<TId extends string = string> {
@@ -48,6 +54,12 @@ export interface FormDropdownMenuItemProps {
previewUrl: string
name: string
label?: string
/** Publisher/organisation, shown after the base model on model cards. */
author?: string
/** Base models this item is compatible with, shown on model cards. */
baseModels?: string[]
/** When set and no previewUrl is present, render the matching gradient. */
placeholderCategory?: string
layout?: LayoutMode
}

View File

@@ -1,22 +1,19 @@
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/platform/distribution/types', () => ({ isCloud: false }))
const mockUpdateModelsForNodeType = vi.fn()
const mockLocalAssets = ref<AssetItem[]>([])
const mockGetCategoryForNodeType = vi.fn()
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
getAssets: () => [],
isModelLoading: () => false,
getError: () => undefined,
hasAssetKey: () => false,
updateModelsForNodeType: mockUpdateModelsForNodeType
vi.mock('@/composables/sidebarTabs/useLocalModelLibrarySource', () => ({
useLocalModelLibrarySource: () => ({
assets: computed(() => mockLocalAssets.value),
isLoading: ref(false),
refresh: vi.fn()
})
}))
@@ -26,16 +23,47 @@ vi.mock('@/stores/modelToNodeStore', () => ({
})
}))
describe('useAssetWidgetData (desktop/isCloud=false)', () => {
it('returns empty/default values without calling stores', () => {
const nodeType = ref('CheckpointLoaderSimple')
const { category, assets, isLoading, error } = useAssetWidgetData(nodeType)
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({})
}))
function asset(id: string, directory: string): AssetItem {
return {
id,
name: id,
size: 1024,
tags: ['models', directory],
created_at: '2025-01-01T00:00:00Z',
metadata: { directory }
}
}
describe('useAssetWidgetData (desktop/localhost, isCloud=false)', () => {
beforeEach(() => {
mockLocalAssets.value = []
mockGetCategoryForNodeType.mockReset()
})
it('returns local-source assets scoped to the node category directory', () => {
mockLocalAssets.value = [
asset('a', 'checkpoints'),
asset('b', 'loras'),
asset('c', 'checkpoints')
]
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
const { category, assets } = useAssetWidgetData('CheckpointLoaderSimple')
expect(category.value).toBe('checkpoints')
expect(assets.value.map((a) => a.id)).toEqual(['a', 'c'])
})
it('returns empty when the node type has no category', () => {
mockLocalAssets.value = [asset('a', 'checkpoints')]
mockGetCategoryForNodeType.mockReturnValue(undefined)
const { assets } = useAssetWidgetData('UnknownNodeType')
expect(category.value).toBeUndefined()
expect(assets.value).toEqual([])
expect(isLoading.value).toBe(false)
expect(error.value).toBeNull()
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
expect(mockGetCategoryForNodeType).not.toHaveBeenCalled()
})
})

View File

@@ -1,6 +1,8 @@
import { computed, toValue, watch } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { useLocalModelLibrarySource } from '@/composables/sidebarTabs/useLocalModelLibrarySource'
import { forceModelPickerAssetMode } from '@/platform/assets/forceAssetMode'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isCloud } from '@/platform/distribution/types'
import { useAssetsStore } from '@/stores/assetsStore'
@@ -11,7 +13,8 @@ import { useModelToNodeStore } from '@/stores/modelToNodeStore'
* Provides reactive asset data based on node type with automatic category detection.
* Uses store-based caching to avoid duplicate fetches across multiple instances.
*
* Cloud-only composable - returns empty data when not in cloud environment.
* Cloud reads from the assets store; desktop/localhost reads from the local
* Model Library source (which enumerates /models/<folder>).
*
* @param nodeType - ComfyUI node type (ref, getter, or plain value). Can be undefined.
* Accepts: ref('CheckpointLoaderSimple'), () => 'CheckpointLoaderSimple', or 'CheckpointLoaderSimple'
@@ -20,7 +23,7 @@ import { useModelToNodeStore } from '@/stores/modelToNodeStore'
export function useAssetWidgetData(
nodeType: MaybeRefOrGetter<string | undefined>
) {
if (isCloud) {
if (isCloud || forceModelPickerAssetMode) {
const assetsStore = useAssetsStore()
const modelToNodeStore = useModelToNodeStore()
@@ -71,10 +74,30 @@ export function useAssetWidgetData(
}
}
// Local mode (desktop / localhost): the unified Model Library source has
// already enumerated /models/<folder>. Look up the node's category via the
// shared modelToNodeStore and return only the assets in that directory so
// each load-node's picker is scoped to the right kind of files.
const localSource = useLocalModelLibrarySource()
const modelToNodeStore = useModelToNodeStore()
const category = computed(() => {
const resolvedType = toValue(nodeType)
return resolvedType
? modelToNodeStore.getCategoryForNodeType(resolvedType)
: undefined
})
const assets = computed<AssetItem[]>(() => {
const cat = category.value
if (!cat) return []
return localSource.assets.value.filter((a) => a.metadata?.directory === cat)
})
return {
category: computed(() => undefined),
assets: computed<AssetItem[]>(() => []),
isLoading: computed(() => false),
error: computed(() => null)
category,
assets,
isLoading: localSource.isLoading,
error: computed<Error | null>(() => null)
}
}

View File

@@ -73,6 +73,7 @@ vi.mock('@/i18n', () => ({
}))
vi.mock('@/platform/assets/services/assetService', () => ({
MODELS_TAG: 'models',
assetService: {
isAssetBrowserEligible: vi.fn(() => false),
shouldUseAssetBrowser: vi.fn(() => false)

View File

@@ -9,6 +9,7 @@ import { assetService } from '@/platform/assets/services/assetService'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import { createAssetWidget } from '@/platform/assets/utils/createAssetWidget'
import { forceModelPickerAssetMode } from '@/platform/assets/forceAssetMode'
import { isCloud } from '@/platform/distribution/types'
import type {
ComboInputSpec,
@@ -243,18 +244,21 @@ const addComboWidget = (
): IBaseWidget => {
const defaultValue = getDefaultValue(inputSpec)
if (isCloud) {
if (assetService.shouldUseAssetBrowser(node.comfyClass, inputSpec.name)) {
// Default from cloud assets, not from server combo options.
// Server options list local files that may not exist in the user's
// cloud asset library, leading to missing-model errors on undo/reload.
const cloudDefault = resolveCloudDefault(
node.comfyClass ?? '',
inputSpec.default
)
return createAssetBrowserWidget(node, inputSpec, cloudDefault)
}
if (
(isCloud || forceModelPickerAssetMode) &&
assetService.shouldUseAssetBrowser(node.comfyClass, inputSpec.name)
) {
// Default from cloud assets, not from server combo options.
// Server options list local files that may not exist in the user's
// cloud asset library, leading to missing-model errors on undo/reload.
const cloudDefault = resolveCloudDefault(
node.comfyClass ?? '',
inputSpec.default
)
return createAssetBrowserWidget(node, inputSpec, cloudDefault)
}
if (isCloud) {
if (NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']) {
return createInputMappingWidget(
node,

View File

@@ -428,6 +428,41 @@ describe('useWidgetSelectItems', () => {
true
)
})
it('maps author and placeholder_category onto cloud asset items', () => {
mockAssetsData.items = [
{
id: 'asset-1',
name: 'flux_lora.safetensors',
preview_url: '',
tags: ['models', 'loras'],
metadata: { author: 'Black Forest Labs' }
}
]
const assetData = {
category: computed(() => 'loras'),
assets: computed(() => mockAssetsData.items),
isLoading: computed(() => false),
error: computed(() => null)
}
const { dropdownItems } = useWidgetSelectItems(
createDefaultOptions({
values: () => [],
modelValue: ref('flux_lora.safetensors'),
assetKind: () => 'model',
isAssetMode: () => true,
assetData
})
)
expect(dropdownItems.value[0]).toMatchObject({
name: 'flux_lora.safetensors',
author: 'Black Forest Labs',
placeholder_category: 'loras'
})
})
})
describe('multi-output jobs', () => {
@@ -1064,6 +1099,46 @@ describe('useWidgetSelectItems', () => {
})
})
describe('All tab recency ordering', () => {
it('interleaves imported and generated media by recency, newest first', async () => {
const { useAssetsStore } = await import('@/stores/assetsStore')
const store = useAssetsStore()
store.inputAssets = [
{
id: 'in-1',
name: 'old_import.png',
asset_hash: 'old_import.png',
tags: ['input'],
created_at: '2025-01-01T00:00:00Z'
} as AssetItem
]
mockMediaAssets = createMockMediaAssets()
mockMediaAssets.media.value = [
{
id: 'out-1',
name: 'new_output.png',
tags: ['output'],
created_at: '2025-06-01T00:00:00Z'
} as AssetItem
]
const { dropdownItems } = useWidgetSelectItems(
createDefaultOptions({
values: () => ['old_import.png'],
modelValue: ref(undefined),
outputMediaAssets: mockMediaAssets
})
)
await nextTick()
expect(dropdownItems.value.map((i) => i.name)).toEqual([
'new_output.png [output]',
'old_import.png'
])
})
})
describe('FE-230 missing-media filtering', () => {
it('drops input items whose name is in the missing-media store', async () => {
const { useMissingMediaStore } =

View File

@@ -2,6 +2,11 @@ import { capitalize } from 'es-toolkit'
import { computed, ref, shallowRef, toValue, watch } from 'vue'
import type { MaybeRefOrGetter, Ref } from 'vue'
import {
UNKNOWN_PROVIDER,
getAssetProvider
} from '@/components/sidebar/tabs/cloudModelLibrary/modelGroups'
import { placeholderCategoryForAsset } from '@/composables/sidebarTabs/useCategoryPlaceholder'
import { t } from '@/i18n'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
@@ -26,6 +31,8 @@ import type { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
import { sortAssets } from '@/platform/assets/utils/assetSortUtils'
import { useAssetsStore } from '@/stores/assetsStore'
import type { IAssetsProvider } from '@/platform/assets/composables/media/IAssetsProvider'
import type { AssetKind } from '@/types/widgetTypes'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
@@ -48,6 +55,11 @@ function assetKindToMediaType(kind: AssetKind): string {
return kind === 'mesh' ? '3D' : kind
}
function getAssetAuthorLabel(asset: AssetItem): string | undefined {
const provider = getAssetProvider(asset)
return provider && provider !== UNKNOWN_PROVIDER ? provider : undefined
}
function getMediaUrl(
filename: string,
type: 'input' | 'output',
@@ -74,6 +86,7 @@ interface UseWidgetSelectItemsOptions {
export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
const { modelValue, outputMediaAssets, assetData } = options
const assetsStore = useAssetsStore()
const missingMediaStore = useMissingMediaStore()
const missingMediaValues = computed<ReadonlySet<string>>(
() =>
@@ -193,7 +206,9 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
id: `input-${index}`,
preview_url: getMediaUrl(String(value), 'input', kind),
name: String(value),
label: getDisplayLabel(String(value), labelFn)
label: getDisplayLabel(String(value), labelFn),
created_at: assetsStore.inputAssetsByFilename.get(String(value))
?.created_at
}))
})
@@ -235,7 +250,8 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
? ''
: asset.preview_url || getMediaUrl(filenameForUrl, 'output', kind),
name: annotatedPath,
label: getDisplayLabel(displayLabel, labelFn)
label: getDisplayLabel(displayLabel, labelFn),
created_at: asset.created_at
})
}
@@ -292,13 +308,21 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
const assetItems = computed<FormDropdownItem[]>(() => {
if (!toValue(options.isAssetMode) || !assetData) return []
// The category placeholder is a model-type gradient; media assets resolve
// to a non-model tag, so leave it unset and let the generic media fallback
// render (matching the non-asset-mode media path).
const isModelKind = toValue(options.assetKind) === 'model'
return assetData.assets.value.map((asset) => ({
id: asset.id,
name: getAssetFilename(asset),
label: getAssetDisplayName(asset),
preview_url: asset.preview_url,
is_immutable: asset.is_immutable,
base_models: getAssetBaseModels(asset)
base_models: getAssetBaseModels(asset),
author: getAssetAuthorLabel(asset),
placeholder_category: isModelKind
? placeholderCategoryForAsset(asset)
: undefined
}))
})
@@ -313,10 +337,16 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
if (toValue(options.isAssetMode) && assetData) {
return filteredAssetItems.value
}
// The "All" tab interleaves imported and generated media by recency rather
// than grouping by type, so the newest asset shows first regardless of
// origin.
const byRecency = sortAssets(
[...inputItems.value, ...outputItems.value],
'recent'
)
return [
...(missingValueItem.value ? [missingValueItem.value] : []),
...inputItems.value,
...outputItems.value
...byRecency
]
})

View File

@@ -383,421 +383,402 @@ export const useAssetsStore = defineStore('assets', () => {
* Multiple node types sharing the same category share the same cache entry.
* Public API accepts nodeType for backwards compatibility but translates
* to category internally using modelToNodeStore.getCategoryForNodeType().
* Cloud-only feature - empty Maps in desktop builds
*/
const getModelState = () => {
if (isCloud) {
const modelStateByCategory = ref(new Map<string, ModelPaginationState>())
const modelStateByCategory = ref(new Map<string, ModelPaginationState>())
const assetsArrayCache = new Map<
string,
{ source: Map<string, AssetItem>; array: AssetItem[] }
>()
const assetsArrayCache = new Map<
string,
{ source: Map<string, AssetItem>; array: AssetItem[] }
>()
const pendingRequestByCategory = new Map<string, ModelPaginationState>()
const pendingPromiseByCategory = new Map<string, Promise<void>>()
const pendingRequestByCategory = new Map<string, ModelPaginationState>()
const pendingPromiseByCategory = new Map<string, Promise<void>>()
function createState(
existingAssets?: Map<string, AssetItem>
): ModelPaginationState {
const assets = new Map(existingAssets)
return reactive({
assets,
offset: 0,
hasMore: true,
isLoading: true
})
function createState(
existingAssets?: Map<string, AssetItem>
): ModelPaginationState {
const assets = new Map(existingAssets)
return reactive({
assets,
offset: 0,
hasMore: true,
isLoading: true
})
}
function isStale(category: string, state: ModelPaginationState): boolean {
const committed = modelStateByCategory.value.get(category)
const pending = pendingRequestByCategory.get(category)
return committed !== state && pending !== state
}
const EMPTY_ASSETS: AssetItem[] = []
/**
* Resolve a key to a category. Handles both nodeType and tag:xxx formats.
* @param key Either a nodeType (e.g., 'CheckpointLoaderSimple') or tag key (e.g., 'tag:models')
* @returns The category or undefined if not resolvable
*/
function resolveCategory(key: string): string | undefined {
if (key.startsWith('tag:')) {
return key
}
return modelToNodeStore.getCategoryForNodeType(key)
}
/**
* Get assets by nodeType or tag key.
* Translates nodeType to category internally for cache lookup.
* @param key Either a nodeType (e.g., 'CheckpointLoaderSimple') or tag key (e.g., 'tag:models')
*/
function getAssets(key: string): AssetItem[] {
const category = resolveCategory(key)
if (!category) return EMPTY_ASSETS
const state = modelStateByCategory.value.get(category)
const assetsMap = state?.assets
if (!assetsMap) return EMPTY_ASSETS
const cached = assetsArrayCache.get(category)
if (cached && cached.source === assetsMap) {
return cached.array
}
function isStale(category: string, state: ModelPaginationState): boolean {
const committed = modelStateByCategory.value.get(category)
const pending = pendingRequestByCategory.get(category)
return committed !== state && pending !== state
const array = Array.from(assetsMap.values())
assetsArrayCache.set(category, { source: assetsMap, array })
return array
}
function isLoading(key: string): boolean {
const category = resolveCategory(key)
if (!category) return false
return modelStateByCategory.value.get(category)?.isLoading ?? false
}
function getError(key: string): Error | undefined {
const category = resolveCategory(key)
if (!category) return undefined
return modelStateByCategory.value.get(category)?.error
}
function hasMore(key: string): boolean {
const category = resolveCategory(key)
if (!category) return false
return modelStateByCategory.value.get(category)?.hasMore ?? false
}
function hasAssetKey(key: string): boolean {
const category = resolveCategory(key)
if (!category) return false
return modelStateByCategory.value.has(category)
}
/**
* Check if a category exists in the cache.
* Checks both direct category keys and tag-prefixed keys.
* @param category The category to check (e.g., 'checkpoints', 'loras')
*/
function hasCategory(category: string): boolean {
return (
modelStateByCategory.value.has(category) ||
modelStateByCategory.value.has(`tag:${category}`)
)
}
/**
* Internal helper to fetch and cache assets for a category.
* Loads first batch immediately, then progressively loads remaining batches.
* Keeps existing data visible until new data is successfully fetched.
*
* Concurrent calls for the same category are short-circuited: if a request
* is already in progress (tracked via pendingRequestByCategory), subsequent
* calls return immediately to avoid redundant work.
*/
async function updateModelsForCategory(
category: string,
fetcher: (options: PaginationOptions) => Promise<AssetItem[]>
): Promise<void> {
if (pendingPromiseByCategory.has(category)) {
return pendingPromiseByCategory.get(category)!
}
const EMPTY_ASSETS: AssetItem[] = []
const existingState = modelStateByCategory.value.get(category)
const state = createState(existingState?.assets)
/**
* Resolve a key to a category. Handles both nodeType and tag:xxx formats.
* @param key Either a nodeType (e.g., 'CheckpointLoaderSimple') or tag key (e.g., 'tag:models')
* @returns The category or undefined if not resolvable
*/
function resolveCategory(key: string): string | undefined {
if (key.startsWith('tag:')) {
return key
}
return modelToNodeStore.getCategoryForNodeType(key)
const seenIds = new Set<string>()
const hasExistingData = modelStateByCategory.value.has(category)
if (hasExistingData) {
pendingRequestByCategory.set(category, state)
} else {
// Also track in pending map for initial loads to prevent concurrent calls
pendingRequestByCategory.set(category, state)
modelStateByCategory.value.set(category, state)
}
/**
* Get assets by nodeType or tag key.
* Translates nodeType to category internally for cache lookup.
* @param key Either a nodeType (e.g., 'CheckpointLoaderSimple') or tag key (e.g., 'tag:models')
*/
function getAssets(key: string): AssetItem[] {
const category = resolveCategory(key)
if (!category) return EMPTY_ASSETS
async function loadBatches(): Promise<void> {
while (state.hasMore) {
try {
const newAssets = await fetcher({
limit: MODEL_BATCH_SIZE,
offset: state.offset
})
const state = modelStateByCategory.value.get(category)
const assetsMap = state?.assets
if (!assetsMap) return EMPTY_ASSETS
if (isStale(category, state)) return
const cached = assetsArrayCache.get(category)
if (cached && cached.source === assetsMap) {
return cached.array
}
const array = Array.from(assetsMap.values())
assetsArrayCache.set(category, { source: assetsMap, array })
return array
}
function isLoading(key: string): boolean {
const category = resolveCategory(key)
if (!category) return false
return modelStateByCategory.value.get(category)?.isLoading ?? false
}
function getError(key: string): Error | undefined {
const category = resolveCategory(key)
if (!category) return undefined
return modelStateByCategory.value.get(category)?.error
}
function hasMore(key: string): boolean {
const category = resolveCategory(key)
if (!category) return false
return modelStateByCategory.value.get(category)?.hasMore ?? false
}
function hasAssetKey(key: string): boolean {
const category = resolveCategory(key)
if (!category) return false
return modelStateByCategory.value.has(category)
}
/**
* Check if a category exists in the cache.
* Checks both direct category keys and tag-prefixed keys.
* @param category The category to check (e.g., 'checkpoints', 'loras')
*/
function hasCategory(category: string): boolean {
return (
modelStateByCategory.value.has(category) ||
modelStateByCategory.value.has(`tag:${category}`)
)
}
/**
* Internal helper to fetch and cache assets for a category.
* Loads first batch immediately, then progressively loads remaining batches.
* Keeps existing data visible until new data is successfully fetched.
*
* Concurrent calls for the same category are short-circuited: if a request
* is already in progress (tracked via pendingRequestByCategory), subsequent
* calls return immediately to avoid redundant work.
*/
async function updateModelsForCategory(
category: string,
fetcher: (options: PaginationOptions) => Promise<AssetItem[]>
): Promise<void> {
if (pendingPromiseByCategory.has(category)) {
return pendingPromiseByCategory.get(category)!
}
const existingState = modelStateByCategory.value.get(category)
const state = createState(existingState?.assets)
const seenIds = new Set<string>()
const hasExistingData = modelStateByCategory.value.has(category)
if (hasExistingData) {
pendingRequestByCategory.set(category, state)
} else {
// Also track in pending map for initial loads to prevent concurrent calls
pendingRequestByCategory.set(category, state)
modelStateByCategory.value.set(category, state)
}
async function loadBatches(): Promise<void> {
while (state.hasMore) {
try {
const newAssets = await fetcher({
limit: MODEL_BATCH_SIZE,
offset: state.offset
})
if (isStale(category, state)) return
const isFirstBatch = state.offset === 0
if (isFirstBatch) {
assetsArrayCache.delete(category)
if (hasExistingData) {
pendingRequestByCategory.delete(category)
modelStateByCategory.value.set(category, state)
}
const isFirstBatch = state.offset === 0
if (isFirstBatch) {
assetsArrayCache.delete(category)
if (hasExistingData) {
pendingRequestByCategory.delete(category)
modelStateByCategory.value.set(category, state)
}
// Merge new assets into existing map and track seen IDs
for (const asset of newAssets) {
seenIds.add(asset.id)
state.assets.set(asset.id, asset)
}
state.assets = new Map(state.assets)
state.offset += newAssets.length
state.hasMore = newAssets.length === MODEL_BATCH_SIZE
if (isFirstBatch) {
state.isLoading = false
}
if (state.hasMore) {
await new Promise((resolve) => setTimeout(resolve, 50))
}
} catch (err) {
if (isStale(category, state)) return
console.error(`Error loading batch for ${category}:`, err)
state.error = err instanceof Error ? err : new Error(String(err))
state.hasMore = false
state.isLoading = false
pendingRequestByCategory.delete(category)
return
}
}
const staleIds = [...state.assets.keys()].filter(
(id) => !seenIds.has(id)
)
for (const id of staleIds) {
state.assets.delete(id)
// Merge new assets into existing map and track seen IDs
for (const asset of newAssets) {
seenIds.add(asset.id)
state.assets.set(asset.id, asset)
}
state.assets = new Map(state.assets)
state.offset += newAssets.length
state.hasMore = newAssets.length === MODEL_BATCH_SIZE
if (isFirstBatch) {
state.isLoading = false
}
if (state.hasMore) {
await new Promise((resolve) => setTimeout(resolve, 50))
}
} catch (err) {
if (isStale(category, state)) return
console.error(`Error loading batch for ${category}:`, err)
state.error = err instanceof Error ? err : new Error(String(err))
state.hasMore = false
state.isLoading = false
pendingRequestByCategory.delete(category)
return
}
assetsArrayCache.delete(category)
pendingRequestByCategory.delete(category)
}
const promise = loadBatches().finally(() => {
pendingPromiseByCategory.delete(category)
})
pendingPromiseByCategory.set(category, promise)
await promise
}
/**
* Fetch and cache model assets for a specific node type.
* Translates nodeType to category internally - multiple node types
* sharing the same category will share the same cache entry.
* @param nodeType The node type to fetch assets for (e.g., 'CheckpointLoaderSimple')
*/
async function updateModelsForNodeType(nodeType: string): Promise<void> {
const category = modelToNodeStore.getCategoryForNodeType(nodeType)
if (!category) return
// Use category as cache key but fetch using nodeType for API compatibility
await updateModelsForCategory(category, (opts) =>
assetService.getAssetsForNodeType(nodeType, opts)
const staleIds = [...state.assets.keys()].filter(
(id) => !seenIds.has(id)
)
}
/**
* Fetch and cache model assets for a specific tag
* @param tag The tag to fetch assets for (e.g., 'models')
*/
async function updateModelsForTag(tag: string): Promise<void> {
const category = `tag:${tag}`
await updateModelsForCategory(category, (opts) =>
assetService.getAssetsByTag(tag, true, opts)
)
}
/**
* Invalidate the cache for a specific category.
* Forces a refetch on next access.
* @param category The category to invalidate (e.g., 'checkpoints', 'loras')
*/
function invalidateCategory(category: string): void {
modelStateByCategory.value.delete(category)
for (const id of staleIds) {
state.assets.delete(id)
}
assetsArrayCache.delete(category)
pendingRequestByCategory.delete(category)
}
const promise = loadBatches().finally(() => {
pendingPromiseByCategory.delete(category)
}
})
pendingPromiseByCategory.set(category, promise)
await promise
}
/**
* Optimistically update an asset in the cache
* @param assetId The asset ID to update
* @param updates Partial asset data to merge
* @param cacheKey Optional cache key to target (nodeType or 'tag:xxx')
*/
function updateAssetInCache(
assetId: string,
updates: Partial<AssetItem>,
cacheKey?: string
) {
const category = cacheKey ? resolveCategory(cacheKey) : undefined
if (cacheKey && !category) return
/**
* Fetch and cache model assets for a specific node type.
* Translates nodeType to category internally - multiple node types
* sharing the same category will share the same cache entry.
* @param nodeType The node type to fetch assets for (e.g., 'CheckpointLoaderSimple')
*/
async function updateModelsForNodeType(nodeType: string): Promise<void> {
const category = modelToNodeStore.getCategoryForNodeType(nodeType)
if (!category) return
const categoriesToCheck = category
? [category]
: Array.from(modelStateByCategory.value.keys())
// Use category as cache key but fetch using nodeType for API compatibility
await updateModelsForCategory(category, (opts) =>
assetService.getAssetsForNodeType(nodeType, opts)
)
}
for (const cat of categoriesToCheck) {
const state = modelStateByCategory.value.get(cat)
if (!state?.assets) continue
/**
* Fetch and cache model assets for a specific tag
* @param tag The tag to fetch assets for (e.g., 'models')
*/
async function updateModelsForTag(tag: string): Promise<void> {
const category = `tag:${tag}`
await updateModelsForCategory(category, (opts) =>
assetService.getAssetsByTag(tag, true, opts)
)
}
const existingAsset = state.assets.get(assetId)
if (existingAsset) {
const updatedAsset = { ...existingAsset, ...updates }
state.assets.set(assetId, updatedAsset)
assetsArrayCache.delete(cat)
if (cacheKey) return
}
/**
* Invalidate the cache for a specific category.
* Forces a refetch on next access.
* @param category The category to invalidate (e.g., 'checkpoints', 'loras')
*/
function invalidateCategory(category: string): void {
modelStateByCategory.value.delete(category)
assetsArrayCache.delete(category)
pendingRequestByCategory.delete(category)
pendingPromiseByCategory.delete(category)
}
/**
* Optimistically update an asset in the cache
* @param assetId The asset ID to update
* @param updates Partial asset data to merge
* @param cacheKey Optional cache key to target (nodeType or 'tag:xxx')
*/
function updateAssetInCache(
assetId: string,
updates: Partial<AssetItem>,
cacheKey?: string
) {
const category = cacheKey ? resolveCategory(cacheKey) : undefined
if (cacheKey && !category) return
const categoriesToCheck = category
? [category]
: Array.from(modelStateByCategory.value.keys())
for (const cat of categoriesToCheck) {
const state = modelStateByCategory.value.get(cat)
if (!state?.assets) continue
const existingAsset = state.assets.get(assetId)
if (existingAsset) {
const updatedAsset = { ...existingAsset, ...updates }
state.assets.set(assetId, updatedAsset)
assetsArrayCache.delete(cat)
if (cacheKey) return
}
}
/**
* Update asset metadata with optimistic cache update
* @param asset The asset to update
* @param userMetadata The user_metadata to save
* @param cacheKey Optional cache key to target for optimistic update
*/
async function updateAssetMetadata(
asset: AssetItem,
userMetadata: Record<string, unknown>,
cacheKey?: string
) {
const originalMetadata = asset.user_metadata
updateAssetInCache(asset.id, { user_metadata: userMetadata }, cacheKey)
try {
const updatedAsset = await assetService.updateAsset(asset.id, {
user_metadata: userMetadata
})
updateAssetInCache(asset.id, updatedAsset, cacheKey)
} catch (error) {
console.error('Failed to update asset metadata:', error)
updateAssetInCache(
asset.id,
{ user_metadata: originalMetadata },
cacheKey
)
}
}
/**
* Update asset tags using add/remove endpoints
* @param asset The asset to update (used to read current tags)
* @param newTags The desired tags array
* @param cacheKey Optional cache key to target for optimistic update
*/
async function updateAssetTags(
asset: AssetItem,
newTags: string[],
cacheKey?: string
) {
const originalTags = asset.tags
const tagsToAdd = difference(newTags, originalTags)
const tagsToRemove = difference(originalTags, newTags)
if (tagsToAdd.length === 0 && tagsToRemove.length === 0) return
updateAssetInCache(asset.id, { tags: newTags }, cacheKey)
let removedTagsOnServer: string[] = []
try {
let removeResult: TagsOperationResult | undefined
if (tagsToRemove.length > 0) {
removeResult = await assetService.removeAssetTags(
asset.id,
tagsToRemove
)
removedTagsOnServer = removeResult.removed ?? tagsToRemove
}
const addResult =
tagsToAdd.length > 0
? await assetService.addAssetTags(asset.id, tagsToAdd)
: undefined
const finalTags = (addResult ?? removeResult)?.total_tags
if (finalTags) {
updateAssetInCache(asset.id, { tags: finalTags }, cacheKey)
}
} catch (error) {
console.error('Failed to update asset tags:', error)
updateAssetInCache(asset.id, { tags: originalTags }, cacheKey)
if (removedTagsOnServer.length > 0) {
try {
await assetService.addAssetTags(asset.id, removedTagsOnServer)
} catch (compensationError) {
console.error(
'Failed to restore tags after partial failure; invalidating cache to force refetch:',
compensationError
)
const categoriesToInvalidate = new Set<string>()
const resolved = cacheKey ? resolveCategory(cacheKey) : undefined
if (resolved) {
categoriesToInvalidate.add(resolved)
}
for (const [
category,
state
] of modelStateByCategory.value.entries()) {
if (state.assets?.has(asset.id)) {
categoriesToInvalidate.add(category)
}
}
for (const category of categoriesToInvalidate) {
invalidateCategory(category)
}
}
}
}
}
/**
* Invalidate model caches for a given category (e.g., 'checkpoints', 'loras')
* Clears the category cache and tag-based caches so next access triggers refetch
* @param category The model category to invalidate (e.g., 'checkpoints')
*/
function invalidateModelsForCategory(category: string): void {
invalidateCategory(category)
invalidateCategory(`tag:${category}`)
invalidateCategory('tag:models')
}
return {
getAssets,
isLoading,
getError,
hasMore,
hasAssetKey,
hasCategory,
updateModelsForNodeType,
updateModelsForTag,
invalidateCategory,
updateAssetMetadata,
updateAssetTags,
invalidateModelsForCategory
}
}
const emptyAssets: AssetItem[] = []
/**
* Update asset metadata with optimistic cache update
* @param asset The asset to update
* @param userMetadata The user_metadata to save
* @param cacheKey Optional cache key to target for optimistic update
*/
async function updateAssetMetadata(
asset: AssetItem,
userMetadata: Record<string, unknown>,
cacheKey?: string
) {
const originalMetadata = asset.user_metadata
updateAssetInCache(asset.id, { user_metadata: userMetadata }, cacheKey)
try {
const updatedAsset = await assetService.updateAsset(asset.id, {
user_metadata: userMetadata
})
updateAssetInCache(asset.id, updatedAsset, cacheKey)
} catch (error) {
console.error('Failed to update asset metadata:', error)
updateAssetInCache(
asset.id,
{ user_metadata: originalMetadata },
cacheKey
)
}
}
/**
* Update asset tags using add/remove endpoints
* @param asset The asset to update (used to read current tags)
* @param newTags The desired tags array
* @param cacheKey Optional cache key to target for optimistic update
*/
async function updateAssetTags(
asset: AssetItem,
newTags: string[],
cacheKey?: string
) {
const originalTags = asset.tags
const tagsToAdd = difference(newTags, originalTags)
const tagsToRemove = difference(originalTags, newTags)
if (tagsToAdd.length === 0 && tagsToRemove.length === 0) return
updateAssetInCache(asset.id, { tags: newTags }, cacheKey)
let removedTagsOnServer: string[] = []
try {
let removeResult: TagsOperationResult | undefined
if (tagsToRemove.length > 0) {
removeResult = await assetService.removeAssetTags(
asset.id,
tagsToRemove
)
removedTagsOnServer = removeResult.removed ?? tagsToRemove
}
const addResult =
tagsToAdd.length > 0
? await assetService.addAssetTags(asset.id, tagsToAdd)
: undefined
const finalTags = (addResult ?? removeResult)?.total_tags
if (finalTags) {
updateAssetInCache(asset.id, { tags: finalTags }, cacheKey)
}
} catch (error) {
console.error('Failed to update asset tags:', error)
updateAssetInCache(asset.id, { tags: originalTags }, cacheKey)
if (removedTagsOnServer.length > 0) {
try {
await assetService.addAssetTags(asset.id, removedTagsOnServer)
} catch (compensationError) {
console.error(
'Failed to restore tags after partial failure; invalidating cache to force refetch:',
compensationError
)
const categoriesToInvalidate = new Set<string>()
const resolved = cacheKey ? resolveCategory(cacheKey) : undefined
if (resolved) {
categoriesToInvalidate.add(resolved)
}
for (const [
category,
state
] of modelStateByCategory.value.entries()) {
if (state.assets?.has(asset.id)) {
categoriesToInvalidate.add(category)
}
}
for (const category of categoriesToInvalidate) {
invalidateCategory(category)
}
}
}
}
}
/**
* Invalidate model caches for a given category (e.g., 'checkpoints', 'loras')
* Clears the category cache and tag-based caches so next access triggers refetch
* @param category The model category to invalidate (e.g., 'checkpoints')
*/
function invalidateModelsForCategory(category: string): void {
invalidateCategory(category)
invalidateCategory(`tag:${category}`)
invalidateCategory('tag:models')
}
return {
getAssets: () => emptyAssets,
isLoading: () => false,
getError: () => undefined,
hasMore: () => false,
hasAssetKey: () => false,
hasCategory: () => false,
updateModelsForNodeType: async () => {},
invalidateCategory: () => {},
updateModelsForTag: async () => {},
updateAssetMetadata: async () => {},
updateAssetTags: async () => {},
invalidateModelsForCategory: () => {}
getAssets,
isLoading,
getError,
hasMore,
hasAssetKey,
hasCategory,
updateModelsForNodeType,
updateModelsForTag,
invalidateCategory,
updateAssetMetadata,
updateAssetTags,
invalidateModelsForCategory
}
}

View File

@@ -63,15 +63,6 @@ vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({
})
}))
vi.mock('@/composables/sidebarTabs/useModelLibrarySidebarTab', () => ({
useModelLibrarySidebarTab: () => ({
id: 'model-library',
title: 'model-library',
type: 'vue',
component: {}
})
}))
vi.mock(
'@/platform/workflow/management/composables/useWorkflowsSidebarTab',
() => ({
@@ -138,6 +129,23 @@ describe('useSidebarTabStore', () => {
expect(mockRegisterCommand).toHaveBeenCalledTimes(5)
})
it('toggles the model library tab when the asset API setting is enabled', async () => {
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.Assets.UseAssetAPI' ? true : undefined
)
const store = useSidebarTabStore()
store.registerCoreSidebarTabs()
const command = mockRegisterCommand.mock.calls
.map(([registered]) => registered)
.find(({ id }) => id === 'Workspace.ToggleSidebarTab.model-library')
expect(command).toBeDefined()
await command.function()
expect(store.activeSidebarTab?.id).toBe('model-library')
})
it('prepends the job history tab when QPO V2 is toggled on', async () => {
const qpoV2Enabled = ref(false)
mockGetSetting.mockImplementation((key: string) =>

View File

@@ -2,8 +2,8 @@ import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { useAssetsSidebarTab } from '@/composables/sidebarTabs/useAssetsSidebarTab'
import { useCloudModelLibrarySidebarTab } from '@/composables/sidebarTabs/useCloudModelLibrarySidebarTab'
import { useJobHistorySidebarTab } from '@/composables/sidebarTabs/useJobHistorySidebarTab'
import { useModelLibrarySidebarTab } from '@/composables/sidebarTabs/useModelLibrarySidebarTab'
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
import { t, te } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -72,20 +72,7 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
tooltip: tooltipFunction,
versionAdded: '1.3.9',
category: 'view-controls' as const,
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
}
function: () => {
toggleSidebarTab(tab.id)
},
active: () => activeSidebarTab.value?.id === tab.id,
@@ -134,7 +121,9 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
registerSidebarTab(useAssetsSidebarTab())
registerSidebarTab(useNodeLibrarySidebarTab())
registerSidebarTab(useModelLibrarySidebarTab())
// Use the unified Model Library tab everywhere — its data source branches
// on isCloud internally.
registerSidebarTab(useCloudModelLibrarySidebarTab())
registerSidebarTab(useWorkflowsSidebarTab())
registerSidebarTab(useAppsSidebarTab())

View File

@@ -108,6 +108,17 @@ export function getProviderIcon(providerName: string): string {
return `icon-[comfy--${iconKey}]`
}
/**
* Checks whether a custom brand icon is registered for the given provider.
* Backed by [[PROVIDER_COLORS]], which is the source of truth for partner
* brands that ship with a colored SVG under
* [[packages/design-system/src/icons]].
*/
export function hasProviderIcon(providerName: string): boolean {
const iconKey = providerName.toLowerCase().replaceAll(/\s+/g, '-')
return iconKey in PROVIDER_COLORS
}
/**
* Returns the border color(s) for an API node provider badge.
* @param providerName - The provider name from the node category