feat: add base model filter to FormDropdown (#8501)

## Summary

Adds a base model filter to the FormDropdown component, allowing users
to filter dropdown options by base model type.

## Changes

- **Base model filter**: New filter popover in FormDropdownMenuActions
that lets users select one or more base models to filter by
- **Clear Filters button**: Added to the base model filter popover for
quick reset
- **Button component refactor**: Replaced native `<button>` elements
with the `Button` component for consistent styling
- **New i18n key**: Added `assets.baseModel` translation

## Files Changed

- `FormDropdownMenuActions.vue` - Main implementation of base model
filter UI
- `WidgetSelectDropdown.vue` - Passes base model options to the dropdown
- `FormDropdown.vue`, `FormDropdownMenu.vue`,
`FormDropdownMenuFilter.vue` - Prop threading
- `filterTypes.ts`, `types.ts` - Type definitions for filter options

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8501-feat-add-base-model-filter-to-FormDropdown-2f96d73d3650813c994debb06070c7dd)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Alexander Brown
2026-01-30 16:28:05 -08:00
committed by GitHub
parent 98fb1ee407
commit 1f0888995a
9 changed files with 197 additions and 79 deletions

View File

@@ -2506,6 +2506,7 @@
"imported": "Imported",
"assetCollection": "Asset collection",
"assets": "Assets",
"baseModel": "Base model",
"baseModels": "Base models",
"browseAssets": "Browse Assets",
"checkpoints": "Checkpoints",

View File

@@ -3,17 +3,12 @@
* Shared across AssetBrowser, AssetFilterBar, and widget dropdowns
*/
/**
* Generic option identifier type
*/
export type OptionId = string
/**
* Generic filter/select option used across components
* Compatible with both SelectOption (name/value) and FilterOption (id/name) patterns
*/
export interface FilterOption {
id: OptionId
id: string
name: string
}

View File

@@ -5,6 +5,7 @@ import { computed, provide, ref, toRef, watch } from 'vue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import { t } from '@/i18n'
import {
getAssetBaseModels,
getAssetDisplayName,
getAssetFilename
} from '@/platform/assets/utils/assetMetadataUtils'
@@ -12,7 +13,6 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
import FormDropdown from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue'
import type {
FilterOption,
OptionId,
OwnershipOption
} from '@/platform/assets/types/filterTypes'
import { AssetKindKey } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
@@ -99,7 +99,22 @@ const ownershipOptions = computed(() => [
}
])
const selectedSet = ref<Set<OptionId>>(new Set())
const baseModelSelected = ref<Set<string>>(new Set())
const showBaseModelFilter = computed(() => props.isAssetMode)
const baseModelOptions = computed<FilterOption[]>(() => {
if (!props.isAssetMode || !assetData) return []
const models = new Set<string>()
for (const asset of assetData.assets.value) {
for (const model of getAssetBaseModels(asset)) {
models.add(model)
}
}
return Array.from(models)
.sort()
.map((model) => ({ id: model, name: model }))
})
const selectedSet = ref<Set<string>>(new Set())
/**
* Transforms a value using getOptionLabel if available.
@@ -222,7 +237,8 @@ const assetItems = computed<FormDropdownItem[]>(() => {
name: getAssetFilename(asset),
label: getAssetDisplayName(asset),
preview_url: asset.preview_url,
is_immutable: asset.is_immutable
is_immutable: asset.is_immutable,
base_models: getAssetBaseModels(asset)
}))
})
@@ -235,12 +251,23 @@ const ownershipFilteredAssetItems = computed<FormDropdownItem[]>(() => {
return assetItems.value.filter((item) => item.is_immutable === isPublic)
})
/**
* Filters asset items by base model selection.
*/
const baseModelFilteredAssetItems = computed<FormDropdownItem[]>(() => {
if (baseModelSelected.value.size === 0)
return ownershipFilteredAssetItems.value
return ownershipFilteredAssetItems.value.filter((item) =>
item.base_models?.some((model) => baseModelSelected.value.has(model))
)
})
const allItems = computed<FormDropdownItem[]>(() => {
if (props.isAssetMode && assetData) {
if (missingValueItem.value) {
return [missingValueItem.value, ...ownershipFilteredAssetItems.value]
return [missingValueItem.value, ...baseModelFilteredAssetItems.value]
}
return ownershipFilteredAssetItems.value
return baseModelFilteredAssetItems.value
}
return [
...(missingValueItem.value ? [missingValueItem.value] : []),
@@ -327,8 +354,8 @@ watch(
{ immediate: true }
)
function updateSelectedItems(selectedItems: Set<OptionId>) {
let id: OptionId | undefined = undefined
function updateSelectedItems(selectedItems: Set<string>) {
let id: string | undefined = undefined
if (selectedItems.size > 0) {
id = selectedItems.values().next().value!
}
@@ -436,6 +463,7 @@ function getMediaUrl(
v-model:filter-selected="filterSelected"
v-model:layout-mode="layoutMode"
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:items="dropdownItems"
:placeholder="mediaPlaceholder"
:multiple="false"
@@ -444,6 +472,8 @@ function getMediaUrl(
:filter-options
:show-ownership-filter
:ownership-options
:show-base-model-filter
:base-model-options
v-bind="combinedProps"
class="w-full"
@update:selected="updateSelectedItems"

View File

@@ -7,7 +7,6 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
import type {
FilterOption,
OptionId,
OwnershipFilterOption,
OwnershipOption
} from '@/platform/assets/types/filterTypes'
@@ -33,8 +32,10 @@ interface Props {
sortOptions?: SortOption[]
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
baseModelOptions?: FilterOption[]
isSelected?: (
selected: Set<OptionId>,
selected: Set<string>,
item: FormDropdownItem,
index: number
) => boolean
@@ -56,11 +57,11 @@ const props = withDefaults(defineProps<Props>(), {
searcher: defaultSearcher
})
const selected = defineModel<Set<OptionId>>('selected', {
const selected = defineModel<Set<string>>('selected', {
default: new Set()
})
const filterSelected = defineModel<OptionId>('filterSelected', { default: '' })
const sortSelected = defineModel<OptionId>('sortSelected', {
const filterSelected = defineModel<string>('filterSelected', { default: '' })
const sortSelected = defineModel<string>('sortSelected', {
default: 'default'
})
const layoutMode = defineModel<LayoutMode>('layoutMode', {
@@ -71,6 +72,9 @@ const searchQuery = defineModel<string>('searchQuery', { default: '' })
const ownershipSelected = defineModel<OwnershipOption>('ownershipSelected', {
default: 'all'
})
const baseModelSelected = defineModel<Set<string>>('baseModelSelected', {
default: new Set()
})
const toastStore = useToastStore()
const popoverRef = ref<InstanceType<typeof Popover>>()
@@ -210,10 +214,13 @@ async function customSearcher(
v-model:sort-selected="sortSelected"
v-model:search-query="searchQuery"
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:filter-options
:sort-options
:show-ownership-filter
:ownership-options
:show-base-model-filter
:base-model-options
:disabled
:searcher="customSearcher"
:items="sortedItems"

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { OptionId } from '@/platform/assets/types/filterTypes'
import { cn } from '@/utils/tailwindUtil'
import { WidgetInputBaseClass } from '../../layout'
@@ -11,7 +10,7 @@ interface Props {
isOpen?: boolean
placeholder?: string
items: FormDropdownItem[]
selected: Set<OptionId>
selected: Set<string>
maxSelectable: number
uploadable: boolean
disabled: boolean

View File

@@ -5,7 +5,6 @@ import { cn } from '@/utils/tailwindUtil'
import type {
FilterOption,
OptionId,
OwnershipFilterOption,
OwnershipOption
} from '@/platform/assets/types/filterTypes'
@@ -27,6 +26,8 @@ interface Props {
updateKey?: MaybeRefOrGetter<unknown>
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
baseModelOptions?: FilterOption[]
}
defineProps<Props>()
@@ -34,11 +35,12 @@ const emit = defineEmits<{
(e: 'item-click', item: FormDropdownItem, index: number): void
}>()
const filterSelected = defineModel<OptionId>('filterSelected')
const filterSelected = defineModel<string>('filterSelected')
const layoutMode = defineModel<LayoutMode>('layoutMode')
const sortSelected = defineModel<OptionId>('sortSelected')
const sortSelected = defineModel<string>('sortSelected')
const searchQuery = defineModel<string>('searchQuery')
const ownershipSelected = defineModel<OwnershipOption>('ownershipSelected')
const baseModelSelected = defineModel<Set<string>>('baseModelSelected')
</script>
<template>
@@ -55,11 +57,14 @@ const ownershipSelected = defineModel<OwnershipOption>('ownershipSelected')
v-model:sort-selected="sortSelected"
v-model:search-query="searchQuery"
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:sort-options
:searcher
:update-key
:show-ownership-filter
:ownership-options
:show-base-model-filter
:base-model-options
/>
<div class="relative flex h-full mt-2 overflow-y-scroll">
<div

View File

@@ -5,8 +5,9 @@ import Popover from 'primevue/popover'
import { ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type {
OptionId,
FilterOption,
OwnershipFilterOption,
OwnershipOption
} from '@/platform/assets/types/filterTypes'
@@ -26,21 +27,24 @@ defineProps<{
updateKey?: MaybeRefOrGetter<unknown>
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
baseModelOptions?: FilterOption[]
}>()
const layoutMode = defineModel<LayoutMode>('layoutMode')
const searchQuery = defineModel<string>('searchQuery')
const sortSelected = defineModel<OptionId>('sortSelected')
const sortSelected = defineModel<string>('sortSelected')
const ownershipSelected = defineModel<OwnershipOption>('ownershipSelected', {
default: 'all'
})
const baseModelSelected = defineModel<Set<string>>('baseModelSelected', {
default: new Set()
})
const actionButtonStyle = cn(
'h-8 bg-zinc-500/20 rounded-lg outline outline-1 outline-offset-[-1px] outline-node-component-border transition-all duration-150'
)
const resetInputStyle = 'bg-transparent border-0 outline-0 ring-0 text-left'
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'
@@ -51,7 +55,7 @@ const isSortPopoverOpen = ref(false)
function toggleSortPopover(event: Event) {
if (!sortPopoverRef.value || !sortTriggerRef.value) return
isSortPopoverOpen.value = !isSortPopoverOpen.value
sortPopoverRef.value.toggle(event, sortTriggerRef.value)
sortPopoverRef.value.toggle(event, sortTriggerRef.value.$el)
}
function closeSortPopover() {
isSortPopoverOpen.value = false
@@ -70,7 +74,7 @@ const isOwnershipPopoverOpen = ref(false)
function toggleOwnershipPopover(event: Event) {
if (!ownershipPopoverRef.value || !ownershipTriggerRef.value) return
isOwnershipPopoverOpen.value = !isOwnershipPopoverOpen.value
ownershipPopoverRef.value.toggle(event, ownershipTriggerRef.value)
ownershipPopoverRef.value.toggle(event, ownershipTriggerRef.value.$el)
}
function closeOwnershipPopover() {
isOwnershipPopoverOpen.value = false
@@ -81,6 +85,26 @@ function handleOwnershipSelected(item: OwnershipFilterOption) {
ownershipSelected.value = item.id
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)
}
function toggleBaseModelSelection(item: FilterOption) {
const current = baseModelSelected.value
if (current.has(item.id)) {
current.delete(item.id)
} else {
current.add(item.id)
}
baseModelSelected.value = new Set(current)
}
</script>
<template>
@@ -98,15 +122,14 @@ function handleOwnershipSelected(item: OwnershipFilterOption) {
"
/>
<button
<Button
ref="sortTriggerRef"
variant="textonly"
size="icon"
:class="
cn(
resetInputStyle,
actionButtonStyle,
'relative w-8 flex justify-center items-center cursor-pointer',
'hover:outline-component-node-widget-background-highlighted',
'active:!scale-95'
'relative w-8 hover:outline-component-node-widget-background-highlighted active:scale-95'
)
"
@click="toggleSortPopover"
@@ -116,7 +139,7 @@ function handleOwnershipSelected(item: OwnershipFilterOption) {
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>
</Button>
<Popover
ref="sortPopoverRef"
:dismissable="true"
@@ -141,16 +164,12 @@ function handleOwnershipSelected(item: OwnershipFilterOption) {
)
"
>
<button
<Button
v-for="item of sortOptions"
:key="item.name"
:class="
cn(
resetInputStyle,
'flex justify-between items-center h-6 cursor-pointer',
'hover:!text-blue-500'
)
"
variant="textonly"
size="unset"
:class="cn('flex justify-between items-center h-6 text-left')"
@click="handleSortSelected(item)"
>
<span>{{ item.name }}</span>
@@ -158,22 +177,21 @@ function handleOwnershipSelected(item: OwnershipFilterOption) {
v-if="sortSelected === item.id"
class="icon-[lucide--check] size-4"
/>
</button>
</Button>
</div>
</Popover>
<button
<Button
v-if="showOwnershipFilter && ownershipOptions?.length"
ref="ownershipTriggerRef"
:aria-label="t('assetBrowser.ownership')"
:title="t('assetBrowser.ownership')"
variant="textonly"
size="icon"
:class="
cn(
resetInputStyle,
actionButtonStyle,
'relative w-8 flex justify-center items-center cursor-pointer',
'hover:outline-component-node-widget-background-highlighted',
'active:!scale-95'
'relative w-8 hover:outline-component-node-widget-background-highlighted active:scale-95'
)
"
@click="toggleOwnershipPopover"
@@ -183,7 +201,7 @@ function handleOwnershipSelected(item: OwnershipFilterOption) {
class="absolute top-[-2px] left-[-2px] size-2 rounded-full bg-component-node-widget-background-highlighted"
/>
<i class="icon-[lucide--user] size-4" />
</button>
</Button>
<Popover
ref="ownershipPopoverRef"
:dismissable="true"
@@ -208,16 +226,12 @@ function handleOwnershipSelected(item: OwnershipFilterOption) {
)
"
>
<button
<Button
v-for="item of ownershipOptions"
:key="item.id"
:class="
cn(
resetInputStyle,
'flex justify-between items-center h-6 cursor-pointer',
'hover:!text-blue-500'
)
"
variant="textonly"
size="unset"
:class="cn('flex justify-between items-center h-6 text-left')"
@click="handleOwnershipSelected(item)"
>
<span>{{ item.name }}</span>
@@ -225,7 +239,78 @@ function handleOwnershipSelected(item: OwnershipFilterOption) {
v-if="ownershipSelected === item.id"
class="icon-[lucide--check] size-4"
/>
</button>
</Button>
</div>
</Popover>
<Button
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"
>
<div
:class="
cn(
'flex flex-col gap-2 p-2 min-w-32',
'bg-component-node-background',
'rounded-lg outline outline-offset-[-1px] outline-component-node-border'
)
"
>
<Button
v-for="item of baseModelOptions"
:key="item.id"
variant="textonly"
size="unset"
:class="cn('flex justify-between items-center h-6 text-left')"
@click="toggleBaseModelSelection(item)"
>
<span>{{ item.name }}</span>
<i
v-if="baseModelSelected.has(item.id)"
class="icon-[lucide--check] size-4"
/>
</Button>
<span class="h-0 w-full border-b border-border-default" />
<Button
variant="textonly"
size="unset"
:class="cn('flex justify-between items-center h-6 text-left')"
@click="baseModelSelected = new Set()"
>
{{ t('g.clearFilters') }}
</Button>
</div>
</Popover>
@@ -237,34 +322,32 @@ function handleOwnershipSelected(item: OwnershipFilterOption) {
)
"
>
<button
<Button
variant="textonly"
size="unset"
:class="
cn(
resetInputStyle,
layoutSwitchItemStyle,
layoutMode === 'list'
? 'bg-neutral-500/50 text-base-foreground'
: ''
layoutMode === 'list' && 'bg-neutral-500/50 text-base-foreground'
)
"
@click="layoutMode = 'list'"
>
<i class="icon-[lucide--list] size-4" />
</button>
<button
</Button>
<Button
variant="textonly"
size="unset"
:class="
cn(
resetInputStyle,
layoutSwitchItemStyle,
layoutMode === 'grid'
? 'bg-neutral-500/50 text-base-foreground'
: ''
layoutMode === 'grid' && 'bg-neutral-500/50 text-base-foreground'
)
"
@click="layoutMode = 'grid'"
>
<i class="icon-[lucide--layout-grid] size-4" />
</button>
</Button>
</div>
</div>
</template>

View File

@@ -3,17 +3,14 @@ import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
import type {
FilterOption,
OptionId
} from '@/platform/assets/types/filterTypes'
import type { FilterOption } from '@/platform/assets/types/filterTypes'
import { cn } from '@/utils/tailwindUtil'
const { filterOptions } = defineProps<{
filterOptions: FilterOption[]
}>()
const filterSelected = defineModel<OptionId>('filterSelected')
const filterSelected = defineModel<string>('filterSelected')
const { isUploadButtonEnabled, showUploadDialog } = useModelUpload()

View File

@@ -1,6 +1,5 @@
import type { ComputedRef, InjectionKey } from 'vue'
import type { OptionId } from '@/platform/assets/types/filterTypes'
import type { AssetKind } from '@/types/widgetTypes'
/**
@@ -8,7 +7,7 @@ import type { AssetKind } from '@/types/widgetTypes'
* Both AssetItem (from cloud API) and local file items satisfy this contract.
*/
export interface FormDropdownItem {
id: OptionId
id: string
/** Display name shown in the dropdown */
name: string
/** Original/alternate label (e.g., original filename) */
@@ -17,9 +16,11 @@ export interface FormDropdownItem {
preview_url?: string
/** Whether the item is immutable (public model) - used for ownership filtering */
is_immutable?: boolean
/** Base models this item is compatible with - used for base model filtering */
base_models?: string[]
}
export interface SortOption<TId extends OptionId = OptionId> {
export interface SortOption<TId extends string = string> {
id: TId
name: string
sorter: (ctx: { items: readonly FormDropdownItem[] }) => FormDropdownItem[]