Compare commits

...

9 Commits

Author SHA1 Message Date
Simon Pinfold
c9649228bd fix: always toggle unified Model Library tab from sidebar button
The model-library toggle command redirected to Comfy.BrowseModelAssets
whenever the UseAssetAPI setting was on. On cloud that command toggles
the same tab anyway; on local builds it opened the legacy asset-browser
overlay, making the new tab unreachable for anyone who had enabled the
asset API experiment. Drop the redirect so the button always toggles
the tab.
2026-06-10 17:53:23 +12:00
Simon Pinfold
bf481f2c71 fix: adapt FormDropdown to useTransformCompatOverlayProps removal
Main removed the composable in #12513 (dropdowns now append to body
directly). The picker already used a constant appendTo:'body' override,
so pass it inline.
2026-06-10 10:42:24 +12:00
Simon Pinfold
45fef1d89c merge: PR #12635 feat: redesign in-node model/media picker 2026-06-10 10:38:23 +12:00
Simon Pinfold
cf996d8e39 merge: PR #12634 feat: add Model Library sidebar tab (cloud + local) 2026-06-10 10:37:48 +12:00
Simon Pinfold
ebf70db149 merge: PR #12633 feat: add Model Library data foundation 2026-06-10 10:37:05 +12:00
shrimbly
6c0c603576 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-04 10:05:09 +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
64 changed files with 4427 additions and 726 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,270 @@
<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 v-if="previewNodeDef" class="-mx-4 border-t border-border-default" />
<!-- 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,125 @@
<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="icon-[comfy--ai-model] 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 :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'
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')
}
const handleActivate = () => {
emit('activate', asset)
}
const onGenerateDragPreview = useNodePreviewDragImage(() => {
const category = getAssetModelType(asset)
return category
? (useModelToNodeStore().getNodeProvider(category)?.nodeDef ?? null)
: null
})
usePragmaticDraggable(() => rowRef.value, {
getInitialData: () => ({ type: 'cloud-model-asset', asset }),
onGenerateDragPreview,
onDragStart: hide
})
</script>

View File

@@ -0,0 +1,587 @@
<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.sort.tooltip')"
variant="secondary"
size="icon"
:aria-label="$t('assets.sort.tooltip')"
>
<i class="icon-[lucide--arrow-down-up] size-4" />
</Button>
</template>
<template #default>
<div class="flex min-w-44 flex-col">
<Button
v-for="option in SORT_OPTIONS"
:key="option.value"
variant="textonly"
class="w-full justify-between"
@click="sortMode = option.value"
>
<span>{{ $t(option.labelKey) }}</span>
<i
class="ml-auto icon-[lucide--check] size-4"
:class="sortMode !== 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">
<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
"
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,
fallbackGroupLabel,
formatPartnerProvider,
getAssetProvider,
isPartnerNodeCategory
} from '@/components/sidebar/tabs/cloudModelLibrary/modelGroups'
import {
firstNonModelsTag,
groupIdForAsset,
groupLabelForAsset,
partnerKind,
rawTagTopLevel
} 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 { isCloud } from '@/platform/distribution/types'
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
// Base-model sort/grouping relies on reliable base-model metadata, which only
// the cloud assets API provides; local builds list models alphabetically.
const SORT_OPTIONS = isCloud
? ALL_SORT_OPTIONS
: ALL_SORT_OPTIONS.filter(
(option) =>
option.value !== 'baseModelAsc' && option.value !== 'baseModelDesc'
)
const sortMode = useStorage<SortMode>(
'Comfy.CloudModelLibrary.SortBy',
isCloud ? 'baseModelAsc' : 'nameAsc'
)
// A base-model sort persisted earlier (or shared with the cloud build via the
// same storage key) must not survive on local, where the option is hidden.
if (
!isCloud &&
(sortMode.value === 'baseModelAsc' || sortMode.value === 'baseModelDesc')
) {
sortMode.value = 'nameAsc'
}
const expanded = ref<Record<string, boolean>>({})
const expandedBeforeSearch = ref<Record<string, boolean>>({})
const assets = computed<AssetItem[]>(() => source.assets.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 = sortMode.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 knownGroups = MODEL_GROUPS.filter(
(g) => g.id !== PARTNER_NODES_GROUP_ID
)
const assetsByGroup = new Map<string, AssetItem[]>()
const unmappedByTag = new Map<string, AssetItem[]>()
for (const asset of matchedAssets.value) {
const tag = firstNonModelsTag(asset)
if (!tag) continue
const top = rawTagTopLevel(tag)
// groupIdForAsset applies the base-model category override (e.g. an
// ACE-Step text encoder lands under "TTS & audio" with its base, not
// "Encoders"). Falls back to the tag-derived group for assets with no
// resolvable base.
const groupId = groupIdForAsset(asset)
if (groupId) {
const list = assetsByGroup.get(groupId) ?? []
list.push(asset)
assetsByGroup.set(groupId, list)
} else {
const list = unmappedByTag.get(top) ?? []
list.push(asset)
unmappedByTag.set(top, list)
}
}
const filteredPartners = matchedPartners.value
const result: Section[] = []
// The curated PINNED_GROUP_IDS render first in their declared order
// (Diffusion LoRAs Partner nodes); everything else interleaves
// alphabetically below.
const makeAssetSection = (
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 buildSection = (id: string): Section | null => {
if (id === PARTNER_NODES_GROUP_ID) {
if (filteredPartners.length === 0) return null
const items: SidebarItem[] = filteredPartners.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
}
}
const group = MODEL_GROUPS.find((g) => g.id === id)
if (!group) return null
return makeAssetSection(
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(
makeAssetSection(group.id, group.label, assetsByGroup.get(group.id) ?? [])
)
}
for (const tag of unmappedByTag.keys()) {
collect(
makeAssetSection(
`tag:${tag}`,
fallbackGroupLabel(tag),
unmappedByTag.get(tag) ?? []
)
)
}
pending.sort((a, b) =>
a.sortKey.localeCompare(b.sortKey, undefined, { sensitivity: 'base' })
)
for (const section of pinnedSections) result.push(section)
for (const { section } of pending) 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 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,80 @@
/**
* Maps a canonical base-model label to the category group its assets should
* land under, regardless of the asset's file-type tag. Use when a base-model
* family's companions (text encoders, VAEs, model patches, etc.) should be
* displayed alongside the base instead of scattered across encoder / vae /
* conditioning buckets.
*
* LoRAs are exempt — they always stay in the dedicated "LoRAs" group, since
* LoRA is a cross-family file format and the [[Base model]] sort axis already
* groups them by family.
*
* Family roots that span multiple modalities (e.g. bare "Qwen" can be either
* a language model or an image model) are intentionally omitted; their tags
* already classify correctly.
*/
const BASE_MODEL_CATEGORY_OVERRIDES: Readonly<Record<string, string>> =
Object.freeze({
// Audio bases
'ACE-Step': 'audio',
'Stable Audio': 'audio',
// Video & motion bases
Wan: 'video',
'Wan 2.1': 'video',
'Wan 2.2': 'video',
HunyuanVideo: 'video',
'HunyuanVideo 1.5': 'video',
'LTX Video': 'video',
'LTX 2': 'video',
'LTX 2.3': 'video',
CogVideo: 'video',
Mochi: 'video',
Cosmos: 'video',
HuMo: 'video',
AnimateDiff: 'video',
// Image diffusion bases — encoders/VAEs/checkpoints stay with the base
'Flux.1 dev': 'diffusion',
'Flux.1 Krea': 'diffusion',
'Flux.1 Kontext': 'diffusion',
'Flux.1 Redux': 'diffusion',
'Flux.1 Schnell': 'diffusion',
'Flux.2 dev': 'diffusion',
'Flux.2 Klein': 'diffusion',
'SD 1.5': 'diffusion',
'SD 2': 'diffusion',
'SD 2.1': 'diffusion',
'SD 3': 'diffusion',
'SD 3.5': 'diffusion',
SDXL: 'diffusion',
Pony: 'diffusion',
Illustrious: 'diffusion',
Chroma: 'diffusion',
'Chroma1 HD': 'diffusion',
'Chroma1 Radiance': 'diffusion',
HiDream: 'diffusion',
'HiDream I1': 'diffusion',
'HiDream O1': 'diffusion',
'Z-Image': 'diffusion',
'Qwen Image': 'diffusion',
'Qwen Image Edit': 'diffusion',
'Hunyuan Image': 'diffusion',
Lumina: 'diffusion',
Kolors: 'diffusion',
AuraFlow: 'diffusion',
PixArt: 'diffusion',
Kandinsky: 'diffusion',
Playground: 'diffusion',
ERNIE: 'diffusion',
Omnigen: 'diffusion',
LongCat: 'diffusion',
NewBie: 'diffusion',
Ovis: 'diffusion',
UltraShape: 'diffusion',
OneReward: 'diffusion',
USO: 'diffusion',
PixelDiT: 'diffusion'
})
export function getCategoryOverrideForBase(label: string): string | null {
return BASE_MODEL_CATEGORY_OVERRIDES[label] ?? null
}

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,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,215 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
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, group.id)
}
return map
})()
/**
* Maps a raw asset category tag (e.g. "loras", "sam3d") to a group id.
* 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) ?? 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')
}
export function fallbackGroupLabel(rawTag: string): string {
return formatCategoryLabel(rawTag)
}
/**
* 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,122 @@
import { describe, expect, it } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
firstNonModelsTag,
groupIdForAsset,
groupLabelForAsset,
looksLikeVae,
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('looksLikeVae', () => {
it('matches a "vae" path segment in the tag', () => {
expect(looksLikeVae(makeAsset(), 'CogVideo/VAE')).toBe(true)
expect(looksLikeVae(makeAsset(), 'foo/vae_approx')).toBe(true)
})
it('matches "vae" as a word in the filename', () => {
expect(
looksLikeVae(makeAsset({ name: 'model_vae_v1.safetensors' }), 'encoders')
).toBe(true)
})
it('does not match "vae" embedded inside another word', () => {
expect(
looksLikeVae(makeAsset({ name: 'levaeon.safetensors' }), 'encoders')
).toBe(false)
})
})
describe('groupIdForAsset', () => {
it('keeps cross-base file types (loras, vae, conditioning) in their bucket', () => {
expect(groupIdForAsset(makeAsset({ tags: ['models', 'loras'] }))).toBe(
'loras'
)
expect(groupIdForAsset(makeAsset({ tags: ['models', 'vae'] }))).toBe('vae')
expect(groupIdForAsset(makeAsset({ tags: ['models', 'controlnet'] }))).toBe(
'conditioning'
)
})
it('routes vae-looking assets to the vae bucket even when tagged otherwise', () => {
expect(
groupIdForAsset(makeAsset({ tags: ['models', 'CogVideo/VAE'] }))
).toBe('vae')
})
it('lets a base-model category override the file-type bucket', () => {
const asset = makeAsset({
tags: ['models', 'text_encoders'],
metadata: { base_model: 'SDXL' }
})
expect(groupIdForAsset(asset)).toBe('diffusion')
})
it('falls back to the tag-derived group when no base override applies', () => {
expect(
groupIdForAsset(makeAsset({ tags: ['models', 'text_encoders'] }))
).toBe('encoders')
})
it('returns null for an unmapped tag with no resolvable base', () => {
expect(
groupIdForAsset(makeAsset({ tags: ['models', 'totallyunknown'] }))
).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 a formatted label for an unmapped tag', () => {
expect(
groupLabelForAsset(makeAsset({ tags: ['models', 'totallyunknown'] }))
).toBe('Totallyunknown')
})
})

View File

@@ -0,0 +1,88 @@
import { getCategoryOverrideForBase } from '@/components/sidebar/tabs/cloudModelLibrary/baseModelCategoryOverrides'
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'
import { getAssetBaseModels } from '@/platform/assets/utils/assetMetadataUtils'
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
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]
}
export function groupLabelForAsset(asset: AssetItem): string {
const groupId = groupIdForAsset(asset)
if (groupId) {
const group = MODEL_GROUPS.find((g) => g.id === groupId)
if (group) return group.label
}
const tag = firstNonModelsTag(asset)
return tag ? formatCategoryLabel(rawTagTopLevel(tag)) : ''
}
export function partnerKind(category: string | undefined): string {
if (!category) return ''
const parts = category.split('/')
return parts[1] ?? ''
}
export function groupIdForAsset(asset: AssetItem): string | null {
const tag = firstNonModelsTag(asset)
if (!tag) return null
const tagGroup = groupIdForRawTag(rawTagTopLevel(tag))
// Cross-base file-types stay in their type bucket. The Base-model sort
// axis still keeps each family's items grouped together within that bucket.
if (
tagGroup === 'loras' ||
tagGroup === 'vae' ||
tagGroup === 'conditioning'
) {
return tagGroup
}
// Filename-based VAE detection: any file with "vae" in any path segment of
// its tag, name, or filepath belongs in the VAE bucket — catches assets
// tagged generically (`latentsync/vae`, `CogVideo/VAE`, `SEEDVR2`) or named
// `*_vae_*` but tagged as something else.
if (looksLikeVae(asset, tag)) return 'vae'
// For everything else, let the resolved base model's primary category
// override the file-type-derived bucket — keeps a family's text encoders
// and checkpoints visible together rather than scattered.
const bases = getAssetBaseModels(asset)
for (const base of bases) {
const override = getCategoryOverrideForBase(base)
if (override) return override
}
return tagGroup
}
export function looksLikeVae(asset: AssetItem, tag: string): boolean {
// Any path segment of the tag containing "vae" (handles `latentsync/vae`,
// `CogVideo/VAE`, etc.)
for (const segment of tag.split('/')) {
if (/^vae(_approx)?$/i.test(segment)) return true
}
// "vae" appearing as a word in the filename / display name
const sources = [
asset.name,
typeof asset.metadata?.filename === 'string'
? asset.metadata.filename
: undefined,
typeof asset.metadata?.filepath === 'string'
? asset.metadata.filepath
: undefined
]
for (const source of sources) {
if (typeof source !== 'string') continue
if (/(?:^|[^a-zA-Z0-9])vae(?:[^a-zA-Z0-9]|$)/i.test(source)) return true
}
return false
}

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,124 @@
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
}
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,44 @@
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 { isCloud } from '@/platform/distribution/types'
import { useAssetsStore } from '@/stores/assetsStore'
import { useLocalModelLibrarySource } from './useLocalModelLibrarySource'
// Unified Model Library data source. The cloud distribution reads from the
// assets API via useAssetsStore; desktop and localhost distributions enumerate
// the on-disk models folder. Consumers see the same AssetItem[] shape either
// way so the sidebar component renders without branching on mode.
export interface ModelLibrarySource {
assets: ComputedRef<AssetItem[]>
isLoading: ComputedRef<boolean> | Ref<boolean>
refresh: () => Promise<void>
}
const CLOUD_CACHE_KEY = `tag:${MODELS_TAG}`
export function useModelLibrarySource(): ModelLibrarySource {
if (!isCloud) {
return useLocalModelLibrarySource()
}
const assetsStore = useAssetsStore()
async function refresh(): Promise<void> {
await assetsStore.updateModelsForTag(MODELS_TAG)
}
const assets = computed<AssetItem[]>(() =>
assetsStore.getAssets(CLOUD_CACHE_KEY)
)
const isLoading = computed(
() =>
assetsStore.isModelLoading(CLOUD_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

@@ -2765,14 +2765,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": {
@@ -3014,6 +3015,34 @@
"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": {
"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"
},
"cloudModelLibrary": {
"preview": {
"createsNode": "Creates node",
"triggerWords": "Trigger words",
"description": "Description",
"nodePreview": "Node preview",
"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",
@@ -3107,6 +3136,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

@@ -89,12 +89,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

@@ -390,17 +390,20 @@ function createAssetService() {
/**
* 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,7 @@
import { computed } from 'vue'
import { assetService } from '@/platform/assets/services/assetService'
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 +116,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 +130,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 ? 'list' : 'list-small'
})
</script>

View File

@@ -1,7 +1,8 @@
<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 { 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 +14,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'
@@ -145,6 +150,32 @@ const acceptTypes = computed(() => {
const layoutMode = ref<LayoutMode>(props.defaultLayoutMode ?? 'grid')
const isModel = computed(() => props.assetKind === 'model')
// 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 (isCloud) 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 ? (isCloud ? 'base-model-asc' : 'name-asc') : 'default'
)
// 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) {
void outputMediaAssets.refresh()
@@ -157,6 +188,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 +199,12 @@ function handleIsOpenUpdate(isOpen: boolean) {
:uploadable
:accept="acceptTypes"
:filter-options
:show-ownership-filter
:sort-options="sortOptions"
:show-ownership-filter="isCloud && showOwnershipFilter"
:ownership-options
:show-base-model-filter
:show-base-model-filter="isCloud && 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

@@ -37,3 +37,27 @@ export function getDefaultSortOptions(): SortOption<AssetSortOption>[] {
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,10 @@ 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
}
export interface SortOption<TId extends string = string> {
@@ -48,6 +52,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,7 @@
import { computed, toValue, watch } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { useLocalModelLibrarySource } from '@/composables/sidebarTabs/useLocalModelLibrarySource'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isCloud } from '@/platform/distribution/types'
import { useAssetsStore } from '@/stores/assetsStore'
@@ -11,7 +12,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'
@@ -71,10 +73,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

@@ -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', () => {

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'
@@ -48,6 +53,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',
@@ -292,13 +302,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
}))
})

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',
() => ({

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'
@@ -73,19 +73,6 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
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
}
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