From 0168a989a02c08a66446b85a4640a7dbb1a48c6a Mon Sep 17 00:00:00 2001 From: Luke Mino-Altherr Date: Fri, 5 Dec 2025 19:24:59 -0800 Subject: [PATCH 1/7] feat: add ownership filter to model browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add dropdown to filter models by ownership (All/My models/Public models). The filter only appears when user has uploaded models. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/locales/en/main.json | 4 + .../assets/components/AssetFilterBar.vue | 31 +++++- .../assets/composables/useAssetBrowser.ts | 13 ++- .../assets/fixtures/ui-mock-assets.ts | 8 +- .../assets/components/AssetFilterBar.test.ts | 88 +++++++++++++++ .../composables/useAssetBrowser.test.ts | 104 +++++++++++++++++- 6 files changed, 240 insertions(+), 8 deletions(-) diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 6bfe081df2..7e01508f55 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2136,6 +2136,10 @@ "sortZA": "Z-A", "sortRecent": "Recent", "sortPopular": "Popular", + "ownership": "Ownership", + "ownershipAll": "All", + "ownershipMyModels": "My models", + "ownershipPublicModels": "Public models", "selectFrameworks": "Select Frameworks", "selectProjects": "Select Projects", "sortingType": "Sorting Type", diff --git a/src/platform/assets/components/AssetFilterBar.vue b/src/platform/assets/components/AssetFilterBar.vue index a86a7a1a84..4d9f6da125 100644 --- a/src/platform/assets/components/AssetFilterBar.vue +++ b/src/platform/assets/components/AssetFilterBar.vue @@ -26,6 +26,16 @@ data-component-id="asset-filter-base-models" @update:model-value="handleFilterChange" /> + +
@@ -46,7 +56,7 @@ diff --git a/src/platform/assets/composables/useAssetBrowser.ts b/src/platform/assets/composables/useAssetBrowser.ts index e5913e94a9..9d9f8b6ffb 100644 --- a/src/platform/assets/composables/useAssetBrowser.ts +++ b/src/platform/assets/composables/useAssetBrowser.ts @@ -35,6 +35,15 @@ function filterByBaseModels(models: string[]) { } } +function filterByOwnership(ownership: string) { + return (asset: AssetItem) => { + if (ownership === 'all') return true + if (ownership === 'my-models') return !asset.is_immutable + if (ownership === 'public-models') return asset.is_immutable + return true + } +} + type AssetBadge = { label: string type: 'type' | 'base' | 'size' @@ -65,7 +74,8 @@ export function useAssetBrowser( const filters = ref({ sortBy: 'recent', fileFormats: [], - baseModels: [] + baseModels: [], + ownership: 'all' }) // Transform API asset to display asset @@ -176,6 +186,7 @@ export function useAssetBrowser( const filtered = searchFiltered.value .filter(filterByFileFormats(filters.value.fileFormats)) .filter(filterByBaseModels(filters.value.baseModels)) + .filter(filterByOwnership(filters.value.ownership)) const sortedAssets = [...filtered] sortedAssets.sort((a, b) => { diff --git a/src/platform/assets/fixtures/ui-mock-assets.ts b/src/platform/assets/fixtures/ui-mock-assets.ts index 482a48af93..a31c7b859c 100644 --- a/src/platform/assets/fixtures/ui-mock-assets.ts +++ b/src/platform/assets/fixtures/ui-mock-assets.ts @@ -146,9 +146,15 @@ export function createAssetWithoutUserMetadata() { return asset } -export function createAssetWithSpecificExtension(extension: string) { +export function createAssetWithSpecificExtension( + extension: string, + isImmutable?: boolean +) { const asset = createMockAssets(1)[0] asset.name = `test-model.${extension}` + if (isImmutable !== undefined) { + asset.is_immutable = isImmutable + } return asset } diff --git a/tests-ui/platform/assets/components/AssetFilterBar.test.ts b/tests-ui/platform/assets/components/AssetFilterBar.test.ts index d9762e9782..4cb434b5ba 100644 --- a/tests-ui/platform/assets/components/AssetFilterBar.test.ts +++ b/tests-ui/platform/assets/components/AssetFilterBar.test.ts @@ -247,5 +247,93 @@ describe('AssetFilterBar', () => { const multiSelects = wrapper.findAllComponents({ name: 'MultiSelect' }) expect(multiSelects).toHaveLength(0) }) + + it('hides ownership filter when no mutable assets', () => { + const assets = [ + createAssetWithSpecificExtension('safetensors', true) // immutable + ] + const wrapper = mountAssetFilterBar({ assets }) + + const ownershipSelects = wrapper + .findAllComponents({ name: 'SingleSelect' }) + .filter( + (component) => component.props('label') === 'assetBrowser.ownership' + ) + + expect(ownershipSelects).toHaveLength(0) + }) + + it('shows ownership filter when mutable assets exist', () => { + const assets = [ + createAssetWithSpecificExtension('safetensors', false) // mutable + ] + const wrapper = mountAssetFilterBar({ assets }) + + const ownershipSelects = wrapper + .findAllComponents({ name: 'SingleSelect' }) + .filter( + (component) => component.props('label') === 'assetBrowser.ownership' + ) + + expect(ownershipSelects).toHaveLength(1) + }) + + it('shows ownership filter when mixed assets exist', () => { + const assets = [ + createAssetWithSpecificExtension('safetensors', true), // immutable + createAssetWithSpecificExtension('ckpt', false) // mutable + ] + const wrapper = mountAssetFilterBar({ assets }) + + const ownershipSelects = wrapper + .findAllComponents({ name: 'SingleSelect' }) + .filter( + (component) => component.props('label') === 'assetBrowser.ownership' + ) + + expect(ownershipSelects).toHaveLength(1) + }) + }) + + describe('Ownership Filter', () => { + it('emits ownership filter changes', async () => { + const assets = [ + createAssetWithSpecificExtension('safetensors', false) // mutable + ] + const wrapper = mountAssetFilterBar({ assets }) + + const ownershipSelect = wrapper + .findAllComponents({ name: 'SingleSelect' }) + .find( + (component) => component.props('label') === 'assetBrowser.ownership' + ) + + await ownershipSelect!.vm.$emit('update:modelValue', 'my-models') + await nextTick() + + const emitted = wrapper.emitted('filterChange') + expect(emitted).toHaveLength(1) + + const filterState = emitted![0][0] as FilterState + expect(filterState.ownership).toBe('my-models') + }) + + it('ownership filter defaults to "all"', async () => { + const assets = [ + createAssetWithSpecificExtension('safetensors', false) // mutable + ] + const wrapper = mountAssetFilterBar({ assets }) + + const sortSelect = wrapper + .findAllComponents({ name: 'SingleSelect' }) + .find((component) => component.props('label') === 'assetBrowser.sortBy') + + await sortSelect!.vm.$emit('update:modelValue', 'recent') + await nextTick() + + const emitted = wrapper.emitted('filterChange') + const filterState = emitted![0][0] as FilterState + expect(filterState.ownership).toBe('all') + }) }) }) diff --git a/tests-ui/platform/assets/composables/useAssetBrowser.test.ts b/tests-ui/platform/assets/composables/useAssetBrowser.test.ts index 92ea78c33a..8c06cc38ec 100644 --- a/tests-ui/platform/assets/composables/useAssetBrowser.test.ts +++ b/tests-ui/platform/assets/composables/useAssetBrowser.test.ts @@ -249,7 +249,8 @@ describe('useAssetBrowser', () => { updateFilters({ sortBy: 'name-asc', fileFormats: ['safetensors'], - baseModels: [] + baseModels: [], + ownership: 'all' }) await nextTick() @@ -284,7 +285,8 @@ describe('useAssetBrowser', () => { updateFilters({ sortBy: 'name-asc', fileFormats: [], - baseModels: ['SDXL'] + baseModels: ['SDXL'], + ownership: 'all' }) await nextTick() @@ -335,7 +337,12 @@ describe('useAssetBrowser', () => { const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets)) - updateFilters({ sortBy: 'name', fileFormats: [], baseModels: [] }) + updateFilters({ + sortBy: 'name', + fileFormats: [], + baseModels: [], + ownership: 'all' + }) await nextTick() const names = filteredAssets.value.map((asset) => asset.name) @@ -355,7 +362,12 @@ describe('useAssetBrowser', () => { const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets)) - updateFilters({ sortBy: 'recent', fileFormats: [], baseModels: [] }) + updateFilters({ + sortBy: 'recent', + fileFormats: [], + baseModels: [], + ownership: 'all' + }) await nextTick() const dates = filteredAssets.value.map((asset) => asset.created_at) @@ -365,6 +377,90 @@ describe('useAssetBrowser', () => { '2024-01-01T00:00:00Z' ]) }) + + it('filters by ownership - all', async () => { + const assets = [ + createApiAsset({ name: 'my-model.safetensors', is_immutable: false }), + createApiAsset({ + name: 'public-model.safetensors', + is_immutable: true + }), + createApiAsset({ + name: 'another-my-model.safetensors', + is_immutable: false + }) + ] + + const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets)) + + updateFilters({ + sortBy: 'name-asc', + fileFormats: [], + baseModels: [], + ownership: 'all' + }) + await nextTick() + + expect(filteredAssets.value).toHaveLength(3) + }) + + it('filters by ownership - my models only', async () => { + const assets = [ + createApiAsset({ name: 'my-model.safetensors', is_immutable: false }), + createApiAsset({ + name: 'public-model.safetensors', + is_immutable: true + }), + createApiAsset({ + name: 'another-my-model.safetensors', + is_immutable: false + }) + ] + + const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets)) + + 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 - public models only', async () => { + const assets = [ + createApiAsset({ name: 'my-model.safetensors', is_immutable: false }), + createApiAsset({ + name: 'public-model.safetensors', + is_immutable: true + }), + createApiAsset({ + name: 'another-public-model.safetensors', + is_immutable: true + }) + ] + + const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets)) + + 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 + ) + }) }) describe('Dynamic Category Extraction', () => { From 9eeaf6ac066684c8b8c8966efff82758f81e0804 Mon Sep 17 00:00:00 2001 From: Luke Mino-Altherr Date: Fri, 5 Dec 2025 19:58:34 -0800 Subject: [PATCH 2/7] refactor: tighten ownership filter typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change FilterState.ownership to use OwnershipOption literal union type - Use strict comparison (=== false/true) for is_immutable checks - Move type definitions before interface to follow dependency order 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../assets/components/AssetFilterBar.vue | 16 ++++++++-------- .../assets/composables/useAssetBrowser.ts | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/platform/assets/components/AssetFilterBar.vue b/src/platform/assets/components/AssetFilterBar.vue index 4d9f6da125..7629e7d294 100644 --- a/src/platform/assets/components/AssetFilterBar.vue +++ b/src/platform/assets/components/AssetFilterBar.vue @@ -65,13 +65,6 @@ import { t } from '@/i18n' import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions' import type { AssetItem } from '@/platform/assets/schemas/assetSchema' -export interface FilterState { - fileFormats: string[] - baseModels: string[] - sortBy: string - ownership: string -} - const SORT_OPTIONS = [ { name: t('assetBrowser.sortRecent'), value: 'recent' }, { name: t('assetBrowser.sortAZ'), value: 'name-asc' }, @@ -92,6 +85,13 @@ type OwnershipOption = (typeof OWNERSHIP_OPTIONS)[number]['value'] const ownershipOptions = [...OWNERSHIP_OPTIONS] +export interface FilterState { + fileFormats: string[] + baseModels: string[] + sortBy: string + ownership: OwnershipOption +} + const { assets = [] } = defineProps<{ assets?: AssetItem[] }>() @@ -105,7 +105,7 @@ const { availableFileFormats, availableBaseModels } = useAssetFilterOptions(assets) const hasMutableAssets = computed(() => - assets.some((asset) => !asset.is_immutable) + assets.some((asset) => asset.is_immutable === false) ) const emit = defineEmits<{ diff --git a/src/platform/assets/composables/useAssetBrowser.ts b/src/platform/assets/composables/useAssetBrowser.ts index 9d9f8b6ffb..083cf0e6e4 100644 --- a/src/platform/assets/composables/useAssetBrowser.ts +++ b/src/platform/assets/composables/useAssetBrowser.ts @@ -38,8 +38,8 @@ function filterByBaseModels(models: string[]) { function filterByOwnership(ownership: string) { return (asset: AssetItem) => { if (ownership === 'all') return true - if (ownership === 'my-models') return !asset.is_immutable - if (ownership === 'public-models') return asset.is_immutable + if (ownership === 'my-models') return asset.is_immutable === false + if (ownership === 'public-models') return asset.is_immutable === true return true } } From a3d9747e524852eebffc979857b0539884b128b3 Mon Sep 17 00:00:00 2001 From: Luke Mino-Altherr Date: Fri, 5 Dec 2025 20:01:06 -0800 Subject: [PATCH 3/7] test: fix SingleSelect selector in AssetFilterBar test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use findAllComponents with label filter to specifically find the sort dropdown instead of the first SingleSelect, which could be the ownership filter when mutable assets are present. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- tests-ui/platform/assets/components/AssetFilterBar.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests-ui/platform/assets/components/AssetFilterBar.test.ts b/tests-ui/platform/assets/components/AssetFilterBar.test.ts index 4cb434b5ba..2d1e70d4b7 100644 --- a/tests-ui/platform/assets/components/AssetFilterBar.test.ts +++ b/tests-ui/platform/assets/components/AssetFilterBar.test.ts @@ -105,8 +105,10 @@ describe('AssetFilterBar', () => { await nextTick() // Update sort - const sortSelect = wrapper.findComponent({ name: 'SingleSelect' }) - await sortSelect.vm.$emit('update:modelValue', 'popular') + const sortSelect = wrapper + .findAllComponents({ name: 'SingleSelect' }) + .find((component) => component.props('label') === 'assetBrowser.sortBy') + await sortSelect!.vm.$emit('update:modelValue', 'popular') await nextTick() From 03791f763d0063104b511222110942f08c4b8d68 Mon Sep 17 00:00:00 2001 From: Luke Mino-Altherr Date: Sat, 6 Dec 2025 13:46:02 -0800 Subject: [PATCH 4/7] feat: persist ownership filter across category changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass allAssets to AssetFilterBar to check for mutable assets across all categories, not just the currently filtered ones. This ensures the ownership filter remains visible when switching categories, providing better UX consistency. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/platform/assets/components/AssetBrowserModal.vue | 1 + src/platform/assets/components/AssetFilterBar.vue | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/platform/assets/components/AssetBrowserModal.vue b/src/platform/assets/components/AssetBrowserModal.vue index ef85ce5f9a..e593861774 100644 --- a/src/platform/assets/components/AssetBrowserModal.vue +++ b/src/platform/assets/components/AssetBrowserModal.vue @@ -48,6 +48,7 @@ diff --git a/src/platform/assets/components/AssetFilterBar.vue b/src/platform/assets/components/AssetFilterBar.vue index 7629e7d294..36bb76023c 100644 --- a/src/platform/assets/components/AssetFilterBar.vue +++ b/src/platform/assets/components/AssetFilterBar.vue @@ -54,7 +54,7 @@
- +