mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-28 10:12:11 +00:00
feat: add ownership and base model filtering, unify asset/dropdown types (#8497)
Add ownership and base model filtering to AssetBrowserModal and FormDropdown widgets. ## Changes - **Ownership filter**: Filter by All/My Models/Public Models (uses `is_immutable` field) - **Base model filter**: Multi-select filter with Clear Filters button - **Type unification**: Replace `AssetDropdownItem` with `FormDropdownItem` - **Sorting unification**: Extract shared utilities to `assetSortUtils.ts` - **UI refactor**: Use `Button` component, Vue 3.5 prop shorthand, i18n improvements --------- Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -51,6 +51,7 @@
|
||||
<template #contentFilter>
|
||||
<AssetFilterBar
|
||||
:assets="categoryFilteredAssets"
|
||||
:show-ownership-filter
|
||||
@filter-change="updateFilters"
|
||||
@click.self="focusedAsset = null"
|
||||
/>
|
||||
@@ -125,19 +126,16 @@ const emit = defineEmits<{
|
||||
|
||||
provide(OnCloseKey, props.onClose ?? (() => {}))
|
||||
|
||||
// Compute the cache key based on nodeType or assetType
|
||||
const cacheKey = computed(() => {
|
||||
if (props.nodeType) return props.nodeType
|
||||
if (props.assetType) return `tag:${props.assetType}`
|
||||
return ''
|
||||
})
|
||||
|
||||
// Read directly from store cache - reactive to any store updates
|
||||
const fetchedAssets = computed(() => assetStore.getAssets(cacheKey.value))
|
||||
|
||||
const isStoreLoading = computed(() => assetStore.isModelLoading(cacheKey.value))
|
||||
|
||||
// Only show loading spinner when loading AND no cached data
|
||||
const isLoading = computed(
|
||||
() => isStoreLoading.value && fetchedAssets.value.length === 0
|
||||
)
|
||||
@@ -150,10 +148,8 @@ async function refreshAssets(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger background refresh on mount
|
||||
void refreshAssets()
|
||||
|
||||
// Eagerly fetch model types so they're available when ModelInfoPanel loads
|
||||
const { fetchModelTypes } = useModelTypes()
|
||||
void fetchModelTypes()
|
||||
|
||||
@@ -210,6 +206,12 @@ const shouldShowLeftPanel = computed(() => {
|
||||
return props.showLeftPanel ?? true
|
||||
})
|
||||
|
||||
const showOwnershipFilter = computed(
|
||||
() =>
|
||||
!shouldShowLeftPanel.value ||
|
||||
(selectedNavItem.value !== 'all' && selectedNavItem.value !== 'imported')
|
||||
)
|
||||
|
||||
const emptyMessage = computed(() => {
|
||||
if (!isImportedSelected.value) {
|
||||
return isUploadButtonEnabled.value
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
|
||||
import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vue'
|
||||
import type { AssetFilterState } from '@/platform/assets/types/filterTypes'
|
||||
import {
|
||||
createAssetWithSpecificBaseModel,
|
||||
createAssetWithSpecificExtension,
|
||||
@@ -142,15 +142,16 @@ describe('AssetFilterBar', () => {
|
||||
expect(emitted!.length).toBeGreaterThanOrEqual(3)
|
||||
|
||||
// Check final state
|
||||
const finalState: FilterState = emitted![
|
||||
const finalState: AssetFilterState = emitted![
|
||||
emitted!.length - 1
|
||||
][0] as FilterState
|
||||
][0] as AssetFilterState
|
||||
expect(finalState.fileFormats).toEqual(['ckpt', 'safetensors'])
|
||||
expect(finalState.baseModels).toEqual(['sdxl'])
|
||||
expect(finalState.sortBy).toBe('name-desc')
|
||||
expect(finalState.ownership).toBe('all')
|
||||
})
|
||||
|
||||
it('ensures FilterState interface compliance', async () => {
|
||||
it('ensures AssetFilterState interface compliance', async () => {
|
||||
// Provide assets with options so filters are visible
|
||||
const assets = [
|
||||
createAssetWithSpecificExtension('safetensors'),
|
||||
@@ -167,7 +168,7 @@ describe('AssetFilterBar', () => {
|
||||
await nextTick()
|
||||
|
||||
const emitted = wrapper.emitted('filterChange')
|
||||
const filterState = emitted![0][0] as FilterState
|
||||
const filterState = emitted![0][0] as AssetFilterState
|
||||
|
||||
// Type and structure assertions
|
||||
expect(Array.isArray(filterState.fileFormats)).toBe(true)
|
||||
|
||||
@@ -26,6 +26,16 @@
|
||||
data-component-id="asset-filter-base-models"
|
||||
@update:model-value="handleFilterChange"
|
||||
/>
|
||||
|
||||
<SingleSelect
|
||||
v-if="showOwnershipFilter"
|
||||
v-model="ownership"
|
||||
:label="$t('assetBrowser.ownership')"
|
||||
:options="ownershipOptions"
|
||||
class="min-w-32"
|
||||
data-component-id="asset-filter-ownership"
|
||||
@update:model-value="handleFilterChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center" data-component-id="asset-filter-bar-right">
|
||||
@@ -54,44 +64,43 @@ import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import type { SelectOption } from '@/components/input/types'
|
||||
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type {
|
||||
AssetFilterState,
|
||||
AssetSortOption,
|
||||
OwnershipOption
|
||||
} from '@/platform/assets/types/filterTypes'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
type SortOption = 'recent' | 'name-asc' | 'name-desc'
|
||||
|
||||
const sortOptions = computed(() => [
|
||||
{ name: t('assetBrowser.sortRecent'), value: 'recent' as const },
|
||||
{ name: t('assetBrowser.sortAZ'), value: 'name-asc' as const },
|
||||
{ name: t('assetBrowser.sortZA'), value: 'name-desc' as const }
|
||||
])
|
||||
|
||||
export interface FilterState {
|
||||
fileFormats: string[]
|
||||
baseModels: string[]
|
||||
sortBy: SortOption
|
||||
}
|
||||
|
||||
const { assets = [] } = defineProps<{
|
||||
const { assets = [], showOwnershipFilter = false } = defineProps<{
|
||||
assets?: AssetItem[]
|
||||
showOwnershipFilter?: boolean
|
||||
}>()
|
||||
|
||||
const fileFormats = ref<SelectOption[]>([])
|
||||
const baseModels = ref<SelectOption[]>([])
|
||||
const sortBy = ref<SortOption>('recent')
|
||||
const sortBy = ref<AssetSortOption>('recent')
|
||||
const ownership = ref<OwnershipOption>('all')
|
||||
|
||||
const { availableFileFormats, availableBaseModels } = useAssetFilterOptions(
|
||||
() => assets
|
||||
)
|
||||
const { availableFileFormats, availableBaseModels, ownershipOptions } =
|
||||
useAssetFilterOptions(() => assets)
|
||||
|
||||
const emit = defineEmits<{
|
||||
filterChange: [filters: FilterState]
|
||||
filterChange: [filters: AssetFilterState]
|
||||
}>()
|
||||
|
||||
function handleFilterChange() {
|
||||
emit('filterChange', {
|
||||
fileFormats: fileFormats.value.map((option: SelectOption) => option.value),
|
||||
baseModels: baseModels.value.map((option: SelectOption) => option.value),
|
||||
sortBy: sortBy.value
|
||||
sortBy: sortBy.value,
|
||||
ownership: ownership.value
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -36,6 +36,7 @@ describe('useAssetBrowser', () => {
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
last_access_time: '2024-01-01T00:00:00Z',
|
||||
is_immutable: false,
|
||||
...overrides
|
||||
})
|
||||
|
||||
@@ -295,7 +296,8 @@ describe('useAssetBrowser', () => {
|
||||
updateFilters({
|
||||
sortBy: 'name-asc',
|
||||
fileFormats: ['safetensors'],
|
||||
baseModels: []
|
||||
baseModels: [],
|
||||
ownership: 'all'
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
@@ -330,7 +332,8 @@ describe('useAssetBrowser', () => {
|
||||
updateFilters({
|
||||
sortBy: 'name-asc',
|
||||
fileFormats: [],
|
||||
baseModels: ['SDXL']
|
||||
baseModels: ['SDXL'],
|
||||
ownership: 'all'
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
@@ -384,7 +387,8 @@ describe('useAssetBrowser', () => {
|
||||
updateFilters({
|
||||
sortBy: 'name-asc',
|
||||
fileFormats: [],
|
||||
baseModels: []
|
||||
baseModels: [],
|
||||
ownership: 'all'
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
@@ -408,7 +412,8 @@ describe('useAssetBrowser', () => {
|
||||
updateFilters({
|
||||
sortBy: 'recent',
|
||||
fileFormats: [],
|
||||
baseModels: []
|
||||
baseModels: [],
|
||||
ownership: 'all'
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
@@ -440,7 +445,8 @@ describe('useAssetBrowser', () => {
|
||||
updateFilters({
|
||||
sortBy: 'name-asc',
|
||||
fileFormats: [],
|
||||
baseModels: []
|
||||
baseModels: [],
|
||||
ownership: 'all'
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
@@ -457,6 +463,12 @@ describe('useAssetBrowser', () => {
|
||||
createApiAsset({
|
||||
name: 'another-my-model.safetensors',
|
||||
is_immutable: false
|
||||
}),
|
||||
// Need a second category so typeCategories.length > 1
|
||||
createApiAsset({
|
||||
name: 'lora.safetensors',
|
||||
is_immutable: true,
|
||||
tags: ['models', 'loras']
|
||||
})
|
||||
]
|
||||
|
||||
@@ -493,6 +505,127 @@ describe('useAssetBrowser', () => {
|
||||
|
||||
expect(filteredAssets.value).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('filters by ownership via filter bar - my-models', async () => {
|
||||
const assets = [
|
||||
createApiAsset({
|
||||
name: 'my-model.safetensors',
|
||||
is_immutable: false,
|
||||
tags: ['models', 'checkpoints']
|
||||
}),
|
||||
createApiAsset({
|
||||
name: 'public-model.safetensors',
|
||||
is_immutable: true,
|
||||
tags: ['models', 'checkpoints']
|
||||
}),
|
||||
createApiAsset({
|
||||
name: 'another-my-model.safetensors',
|
||||
is_immutable: false,
|
||||
tags: ['models', 'checkpoints']
|
||||
})
|
||||
]
|
||||
|
||||
const { selectedNavItem, updateFilters, filteredAssets } =
|
||||
useAssetBrowser(ref(assets))
|
||||
|
||||
// Must select a specific category for ownership filter to apply
|
||||
selectedNavItem.value = 'checkpoints'
|
||||
updateFilters({
|
||||
sortBy: 'name-asc',
|
||||
fileFormats: [],
|
||||
baseModels: [],
|
||||
ownership: 'my-models'
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(filteredAssets.value).toHaveLength(2)
|
||||
expect(filteredAssets.value.every((asset) => !asset.is_immutable)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('filters by ownership via filter bar - public-models', async () => {
|
||||
const assets = [
|
||||
createApiAsset({
|
||||
name: 'my-model.safetensors',
|
||||
is_immutable: false,
|
||||
tags: ['models', 'loras']
|
||||
}),
|
||||
createApiAsset({
|
||||
name: 'public-model.safetensors',
|
||||
is_immutable: true,
|
||||
tags: ['models', 'loras']
|
||||
}),
|
||||
createApiAsset({
|
||||
name: 'another-public-model.safetensors',
|
||||
is_immutable: true,
|
||||
tags: ['models', 'loras']
|
||||
})
|
||||
]
|
||||
|
||||
const { selectedNavItem, updateFilters, filteredAssets } =
|
||||
useAssetBrowser(ref(assets))
|
||||
|
||||
// Must select a specific category for ownership filter to apply
|
||||
selectedNavItem.value = 'loras'
|
||||
updateFilters({
|
||||
sortBy: 'name-asc',
|
||||
fileFormats: [],
|
||||
baseModels: [],
|
||||
ownership: 'public-models'
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(filteredAssets.value).toHaveLength(2)
|
||||
expect(filteredAssets.value.every((asset) => asset.is_immutable)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('nav imported selection overrides filter bar ownership', async () => {
|
||||
const assets = [
|
||||
createApiAsset({
|
||||
name: 'my-model.safetensors',
|
||||
is_immutable: false,
|
||||
tags: ['models', 'checkpoints']
|
||||
}),
|
||||
createApiAsset({
|
||||
name: 'public-model.safetensors',
|
||||
is_immutable: true,
|
||||
tags: ['models', 'checkpoints']
|
||||
}),
|
||||
// Need a second category so typeCategories.length > 1
|
||||
createApiAsset({
|
||||
name: 'lora.safetensors',
|
||||
is_immutable: true,
|
||||
tags: ['models', 'loras']
|
||||
})
|
||||
]
|
||||
|
||||
const { selectedNavItem, updateFilters, filteredAssets } =
|
||||
useAssetBrowser(ref(assets))
|
||||
|
||||
// Must select a specific category for ownership filter to apply
|
||||
selectedNavItem.value = 'checkpoints'
|
||||
// Set filter bar to public-models
|
||||
updateFilters({
|
||||
sortBy: 'name-asc',
|
||||
fileFormats: [],
|
||||
baseModels: [],
|
||||
ownership: 'public-models'
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(filteredAssets.value).toHaveLength(1)
|
||||
expect(filteredAssets.value[0].is_immutable).toBe(true)
|
||||
|
||||
// Nav selection to 'imported' should override filter bar
|
||||
selectedNavItem.value = 'imported'
|
||||
await nextTick()
|
||||
|
||||
expect(filteredAssets.value).toHaveLength(1)
|
||||
expect(filteredAssets.value[0].is_immutable).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dynamic Category Extraction', () => {
|
||||
|
||||
@@ -5,64 +5,27 @@ import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import { d, t } from '@/i18n'
|
||||
import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vue'
|
||||
import type {
|
||||
AssetFilterState,
|
||||
OwnershipOption
|
||||
} from '@/platform/assets/types/filterTypes'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import {
|
||||
filterByBaseModels,
|
||||
filterByCategory,
|
||||
filterByFileFormats,
|
||||
filterByOwnership
|
||||
} from '@/platform/assets/utils/assetFilterUtils'
|
||||
import {
|
||||
getAssetBaseModels,
|
||||
getAssetDisplayName,
|
||||
getAssetFilename
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { sortAssets } from '@/platform/assets/utils/assetSortUtils'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
|
||||
type OwnershipOption = 'all' | 'my-models' | 'public-models'
|
||||
|
||||
type NavId = 'all' | 'imported' | (string & {})
|
||||
|
||||
function filterByCategory(category: string) {
|
||||
return (asset: AssetItem) => {
|
||||
if (category === 'all') return true
|
||||
|
||||
// Check if any tag matches the category (for exact matches)
|
||||
if (asset.tags.includes(category)) return true
|
||||
|
||||
// Check if any tag's top-level folder matches the category
|
||||
return asset.tags.some((tag) => {
|
||||
if (typeof tag === 'string' && tag.includes('/')) {
|
||||
return tag.split('/')[0] === category
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function filterByFileFormats(formats: string[]) {
|
||||
return (asset: AssetItem) => {
|
||||
if (formats.length === 0) return true
|
||||
const formatSet = new Set(formats)
|
||||
const extension = asset.name.split('.').pop()?.toLowerCase()
|
||||
return extension ? formatSet.has(extension) : false
|
||||
}
|
||||
}
|
||||
|
||||
function filterByBaseModels(models: string[]) {
|
||||
return (asset: AssetItem) => {
|
||||
if (models.length === 0) return true
|
||||
const modelSet = new Set(models)
|
||||
const assetBaseModels = getAssetBaseModels(asset)
|
||||
return assetBaseModels.some((model) => modelSet.has(model))
|
||||
}
|
||||
}
|
||||
|
||||
function filterByOwnership(ownership: OwnershipOption) {
|
||||
return (asset: AssetItem) => {
|
||||
if (ownership === 'all') return true
|
||||
if (ownership === 'my-models') return asset.is_immutable === false
|
||||
if (ownership === 'public-models') return asset.is_immutable === true
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
type AssetBadge = {
|
||||
label: string
|
||||
type: 'type' | 'base' | 'size'
|
||||
@@ -93,15 +56,18 @@ export function useAssetBrowser(
|
||||
// State
|
||||
const searchQuery = ref('')
|
||||
const selectedNavItem = ref<NavId>('all')
|
||||
const filters = ref<FilterState>({
|
||||
const filters = ref<AssetFilterState>({
|
||||
sortBy: 'recent',
|
||||
fileFormats: [],
|
||||
baseModels: []
|
||||
baseModels: [],
|
||||
ownership: 'all'
|
||||
})
|
||||
|
||||
const selectedOwnership = computed<OwnershipOption>(() => {
|
||||
if (typeCategories.value.length <= 1) return filters.value.ownership
|
||||
if (selectedNavItem.value === 'imported') return 'my-models'
|
||||
return 'all'
|
||||
if (selectedNavItem.value === 'all') return 'all'
|
||||
return filters.value.ownership
|
||||
})
|
||||
|
||||
const selectedCategory = computed(() => {
|
||||
@@ -261,27 +227,13 @@ export function useAssetBrowser(
|
||||
.filter(filterByBaseModels(filters.value.baseModels))
|
||||
.filter(filterByOwnership(selectedOwnership.value))
|
||||
|
||||
const sortedAssets = [...filtered]
|
||||
sortedAssets.sort((a, b) => {
|
||||
switch (filters.value.sortBy) {
|
||||
case 'name-desc':
|
||||
return getAssetDisplayName(b).localeCompare(getAssetDisplayName(a))
|
||||
case 'recent':
|
||||
return (
|
||||
new Date(b.created_at ?? 0).getTime() -
|
||||
new Date(a.created_at ?? 0).getTime()
|
||||
)
|
||||
case 'name-asc':
|
||||
default:
|
||||
return getAssetDisplayName(a).localeCompare(getAssetDisplayName(b))
|
||||
}
|
||||
})
|
||||
const sortedAssets = sortAssets(filtered, filters.value.sortBy)
|
||||
|
||||
// Transform to display format
|
||||
return sortedAssets.map(transformAssetForDisplay)
|
||||
})
|
||||
|
||||
function updateFilters(newFilters: FilterState) {
|
||||
function updateFilters(newFilters: AssetFilterState) {
|
||||
filters.value = { ...newFilters }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
||||
|
||||
import {
|
||||
createAssetWithSpecificBaseModel,
|
||||
createAssetWithSpecificExtension,
|
||||
@@ -9,6 +10,12 @@ import {
|
||||
createAssetWithoutUserMetadata
|
||||
} from '@/platform/assets/fixtures/ui-mock-assets'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useAssetFilterOptions', () => {
|
||||
describe('File Format Extraction', () => {
|
||||
it('extracts file formats from asset names', () => {
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { uniqWith } from 'es-toolkit'
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { SelectOption } from '@/components/input/types'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { OwnershipFilterOption } from '@/platform/assets/types/filterTypes'
|
||||
import { getAssetBaseModels } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
|
||||
/**
|
||||
* Composable that extracts available filter options from asset data
|
||||
* Provides reactive computed properties for file formats and base models
|
||||
* Provides reactive computed properties for file formats, base models, and ownership
|
||||
*/
|
||||
export function useAssetFilterOptions(assets: MaybeRefOrGetter<AssetItem[]>) {
|
||||
const { t } = useI18n()
|
||||
|
||||
const ownershipOptions = computed<OwnershipFilterOption[]>(() => [
|
||||
{ name: t('assetBrowser.ownershipAll'), value: 'all' },
|
||||
{ name: t('assetBrowser.ownershipMyModels'), value: 'my-models' },
|
||||
{ name: t('assetBrowser.ownershipPublicModels'), value: 'public-models' }
|
||||
])
|
||||
/**
|
||||
* Extract unique file formats from asset names
|
||||
* Returns sorted SelectOption array with extensions
|
||||
@@ -50,6 +59,7 @@ export function useAssetFilterOptions(assets: MaybeRefOrGetter<AssetItem[]>) {
|
||||
|
||||
return {
|
||||
availableFileFormats,
|
||||
availableBaseModels
|
||||
availableBaseModels,
|
||||
ownershipOptions
|
||||
}
|
||||
}
|
||||
|
||||
48
src/platform/assets/types/filterTypes.ts
Normal file
48
src/platform/assets/types/filterTypes.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Asset filtering and sorting types
|
||||
* Shared across AssetBrowser, AssetFilterBar, and widget dropdowns
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generic filter/select option used across components
|
||||
* Compatible with SelectOption (name/value) pattern
|
||||
*/
|
||||
export interface FilterOption {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Ownership filter options for assets
|
||||
* - 'all': Show all assets
|
||||
* - 'my-models': Show only user-owned assets (is_immutable === false)
|
||||
* - 'public-models': Show only public assets (is_immutable === true)
|
||||
*/
|
||||
export type OwnershipOption = 'all' | 'my-models' | 'public-models'
|
||||
|
||||
/**
|
||||
* Ownership filter option for dropdowns/selects
|
||||
*/
|
||||
export interface OwnershipFilterOption {
|
||||
name: string
|
||||
value: OwnershipOption
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort options for asset lists
|
||||
* - 'default': Preserve original order (no sorting)
|
||||
* - 'recent': Sort by created_at descending
|
||||
* - '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'
|
||||
|
||||
/**
|
||||
* Filter state for asset browser and filter bar
|
||||
*/
|
||||
export interface AssetFilterState {
|
||||
fileFormats: string[]
|
||||
baseModels: string[]
|
||||
sortBy: AssetSortOption
|
||||
ownership: OwnershipOption
|
||||
}
|
||||
181
src/platform/assets/utils/assetFilterUtils.test.ts
Normal file
181
src/platform/assets/utils/assetFilterUtils.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
import {
|
||||
filterByBaseModels,
|
||||
filterByCategory,
|
||||
filterByFileFormats,
|
||||
filterByOwnership,
|
||||
filterItemByBaseModels,
|
||||
filterItemByOwnership
|
||||
} from './assetFilterUtils'
|
||||
|
||||
function createAsset(
|
||||
name: string,
|
||||
options: Partial<AssetItem> = {}
|
||||
): AssetItem {
|
||||
return {
|
||||
id: `asset-${name}`,
|
||||
name,
|
||||
tags: [],
|
||||
is_immutable: false,
|
||||
...options
|
||||
} satisfies AssetItem
|
||||
}
|
||||
|
||||
describe('filterByCategory', () => {
|
||||
it.for([
|
||||
{ category: 'all', tags: ['checkpoint'], expected: true },
|
||||
{ category: 'checkpoint', tags: ['checkpoint'], expected: true },
|
||||
{ category: 'lora', tags: ['checkpoint'], expected: false },
|
||||
{
|
||||
category: 'checkpoint',
|
||||
tags: ['models', 'checkpoint/xl'],
|
||||
expected: true
|
||||
},
|
||||
{ category: 'xl', tags: ['models', 'checkpoint/xl'], expected: false }
|
||||
])(
|
||||
'category=$category with tags=$tags returns $expected',
|
||||
({ category, tags, expected }) => {
|
||||
const filter = filterByCategory(category)
|
||||
const asset = createAsset('model.safetensors', { tags })
|
||||
expect(filter(asset)).toBe(expected)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('filterByFileFormats', () => {
|
||||
it.for([
|
||||
{ formats: [], name: 'model.safetensors', expected: true },
|
||||
{ formats: ['safetensors'], name: 'model.safetensors', expected: true },
|
||||
{ formats: ['ckpt'], name: 'model.safetensors', expected: false },
|
||||
{ formats: ['safetensors'], name: 'MODEL.SAFETENSORS', expected: true },
|
||||
{ formats: ['safetensors'], name: 'model', expected: false }
|
||||
])(
|
||||
'formats=$formats with name=$name returns $expected',
|
||||
({ formats, name, expected }) => {
|
||||
const filter = filterByFileFormats(formats)
|
||||
const asset = createAsset(name)
|
||||
expect(filter(asset)).toBe(expected)
|
||||
}
|
||||
)
|
||||
|
||||
it('matches any of multiple formats', () => {
|
||||
const filter = filterByFileFormats(['safetensors', 'ckpt', 'bin'])
|
||||
expect(filter(createAsset('model.safetensors'))).toBe(true)
|
||||
expect(filter(createAsset('model.ckpt'))).toBe(true)
|
||||
expect(filter(createAsset('model.bin'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterByBaseModels', () => {
|
||||
it.for([
|
||||
{ models: [], expected: true },
|
||||
{ models: new Set<string>(), expected: true }
|
||||
])('empty models ($models) returns true', ({ models }) => {
|
||||
const filter = filterByBaseModels(models)
|
||||
const asset = createAsset('model.safetensors')
|
||||
expect(filter(asset)).toBe(true)
|
||||
})
|
||||
|
||||
it.for([
|
||||
{
|
||||
models: ['SDXL'],
|
||||
metadata: { base_model: ['SDXL'] },
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
models: ['SDXL'],
|
||||
metadata: { base_model: ['SD1.5'] },
|
||||
expected: false
|
||||
},
|
||||
{
|
||||
models: new Set(['SDXL', 'SD1.5']),
|
||||
metadata: { base_model: ['SDXL'] },
|
||||
expected: true
|
||||
}
|
||||
])(
|
||||
'models=$models with metadata.base_model returns $expected',
|
||||
({ models, metadata, expected }) => {
|
||||
const filter = filterByBaseModels(models)
|
||||
const asset = createAsset('model.safetensors', { metadata })
|
||||
expect(filter(asset)).toBe(expected)
|
||||
}
|
||||
)
|
||||
|
||||
it('matches base model in user_metadata', () => {
|
||||
const filter = filterByBaseModels(['SD1.5'])
|
||||
const asset = createAsset('model.safetensors', {
|
||||
user_metadata: { base_model: ['SD1.5'] }
|
||||
})
|
||||
expect(filter(asset)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterByOwnership', () => {
|
||||
it.for([
|
||||
{ ownership: 'all' as const, is_immutable: true, expected: true },
|
||||
{ ownership: 'all' as const, is_immutable: false, expected: true },
|
||||
{ ownership: 'my-models' as const, is_immutable: false, expected: true },
|
||||
{ ownership: 'my-models' as const, is_immutable: true, expected: false },
|
||||
{
|
||||
ownership: 'public-models' as const,
|
||||
is_immutable: true,
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
ownership: 'public-models' as const,
|
||||
is_immutable: false,
|
||||
expected: false
|
||||
}
|
||||
])(
|
||||
'ownership=$ownership with is_immutable=$is_immutable returns $expected',
|
||||
({ ownership, is_immutable, expected }) => {
|
||||
const filter = filterByOwnership(ownership)
|
||||
const asset = createAsset('model', { is_immutable })
|
||||
expect(filter(asset)).toBe(expected)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('filterItemByOwnership', () => {
|
||||
const items = [
|
||||
{ id: '1', is_immutable: true },
|
||||
{ id: '2', is_immutable: false },
|
||||
{ id: '3', is_immutable: true }
|
||||
]
|
||||
|
||||
it.for([
|
||||
{ ownership: 'all' as const, expectedIds: ['1', '2', '3'] },
|
||||
{ ownership: 'my-models' as const, expectedIds: ['2'] },
|
||||
{ ownership: 'public-models' as const, expectedIds: ['1', '3'] }
|
||||
])(
|
||||
'ownership=$ownership returns items with ids=$expectedIds',
|
||||
({ ownership, expectedIds }) => {
|
||||
const result = filterItemByOwnership(items, ownership)
|
||||
expect(result.map((i) => i.id)).toEqual(expectedIds)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('filterItemByBaseModels', () => {
|
||||
const items = [
|
||||
{ id: '1', base_models: ['SDXL'] },
|
||||
{ id: '2', base_models: ['SD1.5'] },
|
||||
{ id: '3', base_models: ['SDXL', 'SD1.5'] },
|
||||
{ id: '4' }
|
||||
]
|
||||
|
||||
it.for([
|
||||
{ selectedModels: new Set<string>(), expectedIds: ['1', '2', '3', '4'] },
|
||||
{ selectedModels: new Set(['SDXL']), expectedIds: ['1', '3'] },
|
||||
{ selectedModels: new Set(['SD1.5']), expectedIds: ['2', '3'] }
|
||||
])(
|
||||
'selectedModels=$selectedModels returns items with ids=$expectedIds',
|
||||
({ selectedModels, expectedIds }) => {
|
||||
const result = filterItemByBaseModels(items, selectedModels)
|
||||
expect(result.map((i) => i.id)).toEqual(expectedIds)
|
||||
}
|
||||
)
|
||||
})
|
||||
66
src/platform/assets/utils/assetFilterUtils.ts
Normal file
66
src/platform/assets/utils/assetFilterUtils.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { OwnershipOption } from '@/platform/assets/types/filterTypes'
|
||||
import { getAssetBaseModels } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
|
||||
export function filterByCategory(category: string) {
|
||||
return (asset: AssetItem) => {
|
||||
if (category === 'all') return true
|
||||
|
||||
// Check if any tag matches the category (for exact matches)
|
||||
if (asset.tags.includes(category)) return true
|
||||
|
||||
// Check if any tag's top-level folder matches the category
|
||||
return asset.tags.some((tag) => {
|
||||
if (typeof tag === 'string' && tag.includes('/')) {
|
||||
return tag.split('/')[0] === category
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function filterByFileFormats(formats: string[]) {
|
||||
return (asset: AssetItem) => {
|
||||
if (formats.length === 0) return true
|
||||
const formatSet = new Set(formats)
|
||||
const extension = asset.name.split('.').pop()?.toLowerCase()
|
||||
return extension ? formatSet.has(extension) : false
|
||||
}
|
||||
}
|
||||
|
||||
export function filterByBaseModels(models: string[] | Set<string>) {
|
||||
const modelSet = models instanceof Set ? models : new Set(models)
|
||||
return (asset: AssetItem) => {
|
||||
if (modelSet.size === 0) return true
|
||||
const assetBaseModels = getAssetBaseModels(asset)
|
||||
return assetBaseModels.some((model) => modelSet.has(model))
|
||||
}
|
||||
}
|
||||
|
||||
export function filterByOwnership(ownership: OwnershipOption) {
|
||||
return (asset: AssetItem) => {
|
||||
if (ownership === 'all') return true
|
||||
if (ownership === 'my-models') return asset.is_immutable === false
|
||||
if (ownership === 'public-models') return asset.is_immutable === true
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function filterItemByOwnership<T extends { is_immutable?: boolean }>(
|
||||
items: T[],
|
||||
ownership: OwnershipOption
|
||||
): T[] {
|
||||
if (ownership === 'all') return items
|
||||
const isPublic = ownership === 'public-models'
|
||||
return items.filter((item) => item.is_immutable === isPublic)
|
||||
}
|
||||
|
||||
export function filterItemByBaseModels<T extends { base_models?: string[] }>(
|
||||
items: T[],
|
||||
selectedModels: Set<string>
|
||||
): T[] {
|
||||
if (selectedModels.size === 0) return items
|
||||
return items.filter((item) =>
|
||||
item.base_models?.some((model) => selectedModels.has(model))
|
||||
)
|
||||
}
|
||||
122
src/platform/assets/utils/assetSortUtils.test.ts
Normal file
122
src/platform/assets/utils/assetSortUtils.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SortableItem } from './assetSortUtils'
|
||||
import { sortAssets } from './assetSortUtils'
|
||||
|
||||
function createItem(
|
||||
name: string,
|
||||
options: { label?: string; created_at?: string } = {}
|
||||
): SortableItem {
|
||||
return { name, ...options }
|
||||
}
|
||||
|
||||
describe('sortAssets', () => {
|
||||
describe('default sort', () => {
|
||||
it('preserves original order', () => {
|
||||
const items = [createItem('z'), createItem('a'), createItem('m')]
|
||||
const result = sortAssets(items, 'default')
|
||||
expect(result.map((i) => i.name)).toEqual(['z', 'a', 'm'])
|
||||
})
|
||||
|
||||
it('returns a new array (does not mutate)', () => {
|
||||
const items = [createItem('z'), createItem('a')]
|
||||
const result = sortAssets(items, 'default')
|
||||
expect(result).not.toBe(items)
|
||||
})
|
||||
})
|
||||
|
||||
describe('name-asc sort', () => {
|
||||
it('sorts alphabetically A-Z by name', () => {
|
||||
const items = [
|
||||
createItem('cherry'),
|
||||
createItem('apple'),
|
||||
createItem('banana')
|
||||
]
|
||||
const result = sortAssets(items, 'name-asc')
|
||||
expect(result.map((i) => i.name)).toEqual(['apple', 'banana', 'cherry'])
|
||||
})
|
||||
|
||||
it('prefers label over name when label exists', () => {
|
||||
const items = [
|
||||
createItem('file_c.png', { label: 'Cherry' }),
|
||||
createItem('file_a.png', { label: 'Apple' }),
|
||||
createItem('file_b.png', { label: 'Banana' })
|
||||
]
|
||||
const result = sortAssets(items, 'name-asc')
|
||||
expect(result.map((i) => i.name)).toEqual([
|
||||
'file_a.png',
|
||||
'file_b.png',
|
||||
'file_c.png'
|
||||
])
|
||||
})
|
||||
|
||||
it('uses natural sort for numeric values', () => {
|
||||
const items = [
|
||||
createItem('img_10.png'),
|
||||
createItem('img_2.png'),
|
||||
createItem('img_1.png'),
|
||||
createItem('img_20.png')
|
||||
]
|
||||
const result = sortAssets(items, 'name-asc')
|
||||
expect(result.map((i) => i.name)).toEqual([
|
||||
'img_1.png',
|
||||
'img_2.png',
|
||||
'img_10.png',
|
||||
'img_20.png'
|
||||
])
|
||||
})
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
const items = [
|
||||
createItem('Banana'),
|
||||
createItem('apple'),
|
||||
createItem('CHERRY')
|
||||
]
|
||||
const result = sortAssets(items, 'name-asc')
|
||||
expect(result.map((i) => i.name)).toEqual(['apple', 'Banana', 'CHERRY'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('name-desc sort', () => {
|
||||
it('sorts alphabetically Z-A by name', () => {
|
||||
const items = [
|
||||
createItem('apple'),
|
||||
createItem('cherry'),
|
||||
createItem('banana')
|
||||
]
|
||||
const result = sortAssets(items, 'name-desc')
|
||||
expect(result.map((i) => i.name)).toEqual(['cherry', 'banana', 'apple'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('recent sort', () => {
|
||||
it('sorts by created_at descending (newest first)', () => {
|
||||
const items = [
|
||||
createItem('old', { created_at: '2024-01-01T00:00:00Z' }),
|
||||
createItem('newest', { created_at: '2024-03-01T00:00:00Z' }),
|
||||
createItem('middle', { created_at: '2024-02-01T00:00:00Z' })
|
||||
]
|
||||
const result = sortAssets(items, 'recent')
|
||||
expect(result.map((i) => i.name)).toEqual(['newest', 'middle', 'old'])
|
||||
})
|
||||
|
||||
it('handles null/undefined created_at (sorts to end)', () => {
|
||||
const items = [
|
||||
createItem('no-date'),
|
||||
createItem('has-date', { created_at: '2024-01-01T00:00:00Z' }),
|
||||
createItem('undefined-date', { created_at: undefined })
|
||||
]
|
||||
const result = sortAssets(items, 'recent')
|
||||
expect(result[0].name).toBe('has-date')
|
||||
})
|
||||
})
|
||||
|
||||
describe('immutability', () => {
|
||||
it('does not mutate the original array', () => {
|
||||
const items = [createItem('z'), createItem('a')]
|
||||
const original = [...items]
|
||||
sortAssets(items, 'name-asc')
|
||||
expect(items).toEqual(original)
|
||||
})
|
||||
})
|
||||
})
|
||||
61
src/platform/assets/utils/assetSortUtils.ts
Normal file
61
src/platform/assets/utils/assetSortUtils.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Shared asset sorting utilities
|
||||
* Used by both AssetBrowser and FormDropdown
|
||||
*/
|
||||
|
||||
import type { AssetSortOption } from '../types/filterTypes'
|
||||
|
||||
/**
|
||||
* Minimal interface for sortable items
|
||||
* Works with both AssetItem and FormDropdownItem
|
||||
*/
|
||||
export interface SortableItem {
|
||||
name: string
|
||||
label?: string
|
||||
created_at?: string | null
|
||||
}
|
||||
|
||||
function getDisplayName(item: SortableItem): string {
|
||||
return item.label ?? item.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort items by the specified sort option
|
||||
* @param items - Array of sortable items
|
||||
* @param sortBy - Sort option from AssetSortOption
|
||||
* @returns New sorted array (does not mutate input)
|
||||
*/
|
||||
export function sortAssets<T extends SortableItem>(
|
||||
items: readonly T[],
|
||||
sortBy: AssetSortOption
|
||||
): T[] {
|
||||
if (sortBy === 'default') {
|
||||
return items.slice()
|
||||
}
|
||||
|
||||
const sorted = items.slice()
|
||||
|
||||
switch (sortBy) {
|
||||
case 'name-desc':
|
||||
return sorted.sort((a, b) =>
|
||||
getDisplayName(b).localeCompare(getDisplayName(a), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
})
|
||||
)
|
||||
case 'recent':
|
||||
return sorted.sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at ?? 0).getTime() -
|
||||
new Date(a.created_at ?? 0).getTime()
|
||||
)
|
||||
case 'name-asc':
|
||||
default:
|
||||
return sorted.sort((a, b) =>
|
||||
getDisplayName(a).localeCompare(getDisplayName(b), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user