feature: model browser folder grouping (#7892)

This commit is contained in:
Jin Yi
2026-01-09 09:58:06 +09:00
committed by GitHub
parent e26e1f0c9e
commit a2e0c3d596
6 changed files with 120 additions and 6 deletions

View File

@@ -80,7 +80,9 @@
>
{{ contentTitle }}
</h2>
<div class="min-h-0 px-6 pt-0 pb-10 overflow-y-auto">
<div
class="min-h-0 px-6 pt-0 pb-10 overflow-y-auto scrollbar-custom"
>
<slot name="content"></slot>
</div>
</main>

View File

@@ -9,7 +9,7 @@
role="button"
@click="onClick"
>
<div v-if="icon" class="py-0.5">
<div v-if="icon" class="pt-0.5">
<NavIcon :icon="icon" />
</div>
<i v-else class="text-neutral icon-[lucide--folder] text-xs shrink-0" />

View File

@@ -5,7 +5,7 @@
:key="badge.label"
:class="
cn(
'px-2 py-1 rounded text-xs font-bold uppercase tracking-wider text-modal-card-tag-foreground bg-modal-card-tag-background'
'px-2 py-1 rounded text-xs font-bold uppercase tracking-wider text-modal-card-tag-foreground bg-modal-card-tag-background break-all'
)
"
>

View File

@@ -48,7 +48,7 @@
@update:model-value="handleFilterChange"
>
<template #icon>
<i class="icon-[lucide--arrow-up-down] size-3" />
<i class="icon-[lucide--arrow-up-down]" />
</template>
</SingleSelect>
</div>

View File

@@ -98,6 +98,48 @@ describe('useAssetBrowser', () => {
expect(result.description).toBe('loras model')
})
it('removes category prefix from badge labels', () => {
const apiAsset = createApiAsset({
tags: ['models', 'checkpoint/stable-diffusion-v1-5']
})
const { filteredAssets } = useAssetBrowser(ref([apiAsset]))
const result = filteredAssets.value[0]
expect(result.badges).toContainEqual({
label: 'stable-diffusion-v1-5',
type: 'type'
})
})
it('handles tags without slash for badges', () => {
const apiAsset = createApiAsset({
tags: ['models', 'checkpoints']
})
const { filteredAssets } = useAssetBrowser(ref([apiAsset]))
const result = filteredAssets.value[0]
expect(result.badges).toContainEqual({
label: 'checkpoints',
type: 'type'
})
})
it('handles tags with multiple slashes in badges', () => {
const apiAsset = createApiAsset({
tags: ['models', 'checkpoint/subfolder/model-name']
})
const { filteredAssets } = useAssetBrowser(ref([apiAsset]))
const result = filteredAssets.value[0]
expect(result.badges).toContainEqual({
label: 'subfolder/model-name',
type: 'type'
})
})
})
describe('Tag-Based Filtering', () => {
@@ -533,5 +575,58 @@ describe('useAssetBrowser', () => {
selectedCategory.value = 'unknown'
expect(contentTitle.value).toBe('Assets')
})
it('groups models by top-level folder name', () => {
const assets = [
createApiAsset({
id: 'asset-1',
tags: ['models', 'Chatterbox/subfolder1/model1']
}),
createApiAsset({
id: 'asset-2',
tags: ['models', 'Chatterbox/subfolder2/model2']
}),
createApiAsset({
id: 'asset-3',
tags: ['models', 'Chatterbox/subfolder3/model3']
}),
createApiAsset({
id: 'asset-4',
tags: ['models', 'OtherFolder/subfolder1/model4']
})
]
const { availableCategories, selectedCategory, categoryFilteredAssets } =
useAssetBrowser(ref(assets))
// Should group all Chatterbox subfolders under single category
expect(availableCategories.value).toEqual([
{ id: 'all', label: 'All Models', icon: 'icon-[lucide--folder]' },
{
id: 'Chatterbox',
label: 'Chatterbox',
icon: 'icon-[lucide--package]'
},
{
id: 'OtherFolder',
label: 'OtherFolder',
icon: 'icon-[lucide--package]'
}
])
// When selecting Chatterbox category, should include all models from its subfolders
selectedCategory.value = 'Chatterbox'
expect(categoryFilteredAssets.value).toHaveLength(3)
expect(categoryFilteredAssets.value.map((a) => a.id)).toEqual([
'asset-1',
'asset-2',
'asset-3'
])
// When selecting OtherFolder category, should include only its models
selectedCategory.value = 'OtherFolder'
expect(categoryFilteredAssets.value).toHaveLength(1)
expect(categoryFilteredAssets.value[0].id).toBe('asset-4')
})
})
})

View File

@@ -15,7 +15,18 @@ export type OwnershipOption = 'all' | 'my-models' | 'public-models'
function filterByCategory(category: string) {
return (asset: AssetItem) => {
return category === 'all' || asset.tags.includes(category)
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
})
}
}
@@ -93,7 +104,12 @@ export function useAssetBrowser(
// Type badge from non-root tag
if (typeTag) {
badges.push({ label: typeTag, type: 'type' })
// Remove category prefix from badge label (e.g. "checkpoint/model" → "model")
const badgeLabel = typeTag.includes('/')
? typeTag.substring(typeTag.indexOf('/') + 1)
: typeTag
badges.push({ label: badgeLabel, type: 'type' })
}
// Base model badge from metadata
@@ -125,6 +141,7 @@ export function useAssetBrowser(
.filter((asset) => asset.tags[0] === 'models')
.map((asset) => asset.tags[1])
.filter((tag): tag is string => typeof tag === 'string' && tag.length > 0)
.map((tag) => tag.split('/')[0]) // Extract top-level folder name
const uniqueCategories = Array.from(new Set(categories))
.sort()