mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-11 00:38:37 +00:00
Compare commits
9 Commits
uy/node-se
...
synap5e/in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9649228bd | ||
|
|
bf481f2c71 | ||
|
|
45fef1d89c | ||
|
|
cf996d8e39 | ||
|
|
ebf70db149 | ||
|
|
6c0c603576 | ||
|
|
a13d6cf99e | ||
|
|
c7873ac7ed | ||
|
|
87625d852b |
@@ -1,188 +0,0 @@
|
||||
<template>
|
||||
<ContextMenu
|
||||
ref="menu"
|
||||
:model="items"
|
||||
class="max-h-[80vh] overflow-y-auto md:max-h-none md:overflow-y-visible"
|
||||
:pt="{
|
||||
item: ({ context }) =>
|
||||
context.item.isSearch || context.item.isGroupLabel
|
||||
? { class: searchItemSurfaceClass }
|
||||
: undefined,
|
||||
itemContent: ({ context }) =>
|
||||
context.item.isSearch || context.item.isGroupLabel
|
||||
? { class: searchItemSurfaceClass }
|
||||
: undefined
|
||||
}"
|
||||
@hide="onHide"
|
||||
>
|
||||
<template #item="{ item, props, hasSubmenu }">
|
||||
<span
|
||||
v-if="item.isHeader"
|
||||
class="block truncate px-3 py-1.5 text-xs font-medium text-muted-foreground uppercase"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="item.isGroupLabel"
|
||||
class="block truncate px-3 pt-1 pb-0.5 text-xs font-medium text-muted-foreground uppercase"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<div
|
||||
v-else-if="item.isSearch"
|
||||
class="px-1 py-1.5"
|
||||
@click.stop
|
||||
@keydown.capture="onSearchKeydown"
|
||||
>
|
||||
<SearchInput
|
||||
ref="searchInput"
|
||||
v-model="query"
|
||||
size="md"
|
||||
:placeholder="t('contextMenu.Search')"
|
||||
:debounce-time="0"
|
||||
/>
|
||||
</div>
|
||||
<a
|
||||
v-else
|
||||
v-bind="props.action"
|
||||
class="flex items-center gap-2 px-3 py-1.5"
|
||||
>
|
||||
<i v-if="item.icon" :class="cn(item.icon, 'size-4')" />
|
||||
<span class="flex-1 truncate">{{ item.label }}</span>
|
||||
<i
|
||||
v-if="hasSubmenu"
|
||||
class="icon-[lucide--chevron-right] size-4 opacity-60"
|
||||
/>
|
||||
</a>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
import { buildLinkReleaseMenuItems } from './linkReleaseMenuModel'
|
||||
import type { LinkReleaseContext } from './linkReleaseMenuModel'
|
||||
|
||||
const { context } = defineProps<{ context: LinkReleaseContext | null }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectNode: [nodeDef: ComfyNodeDefImpl]
|
||||
addReroute: []
|
||||
dismiss: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
const menu = ref<InstanceType<typeof ContextMenu>>()
|
||||
const searchInput = ref<InstanceType<typeof SearchInput>>()
|
||||
const query = ref('')
|
||||
let actionTaken = false
|
||||
|
||||
const searchItemSurfaceClass =
|
||||
'bg-interface-menu-surface hover:bg-interface-menu-surface focus:bg-interface-menu-surface data-[p-focused=true]:bg-interface-menu-surface'
|
||||
|
||||
const defaultNodeDefs = computed<ComfyNodeDefImpl[]>(() => {
|
||||
if (!context?.dataType) return []
|
||||
const table = context.isFromOutput
|
||||
? LiteGraph.slot_types_default_out
|
||||
: LiteGraph.slot_types_default_in
|
||||
const types = table?.[context.dataType] ?? []
|
||||
return types
|
||||
.map((type) => nodeDefStore.allNodeDefsByName[type])
|
||||
.filter((nodeDef): nodeDef is ComfyNodeDefImpl => Boolean(nodeDef))
|
||||
})
|
||||
|
||||
const typeFilter = computed(() => {
|
||||
if (!context) return null
|
||||
const svc = nodeDefStore.nodeSearchService
|
||||
return {
|
||||
filterDef: context.isFromOutput
|
||||
? svc.inputTypeFilter
|
||||
: svc.outputTypeFilter,
|
||||
value: context.dataType
|
||||
}
|
||||
})
|
||||
|
||||
const compatibleNodes = computed<ComfyNodeDefImpl[]>(() => {
|
||||
if (!typeFilter.value) return []
|
||||
return nodeDefStore.nodeSearchService.searchNode('', [typeFilter.value], {
|
||||
limit: 500
|
||||
})
|
||||
})
|
||||
|
||||
const searchResults = computed<ComfyNodeDefImpl[]>(() => {
|
||||
const q = query.value.trim()
|
||||
if (!q || !typeFilter.value) return []
|
||||
return nodeDefStore.nodeSearchService.searchNode(q, [typeFilter.value], {
|
||||
limit: 20
|
||||
})
|
||||
})
|
||||
|
||||
function selectNode(nodeDef: ComfyNodeDefImpl) {
|
||||
actionTaken = true
|
||||
emit('selectNode', nodeDef)
|
||||
hide()
|
||||
}
|
||||
|
||||
function addReroute() {
|
||||
actionTaken = true
|
||||
emit('addReroute')
|
||||
hide()
|
||||
}
|
||||
|
||||
const items = computed<MenuItem[]>(() =>
|
||||
context
|
||||
? buildLinkReleaseMenuItems({
|
||||
context,
|
||||
compatibleNodes: compatibleNodes.value,
|
||||
defaultNodeDefs: defaultNodeDefs.value,
|
||||
query: query.value,
|
||||
searchResults: searchResults.value,
|
||||
t,
|
||||
handlers: { selectNode, addReroute }
|
||||
})
|
||||
: []
|
||||
)
|
||||
|
||||
function onSearchKeydown(event: KeyboardEvent) {
|
||||
event.stopPropagation()
|
||||
if (event.key === 'Enter') {
|
||||
const first = searchResults.value[0]
|
||||
if (first) selectNode(first)
|
||||
} else if (event.key === 'Escape') {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
function show(event: MouseEvent) {
|
||||
actionTaken = false
|
||||
query.value = ''
|
||||
menu.value?.show(event)
|
||||
requestAnimationFrame(() => searchInput.value?.focus())
|
||||
}
|
||||
|
||||
function hide() {
|
||||
menu.value?.hide()
|
||||
}
|
||||
|
||||
function onHide() {
|
||||
if (actionTaken) {
|
||||
actionTaken = false
|
||||
return
|
||||
}
|
||||
emit('dismiss')
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -52,13 +52,6 @@
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
<LinkReleaseContextMenu
|
||||
ref="linkReleaseMenu"
|
||||
:context="linkReleaseContext"
|
||||
@select-node="connectNodeFromMenu"
|
||||
@add-reroute="addRerouteFromMenu"
|
||||
@dismiss="reset"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -70,11 +63,7 @@ import { computed, ref, toRaw, watch, watchEffect } from 'vue'
|
||||
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LiteGraphCanvasEvent } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
isNodeSlot
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
|
||||
@@ -92,12 +81,11 @@ import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
|
||||
|
||||
import LinkReleaseContextMenu from './LinkReleaseContextMenu.vue'
|
||||
import type { LinkReleaseContext } from './linkReleaseMenuModel'
|
||||
import NodeSearchContent from './v2/NodeSearchContent.vue'
|
||||
import NodeSearchBox from './NodeSearchBox.vue'
|
||||
|
||||
let triggerEvent: CanvasPointerEvent | null = null
|
||||
let listenerController: AbortController | null = null
|
||||
let disconnectOnReset = false
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
@@ -120,8 +108,6 @@ const enableNodePreview = computed(
|
||||
windowWidth.value >= MIN_WIDTH_FOR_PREVIEW
|
||||
)
|
||||
const defaultRootFilter = ref<RootCategoryId | null>(null)
|
||||
const linkReleaseMenu = ref<InstanceType<typeof LinkReleaseContextMenu>>()
|
||||
const linkReleaseContext = ref<LinkReleaseContext | null>(null)
|
||||
watch(visible, (isVisible) => {
|
||||
if (!isVisible) return
|
||||
defaultRootFilter.value = !canvasStore.canvas?.graph?.nodes?.length
|
||||
@@ -153,16 +139,13 @@ function closeDialog() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function connectNewNode(
|
||||
nodeDef: ComfyNodeDefImpl,
|
||||
options: { ghost?: boolean; dragEvent?: MouseEvent } = {}
|
||||
) {
|
||||
const { ghost = false, dragEvent } = options
|
||||
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
|
||||
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
|
||||
const node = withNodeAddSource('search_modal', () =>
|
||||
litegraphService.addNodeOnGraph(
|
||||
nodeDef,
|
||||
{ pos: getNewNodeLocation() },
|
||||
{ ghost, dragEvent }
|
||||
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
|
||||
)
|
||||
)
|
||||
if (!node) return
|
||||
@@ -177,14 +160,6 @@ function connectNewNode(
|
||||
|
||||
// Notify changeTracker - new step should be added
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
|
||||
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
|
||||
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
|
||||
connectNewNode(nodeDef, {
|
||||
ghost: useSearchBoxV2.value && followCursor,
|
||||
dragEvent
|
||||
})
|
||||
window.requestAnimationFrame(closeDialog)
|
||||
}
|
||||
|
||||
@@ -237,38 +212,62 @@ function showContextMenu(e: CanvasPointerEvent) {
|
||||
const firstLink = getFirstLink()
|
||||
if (!firstLink) return
|
||||
|
||||
const { fromSlot, toType } = firstLink
|
||||
linkReleaseContext.value = {
|
||||
dataType: fromSlot.type?.toString() ?? '',
|
||||
slotName: fromSlot.name ?? '',
|
||||
isFromOutput: toType === 'input'
|
||||
const { node, fromSlot, toType } = firstLink
|
||||
const commonOptions = {
|
||||
e,
|
||||
allow_searchbox: true,
|
||||
showSearchBox: () => {
|
||||
cancelResetOnContextClose()
|
||||
showSearchBox(e)
|
||||
}
|
||||
}
|
||||
const afterRerouteId = firstLink.fromReroute?.id
|
||||
const connectionOptions =
|
||||
toType === 'input'
|
||||
? { nodeFrom: node, slotFrom: fromSlot, afterRerouteId }
|
||||
: { nodeTo: node, slotTo: fromSlot, afterRerouteId }
|
||||
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const menu = canvas.showConnectionMenu({
|
||||
...connectionOptions,
|
||||
...commonOptions
|
||||
})
|
||||
|
||||
if (!menu) {
|
||||
console.warn('No menu was returned from showConnectionMenu')
|
||||
return
|
||||
}
|
||||
|
||||
triggerEvent = e
|
||||
linkReleaseMenu.value?.show(e)
|
||||
}
|
||||
listenerController = new AbortController()
|
||||
const { signal } = listenerController
|
||||
const options = { once: true, signal }
|
||||
|
||||
function connectNodeFromMenu(nodeDef: ComfyNodeDefImpl) {
|
||||
connectNewNode(nodeDef)
|
||||
reset()
|
||||
}
|
||||
// Connect the node after it is created via context menu
|
||||
useEventListener(
|
||||
canvas.canvas,
|
||||
'connect-new-default-node',
|
||||
(createEvent) => {
|
||||
if (!(createEvent instanceof CustomEvent))
|
||||
throw new Error('Invalid event')
|
||||
|
||||
function addRerouteFromMenu() {
|
||||
const firstLink = getFirstLink()
|
||||
const node = firstLink?.node
|
||||
if (
|
||||
firstLink &&
|
||||
triggerEvent &&
|
||||
node instanceof LGraphNode &&
|
||||
isNodeSlot(firstLink.fromSlot)
|
||||
) {
|
||||
node.connectFloatingReroute(
|
||||
[triggerEvent.canvasX, triggerEvent.canvasY],
|
||||
firstLink.fromSlot,
|
||||
firstLink.fromReroute?.id
|
||||
)
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
reset()
|
||||
const node: unknown = createEvent.detail?.node
|
||||
if (!(node instanceof LGraphNode)) throw new Error('Invalid node')
|
||||
|
||||
disconnectOnReset = false
|
||||
createEvent.preventDefault()
|
||||
canvas.linkConnector.connectToNode(node, e)
|
||||
},
|
||||
options
|
||||
)
|
||||
|
||||
// Reset when the context menu is closed
|
||||
const cancelResetOnContextClose = useEventListener(
|
||||
menu.controller.signal,
|
||||
'abort',
|
||||
reset,
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
// Disable litegraph's default behavior of release link and search box.
|
||||
@@ -344,6 +343,8 @@ function handleDroppedOnCanvas(e: CustomEvent<CanvasPointerEvent>) {
|
||||
|
||||
// Resets litegraph state
|
||||
function reset() {
|
||||
listenerController?.abort()
|
||||
listenerController = null
|
||||
triggerEvent = null
|
||||
|
||||
const canvas = canvasStore.getCanvas()
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
|
||||
import {
|
||||
buildLinkReleaseMenuItems,
|
||||
getLinkReleaseHeaderLabel
|
||||
} from './linkReleaseMenuModel'
|
||||
import type {
|
||||
LinkReleaseContext,
|
||||
LinkReleaseMenuHandlers
|
||||
} from './linkReleaseMenuModel'
|
||||
|
||||
function coreNode(name: string, display_name = name): ComfyNodeDefImpl {
|
||||
return {
|
||||
name,
|
||||
display_name,
|
||||
nodeSource: { type: NodeSourceType.Core },
|
||||
api_node: false
|
||||
} as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
function customNode(name: string, display_name = name): ComfyNodeDefImpl {
|
||||
return {
|
||||
name,
|
||||
display_name,
|
||||
nodeSource: { type: NodeSourceType.CustomNodes },
|
||||
api_node: false
|
||||
} as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
function partnerNode(name: string, display_name = name): ComfyNodeDefImpl {
|
||||
return {
|
||||
name,
|
||||
display_name,
|
||||
nodeSource: { type: NodeSourceType.Core },
|
||||
api_node: true
|
||||
} as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
const ksampler = coreNode('KSampler')
|
||||
const vaeDecode = coreNode('VAEDecode', 'VAE Decode')
|
||||
const rerouteNode = coreNode('Reroute')
|
||||
|
||||
function createContext(
|
||||
overrides: Partial<LinkReleaseContext> = {}
|
||||
): LinkReleaseContext {
|
||||
return {
|
||||
dataType: 'MODEL',
|
||||
slotName: 'model',
|
||||
isFromOutput: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createHandlers(): LinkReleaseMenuHandlers {
|
||||
return {
|
||||
selectNode: vi.fn(),
|
||||
addReroute: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
const identityT = (key: string) => key
|
||||
|
||||
const labelOf = (item: MenuItem) => item.label
|
||||
|
||||
describe('getLinkReleaseHeaderLabel', () => {
|
||||
it('combines slot name and data type', () => {
|
||||
const label = getLinkReleaseHeaderLabel(
|
||||
createContext({ slotName: 'model', dataType: 'MODEL' })
|
||||
)
|
||||
expect(label).toBe('model | MODEL')
|
||||
})
|
||||
|
||||
it('falls back to whichever value is present', () => {
|
||||
const onlyType = getLinkReleaseHeaderLabel(
|
||||
createContext({ slotName: '', dataType: 'IMAGE' })
|
||||
)
|
||||
const onlyName = getLinkReleaseHeaderLabel(
|
||||
createContext({ slotName: 'clip', dataType: '' })
|
||||
)
|
||||
expect(onlyType).toBe('IMAGE')
|
||||
expect(onlyName).toBe('clip')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildLinkReleaseMenuItems', () => {
|
||||
function build(
|
||||
options: {
|
||||
context?: LinkReleaseContext
|
||||
compatibleNodes?: ComfyNodeDefImpl[]
|
||||
defaultNodeDefs?: ComfyNodeDefImpl[]
|
||||
query?: string
|
||||
searchResults?: ComfyNodeDefImpl[]
|
||||
handlers?: LinkReleaseMenuHandlers
|
||||
} = {}
|
||||
) {
|
||||
const handlers = options.handlers ?? createHandlers()
|
||||
const items = buildLinkReleaseMenuItems({
|
||||
context: options.context ?? createContext(),
|
||||
compatibleNodes: options.compatibleNodes ?? [],
|
||||
defaultNodeDefs: options.defaultNodeDefs ?? [],
|
||||
query: options.query ?? '',
|
||||
searchResults: options.searchResults ?? [],
|
||||
t: identityT,
|
||||
handlers
|
||||
})
|
||||
return { items, handlers }
|
||||
}
|
||||
|
||||
it('renders a disabled slot-type header as the first entry', () => {
|
||||
const { items } = build()
|
||||
expect(items[0]).toMatchObject({
|
||||
label: 'model | MODEL',
|
||||
isHeader: true,
|
||||
disabled: true
|
||||
})
|
||||
})
|
||||
|
||||
it('renders a separator then search field after the header', () => {
|
||||
const { items } = build()
|
||||
expect(items[1]).toMatchObject({ separator: true })
|
||||
expect(items[2]).toMatchObject({ isSearch: true })
|
||||
expect(items[3]).toMatchObject({ separator: true })
|
||||
})
|
||||
|
||||
it('always has Add Reroute as the last item', () => {
|
||||
const { items } = build()
|
||||
expect(items.at(-1)?.label).toBe('contextMenu.Add Reroute')
|
||||
})
|
||||
|
||||
it('Add Reroute remains last when query is non-empty', () => {
|
||||
const { items } = build({ query: 'ksampler', searchResults: [ksampler] })
|
||||
expect(items.at(-1)?.label).toBe('contextMenu.Add Reroute')
|
||||
})
|
||||
|
||||
it('groups Reroute node def immediately before Add Reroute', () => {
|
||||
const { items, handlers } = build({
|
||||
defaultNodeDefs: [vaeDecode, rerouteNode]
|
||||
})
|
||||
expect(items.at(-1)?.label).toBe('contextMenu.Add Reroute')
|
||||
expect(items.at(-2)?.label).toBe('Reroute')
|
||||
expect(items.at(-3)?.separator).toBe(true)
|
||||
|
||||
items.at(-2)?.command?.({} as never)
|
||||
expect(handlers.selectNode).toHaveBeenCalledWith(rerouteNode)
|
||||
})
|
||||
|
||||
it('excludes Reroute node def from the suggestions section', () => {
|
||||
const { items } = build({ defaultNodeDefs: [rerouteNode, vaeDecode] })
|
||||
const addRerouteIdx = items.findIndex(
|
||||
(i) => i.label === 'contextMenu.Add Reroute'
|
||||
)
|
||||
const rerouteNodeIdx = items.findIndex((i) => i.label === 'Reroute')
|
||||
expect(rerouteNodeIdx).toBeGreaterThan(0)
|
||||
expect(rerouteNodeIdx).toBeLessThan(addRerouteIdx)
|
||||
expect(items.at(-2)?.label).toBe('Reroute')
|
||||
})
|
||||
|
||||
it('groups compatible nodes by source under Comfy Nodes, Extensions, Partner Nodes', () => {
|
||||
const ext = customNode('ExtNode', 'Ext Node')
|
||||
const partner = partnerNode('PartnerNode', 'Partner Node')
|
||||
|
||||
const { items, handlers } = build({
|
||||
compatibleNodes: [ksampler, ext, partner]
|
||||
})
|
||||
|
||||
const comfyGroup = items.find((i) => i.label === 'contextMenu.Comfy Nodes')
|
||||
const extGroup = items.find((i) => i.label === 'contextMenu.Extensions')
|
||||
const partnerGroup = items.find(
|
||||
(i) => i.label === 'contextMenu.Partner Nodes'
|
||||
)
|
||||
|
||||
expect(comfyGroup?.items?.map((i) => i.label)).toContain('KSampler')
|
||||
expect(extGroup?.items?.map((i) => i.label)).toContain('Ext Node')
|
||||
expect(partnerGroup?.items?.map((i) => i.label)).toContain('Partner Node')
|
||||
|
||||
comfyGroup?.items
|
||||
?.find((i) => i.label === 'KSampler')
|
||||
?.command?.({} as never)
|
||||
expect(handlers.selectNode).toHaveBeenCalledWith(ksampler)
|
||||
})
|
||||
|
||||
it('omits empty source groups', () => {
|
||||
const { items } = build({ compatibleNodes: [ksampler] })
|
||||
const labels = items.map(labelOf)
|
||||
expect(labels).toContain('contextMenu.Comfy Nodes')
|
||||
expect(labels).not.toContain('contextMenu.Extensions')
|
||||
expect(labels).not.toContain('contextMenu.Partner Nodes')
|
||||
})
|
||||
|
||||
it('sorts nodes alphabetically within each group', () => {
|
||||
const nodeB = coreNode('B')
|
||||
const nodeA = coreNode('A')
|
||||
const { items } = build({ compatibleNodes: [nodeB, nodeA] })
|
||||
const comfyGroup = items.find((i) => i.label === 'contextMenu.Comfy Nodes')
|
||||
expect(comfyGroup?.items?.[0]?.label).toBe('A')
|
||||
expect(comfyGroup?.items?.[1]?.label).toBe('B')
|
||||
})
|
||||
|
||||
it('wires Add Reroute to its handler', () => {
|
||||
const { items, handlers } = build()
|
||||
items
|
||||
.find((i) => i.label === 'contextMenu.Add Reroute')
|
||||
?.command?.({} as never)
|
||||
expect(handlers.addReroute).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('lists suggestions before compatible node groups', () => {
|
||||
const { items, handlers } = build({
|
||||
defaultNodeDefs: [vaeDecode],
|
||||
compatibleNodes: [ksampler]
|
||||
})
|
||||
const suggestionIdx = items.findIndex((i) => i.label === 'VAE Decode')
|
||||
const comfyGroupIdx = items.findIndex(
|
||||
(i) => i.label === 'contextMenu.Comfy Nodes'
|
||||
)
|
||||
const rerouteIdx = items.findIndex(
|
||||
(i) => i.label === 'contextMenu.Add Reroute'
|
||||
)
|
||||
|
||||
expect(suggestionIdx).toBeGreaterThan(0)
|
||||
expect(suggestionIdx).toBeLessThan(comfyGroupIdx)
|
||||
expect(comfyGroupIdx).toBeLessThan(rerouteIdx)
|
||||
|
||||
items[suggestionIdx].command?.({} as never)
|
||||
expect(handlers.selectNode).toHaveBeenCalledWith(vaeDecode)
|
||||
})
|
||||
|
||||
it('has 3 separators with no compatible nodes, 4 with', () => {
|
||||
const { items: noCompat } = build({ compatibleNodes: [] })
|
||||
expect(noCompat.filter((i) => i.separator).length).toBe(3)
|
||||
|
||||
const { items: withCompat } = build({ compatibleNodes: [ksampler] })
|
||||
expect(withCompat.filter((i) => i.separator).length).toBe(4)
|
||||
})
|
||||
|
||||
it('shows search results when query is non-empty', () => {
|
||||
const { items, handlers } = build({
|
||||
query: 'ksampler',
|
||||
searchResults: [ksampler]
|
||||
})
|
||||
const labels = items.map(labelOf)
|
||||
expect(labels).not.toContain('contextMenu.Comfy Nodes')
|
||||
expect(labels).toContain('KSampler')
|
||||
|
||||
items.find((i) => i.label === 'KSampler')?.command?.({} as never)
|
||||
expect(handlers.selectNode).toHaveBeenCalledWith(ksampler)
|
||||
})
|
||||
|
||||
it('shows a disabled no-results row when query has no matches', () => {
|
||||
const { items } = build({ query: 'nonexistent', searchResults: [] })
|
||||
const noResults = items.find((i) => i.label === 'g.noResults')
|
||||
expect(noResults).toMatchObject({ disabled: true })
|
||||
})
|
||||
})
|
||||
@@ -1,208 +0,0 @@
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
|
||||
export interface LinkReleaseContext {
|
||||
/** The data type of the slot the link was dragged from (e.g. "MODEL"). */
|
||||
dataType: string
|
||||
/** The name of the slot the link was dragged from (e.g. "model"). */
|
||||
slotName: string
|
||||
/**
|
||||
* Whether the released link originates from an output slot, meaning the new
|
||||
* node will be connected to via one of its inputs.
|
||||
*/
|
||||
isFromOutput: boolean
|
||||
}
|
||||
|
||||
declare module 'primevue/menuitem' {
|
||||
interface MenuItem {
|
||||
isHeader?: boolean
|
||||
isSearch?: boolean
|
||||
isGroupLabel?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface LinkReleaseMenuHandlers {
|
||||
selectNode: (nodeDef: ComfyNodeDefImpl) => void
|
||||
addReroute: () => void
|
||||
}
|
||||
|
||||
export interface LinkReleaseMenuModelOptions {
|
||||
context: LinkReleaseContext
|
||||
/** All nodes compatible with the slot type, for grouping into source buckets. */
|
||||
compatibleNodes: ComfyNodeDefImpl[]
|
||||
/** Quick-add node suggestions for the released slot type. */
|
||||
defaultNodeDefs: ComfyNodeDefImpl[]
|
||||
/** Current search field value. */
|
||||
query: string
|
||||
/** Slot-type-filtered search results when query is non-empty. */
|
||||
searchResults: ComfyNodeDefImpl[]
|
||||
t: (key: string) => string
|
||||
handlers: LinkReleaseMenuHandlers
|
||||
}
|
||||
|
||||
export function getLinkReleaseHeaderLabel(context: LinkReleaseContext): string {
|
||||
const { slotName, dataType } = context
|
||||
if (slotName && dataType) return `${slotName} | ${dataType}`
|
||||
return slotName || dataType
|
||||
}
|
||||
|
||||
function classifyNodes(nodes: ComfyNodeDefImpl[]): {
|
||||
comfy: ComfyNodeDefImpl[]
|
||||
extensions: ComfyNodeDefImpl[]
|
||||
partner: ComfyNodeDefImpl[]
|
||||
} {
|
||||
const comfy: ComfyNodeDefImpl[] = []
|
||||
const extensions: ComfyNodeDefImpl[] = []
|
||||
const partner: ComfyNodeDefImpl[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.api_node || node.category?.startsWith('api node')) {
|
||||
partner.push(node)
|
||||
} else if (
|
||||
node.nodeSource.type === NodeSourceType.Core ||
|
||||
node.nodeSource.type === NodeSourceType.Essentials
|
||||
) {
|
||||
comfy.push(node)
|
||||
} else {
|
||||
extensions.push(node)
|
||||
}
|
||||
}
|
||||
|
||||
return { comfy, extensions, partner }
|
||||
}
|
||||
|
||||
function toNodeGroupItem(
|
||||
label: string,
|
||||
nodes: ComfyNodeDefImpl[],
|
||||
selectNode: (nodeDef: ComfyNodeDefImpl) => void
|
||||
): MenuItem | null {
|
||||
if (!nodes.length) return null
|
||||
const sorted = [...nodes].sort((a, b) =>
|
||||
a.display_name.localeCompare(b.display_name)
|
||||
)
|
||||
return {
|
||||
label,
|
||||
items: sorted.map((nodeDef) => ({
|
||||
label: nodeDef.display_name,
|
||||
command: () => selectNode(nodeDef)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function buildAddRerouteItem(
|
||||
t: (key: string) => string,
|
||||
handlers: LinkReleaseMenuHandlers
|
||||
): MenuItem {
|
||||
return {
|
||||
label: t('contextMenu.Add Reroute'),
|
||||
icon: 'icon-[lucide--git-fork]',
|
||||
command: handlers.addReroute
|
||||
}
|
||||
}
|
||||
|
||||
function buildDefaultMenuItems(
|
||||
suggestions: ComfyNodeDefImpl[],
|
||||
compatibleNodes: ComfyNodeDefImpl[],
|
||||
t: (key: string) => string,
|
||||
handlers: LinkReleaseMenuHandlers
|
||||
): MenuItem[] {
|
||||
const items: MenuItem[] = []
|
||||
|
||||
if (suggestions.length) {
|
||||
items.push({ label: t('contextMenu.Most Relevant'), isGroupLabel: true })
|
||||
for (const nodeDef of suggestions) {
|
||||
items.push({
|
||||
label: nodeDef.display_name,
|
||||
command: () => handlers.selectNode(nodeDef)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const { comfy, extensions, partner } = classifyNodes(compatibleNodes)
|
||||
const groups = [
|
||||
toNodeGroupItem(t('contextMenu.Comfy Nodes'), comfy, handlers.selectNode),
|
||||
toNodeGroupItem(
|
||||
t('contextMenu.Extensions'),
|
||||
extensions,
|
||||
handlers.selectNode
|
||||
),
|
||||
toNodeGroupItem(
|
||||
t('contextMenu.Partner Nodes'),
|
||||
partner,
|
||||
handlers.selectNode
|
||||
)
|
||||
].filter((g): g is MenuItem => g !== null)
|
||||
|
||||
if (groups.length) {
|
||||
items.push({ separator: true }, ...groups)
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
function buildSearchResultItems(
|
||||
searchResults: ComfyNodeDefImpl[],
|
||||
noResultsLabel: string,
|
||||
selectNode: (nodeDef: ComfyNodeDefImpl) => void
|
||||
): MenuItem[] {
|
||||
if (!searchResults.length) {
|
||||
return [{ label: noResultsLabel, disabled: true }]
|
||||
}
|
||||
return searchResults.map((nodeDef) => ({
|
||||
label: nodeDef.display_name,
|
||||
command: () => selectNode(nodeDef)
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildLinkReleaseMenuItems({
|
||||
context,
|
||||
compatibleNodes,
|
||||
defaultNodeDefs,
|
||||
query,
|
||||
searchResults,
|
||||
t,
|
||||
handlers
|
||||
}: LinkReleaseMenuModelOptions): MenuItem[] {
|
||||
const trimmedQuery = query.trim()
|
||||
|
||||
const rerouteDef = defaultNodeDefs.find((d) => d.name === 'Reroute')
|
||||
const suggestions = defaultNodeDefs.filter((d) => d.name !== 'Reroute')
|
||||
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
label: getLinkReleaseHeaderLabel(context),
|
||||
isHeader: true,
|
||||
disabled: true
|
||||
},
|
||||
{ separator: true },
|
||||
{ isSearch: true },
|
||||
{ separator: true }
|
||||
]
|
||||
|
||||
if (trimmedQuery) {
|
||||
items.push(
|
||||
...buildSearchResultItems(
|
||||
searchResults,
|
||||
t('g.noResults'),
|
||||
handlers.selectNode
|
||||
)
|
||||
)
|
||||
} else {
|
||||
items.push(
|
||||
...buildDefaultMenuItems(suggestions, compatibleNodes, t, handlers)
|
||||
)
|
||||
}
|
||||
|
||||
items.push({ separator: true })
|
||||
if (rerouteDef) {
|
||||
items.push({
|
||||
label: rerouteDef.display_name,
|
||||
command: () => handlers.selectNode(rerouteDef)
|
||||
})
|
||||
}
|
||||
items.push(buildAddRerouteItem(t, handlers))
|
||||
|
||||
return items
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
125
src/components/sidebar/tabs/cloudModelLibrary/CloudModelLeaf.vue
Normal file
125
src/components/sidebar/tabs/cloudModelLibrary/CloudModelLeaf.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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] ?? []
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
215
src/components/sidebar/tabs/cloudModelLibrary/modelGroups.ts
Normal file
215
src/components/sidebar/tabs/cloudModelLibrary/modelGroups.ts
Normal 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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 A–Z 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', '—'])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
57
src/composables/sidebarTabs/useCategoryPlaceholder.ts
Normal file
57
src/composables/sidebarTabs/useCategoryPlaceholder.ts
Normal 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'
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
117
src/composables/sidebarTabs/useLocalModelLibrarySource.ts
Normal file
117
src/composables/sidebarTabs/useLocalModelLibrarySource.ts
Normal 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
|
||||
}
|
||||
123
src/composables/sidebarTabs/useModelLibraryHoverPopover.ts
Normal file
123
src/composables/sidebarTabs/useModelLibraryHoverPopover.ts
Normal 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
|
||||
}
|
||||
}
|
||||
42
src/composables/sidebarTabs/useModelLibraryLeaf.ts
Normal file
42
src/composables/sidebarTabs/useModelLibraryLeaf.ts
Normal 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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
44
src/composables/sidebarTabs/useModelLibrarySource.ts
Normal file
44
src/composables/sidebarTabs/useModelLibrarySource.ts
Normal 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 }
|
||||
}
|
||||
31
src/composables/sidebarTabs/useRecentlyUsedModels.ts
Normal file
31
src/composables/sidebarTabs/useRecentlyUsedModels.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -170,7 +170,6 @@ export type { TWidgetType, TWidgetValue, IWidgetOptions } from './types/widgets'
|
||||
export {
|
||||
findUsedSubgraphIds,
|
||||
getDirectSubgraphIds,
|
||||
isNodeSlot,
|
||||
isSubgraphInput,
|
||||
isSubgraphOutput
|
||||
} from './subgraph/subgraphUtils'
|
||||
|
||||
@@ -593,11 +593,6 @@
|
||||
"Bypass": "Bypass",
|
||||
"Copy (Clipspace)": "Copy (Clipspace)",
|
||||
"Add Node": "Add Node",
|
||||
"Add Reroute": "Add Reroute",
|
||||
"Most Relevant": "Most Relevant",
|
||||
"Comfy Nodes": "Comfy Nodes",
|
||||
"Extensions": "Extensions",
|
||||
"Partner Nodes": "Partner Nodes",
|
||||
"Add Group": "Add Group",
|
||||
"Manage Group Nodes": "Manage Group Nodes",
|
||||
"Add Group For Selected Nodes": "Add Group For Selected Nodes",
|
||||
@@ -2770,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": {
|
||||
@@ -3019,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 A–Z",
|
||||
"nameDesc": "Name Z–A",
|
||||
"baseModelAsc": "Base model A–Z",
|
||||
"baseModelDesc": "Base model Z–A"
|
||||
},
|
||||
"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",
|
||||
@@ -3112,6 +3136,7 @@
|
||||
"selectFrameworks": "Select Frameworks",
|
||||
"selectModelType": "Select model type",
|
||||
"selectProjects": "Select Projects",
|
||||
"recentlyUsed": "Recently used",
|
||||
"sortAZ": "A-Z",
|
||||
"sortDefault": "Default",
|
||||
"sortBy": "Sort by",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 A–Z
|
||||
// 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)"
|
||||
/>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'))
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
SubgraphNode,
|
||||
createBounds
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { overlapBounding } from '@/lib/litegraph/src/measure'
|
||||
import type {
|
||||
CreateNodeOptions,
|
||||
GraphAddOptions,
|
||||
@@ -945,39 +944,9 @@ export const useLitegraphService = () => {
|
||||
if (!graph || !node) return null
|
||||
|
||||
graph.add(node, addOptions)
|
||||
if (!addOptions?.ghost) {
|
||||
resolveOverlap(node, graph)
|
||||
centerOnNewNode(node)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
const OVERLAP_GAP = 20
|
||||
const OVERLAP_MAX_ITER = 100
|
||||
|
||||
function resolveOverlap(
|
||||
node: LGraphNode,
|
||||
graph: { nodes: LGraphNode[] }
|
||||
): void {
|
||||
node.updateArea()
|
||||
let iter = 0
|
||||
while (
|
||||
iter++ < OVERLAP_MAX_ITER &&
|
||||
graph.nodes.some(
|
||||
(n) =>
|
||||
n.id !== node.id && overlapBounding(node.boundingRect, n.boundingRect)
|
||||
)
|
||||
) {
|
||||
node.pos[1] += node.size[1] + OVERLAP_GAP
|
||||
node.updateArea()
|
||||
}
|
||||
}
|
||||
|
||||
function centerOnNewNode(node: LGraphNode): void {
|
||||
node.updateArea()
|
||||
app.canvas?.animateToBounds(node.boundingRect, { zoom: 0 })
|
||||
}
|
||||
|
||||
function getCanvasCenter(): Point {
|
||||
const dpi = Math.max(window.devicePixelRatio ?? 1, 1)
|
||||
const visibleArea = app.canvas?.ds?.visible_area
|
||||
|
||||
@@ -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',
|
||||
() => ({
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user