feat(assets): add ModelInfoPanel for asset browser right panel (#8090)

## Summary

Adds an editable Model Info Panel to show and modify asset details in
the asset browser.

## Changes

- Add `ModelInfoPanel` component with editable display name,
description, model type, base models, and tags
- Add `updateAssetMetadata` action in `assetsStore` with optimistic
cache updates
- Add shadcn-vue `Select` components with design system styling
- Add utility functions in `assetMetadataUtils` for extracting model
metadata
- Convert `BaseModalLayout` right panel state to `defineModel` pattern
- Add slide-in animation and collapse button for right panel
- Add `class` prop to `PropertiesAccordionItem` for custom styling
- Fix keyboard handling: Escape in TagsInput/TextArea doesn't close
parent modal

## Testing

- Unit tests for `ModelInfoPanel` component
- Unit tests for `assetMetadataUtils` functions

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Alexander Brown
2026-01-21 19:43:56 -08:00
committed by GitHub
parent 8261e1d187
commit 93e7a4f9f9
25 changed files with 1198 additions and 314 deletions

View File

@@ -30,6 +30,10 @@ describe('MyStore', () => {
**Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior.
## i18n in Component Tests
Use real `createI18n` with empty messages instead of mocking `vue-i18n`. See `SearchBox.test.ts` for example.
## Mock Patterns
### Reset all mocks at once

View File

@@ -5,25 +5,32 @@ import { cn } from '@/utils/tailwindUtil'
import TransitionCollapse from './TransitionCollapse.vue'
const props = defineProps<{
const {
disabled,
label,
enableEmptyState,
tooltip,
class: className
} = defineProps<{
disabled?: boolean
label?: string
enableEmptyState?: boolean
tooltip?: string
class?: string
}>()
const isCollapse = defineModel<boolean>('collapse', { default: false })
const isExpanded = computed(() => !isCollapse.value && !props.disabled)
const isExpanded = computed(() => !isCollapse.value && !disabled)
const tooltipConfig = computed(() => {
if (!props.tooltip) return undefined
return { value: props.tooltip, showDelay: 1000 }
if (!tooltip) return undefined
return { value: tooltip, showDelay: 1000 }
})
</script>
<template>
<div class="flex flex-col bg-comfy-menu-bg">
<div :class="cn('flex flex-col bg-comfy-menu-bg', className)">
<div
class="sticky top-0 z-10 flex items-center justify-between backdrop-blur-xl bg-inherit"
>

View File

@@ -184,6 +184,7 @@
"source": "Source",
"filter": "Filter",
"apply": "Apply",
"use": "Use",
"enabled": "Enabled",
"installed": "Installed",
"restart": "Restart",
@@ -2413,6 +2414,29 @@
"assetCard": "{name} - {type} asset",
"loadingAsset": "Loading asset"
},
"modelInfo": {
"title": "Model Info",
"selectModelPrompt": "Select a model to see its information",
"basicInfo": "Basic Info",
"displayName": "Display Name",
"fileName": "File Name",
"source": "Source",
"viewOnSource": "View on {source}",
"modelTagging": "Model Tagging",
"modelType": "Model Type",
"selectModelType": "Select model type...",
"compatibleBaseModels": "Compatible Base Models",
"addBaseModel": "Add base model...",
"baseModelUnknown": "Base model unknown",
"additionalTags": "Additional Tags",
"addTag": "Add tag...",
"noAdditionalTags": "No additional tags",
"modelDescription": "Model Description",
"triggerPhrases": "Trigger Phrases",
"description": "Description",
"descriptionNotSet": "No description set",
"descriptionPlaceholder": "Add a description for this model..."
},
"media": {
"threeDModelPlaceholder": "3D Model",
"audioPlaceholder": "Audio"

View File

@@ -1,8 +1,10 @@
<template>
<BaseModalLayout
v-model:right-panel-open="isRightPanelOpen"
data-component-id="AssetBrowserModal"
class="size-full max-h-full max-w-full min-w-0"
:content-title="displayTitle"
:right-panel-title="$t('assetBrowser.modelInfo.title')"
@close="handleClose"
>
<template v-if="shouldShowLeftPanel" #leftPanel>
@@ -21,7 +23,10 @@
</template>
<template #header>
<div class="flex w-full items-center justify-between gap-2">
<div
class="flex w-full items-center justify-between gap-2"
@click.self="focusedAsset = null"
>
<SearchBox
v-model="searchQuery"
:autofocus="true"
@@ -47,8 +52,8 @@
<template #contentFilter>
<AssetFilterBar
:assets="categoryFilteredAssets"
:all-assets="fetchedAssets"
@filter-change="updateFilters"
@click.self="focusedAsset = null"
/>
</template>
@@ -56,16 +61,31 @@
<AssetGrid
:assets="filteredAssets"
:loading="isLoading"
:focused-asset-id="focusedAsset?.id"
:empty-message
@asset-focus="handleAssetFocus"
@asset-select="handleAssetSelectAndEmit"
@asset-deleted="refreshAssets"
@asset-show-info="handleShowInfo"
@click="focusedAsset = null"
/>
</template>
<template #rightPanel>
<ModelInfoPanel v-if="focusedAsset" :asset="focusedAsset" :cache-key />
<div
v-else
class="flex h-full items-center justify-center break-words p-6 text-center text-muted"
>
{{ $t('assetBrowser.modelInfo.selectModelPrompt') }}
</div>
</template>
</BaseModalLayout>
</template>
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { computed, provide } from 'vue'
import { computed, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
@@ -74,8 +94,10 @@ import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
import ModelInfoPanel from '@/platform/assets/components/modelInfo/ModelInfoPanel.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
@@ -132,6 +154,10 @@ 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()
const { isUploadButtonEnabled, showUploadDialog } =
useModelUpload(refreshAssets)
@@ -142,9 +168,13 @@ const {
navItems,
categoryFilteredAssets,
filteredAssets,
isImportedSelected,
updateFilters
} = useAssetBrowser(fetchedAssets)
const focusedAsset = ref<AssetDisplayItem | null>(null)
const isRightPanelOpen = ref(false)
const primaryCategoryTag = computed(() => {
const assets = fetchedAssets.value ?? []
const tagFromAssets = assets
@@ -181,15 +211,30 @@ const shouldShowLeftPanel = computed(() => {
return props.showLeftPanel ?? true
})
const emptyMessage = computed(() => {
if (!isImportedSelected.value) return undefined
return isUploadButtonEnabled.value
? t('assetBrowser.emptyImported.canImport')
: t('assetBrowser.emptyImported.restricted')
})
function handleClose() {
props.onClose?.()
emit('close')
}
function handleAssetFocus(asset: AssetDisplayItem) {
focusedAsset.value = asset
}
function handleShowInfo(asset: AssetDisplayItem) {
focusedAsset.value = asset
isRightPanelOpen.value = true
}
function handleAssetSelectAndEmit(asset: AssetDisplayItem) {
emit('asset-select', asset)
// onSelect callback is provided by dialog composable layer
// It handles the appropriate transformation (filename extraction or full asset)
props.onSelect?.(asset)
}
</script>

View File

@@ -9,30 +9,28 @@
cn(
'rounded-2xl overflow-hidden transition-all duration-200 bg-modal-card-background p-2 gap-2 flex flex-col h-full',
interactive &&
'group appearance-none bg-transparent m-0 outline-none text-left hover:bg-secondary-background focus:bg-secondary-background border-none focus:outline-solid outline-base-foreground outline-4'
'group appearance-none bg-transparent m-0 outline-none text-left hover:bg-secondary-background focus:bg-secondary-background border-none focus:outline-solid outline-base-foreground outline-4',
focused && 'bg-secondary-background outline-solid'
)
"
@click.stop="interactive && $emit('focus', asset)"
@focus="interactive && $emit('focus', asset)"
@keydown.enter.self="interactive && $emit('select', asset)"
>
<div class="relative aspect-square w-full overflow-hidden rounded-xl">
<div
v-if="isLoading || error"
class="flex size-full cursor-pointer items-center justify-center bg-gradient-to-br from-smoke-400 via-smoke-800 to-charcoal-400"
role="button"
@click.self="interactive && $emit('select', asset)"
/>
<img
v-else
:src="asset.preview_url"
:alt="displayName"
class="size-full object-cover cursor-pointer"
role="button"
@click.self="interactive && $emit('select', asset)"
/>
<AssetBadgeGroup :badges="asset.badges" />
<IconGroup
v-if="showAssetOptions"
:class="
cn(
'absolute top-2 right-2 invisible group-hover:visible',
@@ -40,18 +38,21 @@
)
"
>
<MoreButton ref="dropdown-menu-button" size="sm">
<Button
v-tooltip.bottom="$t('assetBrowser.modelInfo.title')"
:aria-label="$t('assetBrowser.modelInfo.title')"
variant="secondary"
size="sm"
@click.stop="$emit('showInfo', asset)"
>
<i class="icon-[lucide--info]" />
</Button>
<MoreButton
v-if="showAssetOptions"
ref="dropdown-menu-button"
size="sm"
>
<template #default>
<Button
v-if="flags.assetRenameEnabled"
variant="secondary"
size="md"
class="justify-start"
@click="startAssetRename"
>
<i class="icon-[lucide--pencil]" />
<span>{{ $t('g.rename') }}</span>
</Button>
<Button
v-if="flags.assetDeletionEnabled"
variant="secondary"
@@ -72,43 +73,59 @@
v-tooltip.top="{ value: displayName, showDelay: tooltipDelay }"
:class="
cn(
'mb-2 m-0 text-base font-semibold line-clamp-2 wrap-anywhere',
'm-0 text-sm font-semibold line-clamp-2 wrap-anywhere',
'text-base-foreground'
)
"
>
<EditableText
:model-value="displayName"
:is-editing="isEditing"
:input-attrs="{ 'data-testid': 'asset-name-input' }"
@edit="assetRename"
@cancel="assetRename()"
/>
{{ displayName }}
</h3>
<p
:id="descId"
v-tooltip.top="{ value: asset.description, showDelay: tooltipDelay }"
:class="
cn(
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box] text-muted-foreground'
'm-0 text-sm line-clamp-2 [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box] text-muted-foreground'
)
"
>
{{ asset.description }}
</p>
<div class="flex gap-4 text-xs text-muted-foreground mt-auto">
<span v-if="asset.stats.stars" class="flex items-center gap-1">
<i class="icon-[lucide--star] size-3" />
{{ asset.stats.stars }}
</span>
<span v-if="asset.stats.downloadCount" class="flex items-center gap-1">
<i class="icon-[lucide--download] size-3" />
{{ asset.stats.downloadCount }}
</span>
<span v-if="asset.stats.formattedDate" class="flex items-center gap-1">
<i class="icon-[lucide--clock] size-3" />
{{ asset.stats.formattedDate }}
</span>
<div class="flex items-center justify-between gap-2 mt-auto">
<div class="flex gap-3 text-xs text-muted-foreground">
<span v-if="asset.stats.stars" class="flex items-center gap-1">
<i class="icon-[lucide--star] size-3" />
{{ asset.stats.stars }}
</span>
<span
v-if="asset.stats.downloadCount"
class="flex items-center gap-1"
>
<i class="icon-[lucide--download] size-3" />
{{ asset.stats.downloadCount }}
</span>
<span
v-if="asset.stats.formattedDate"
class="flex items-center gap-1"
>
<i class="icon-[lucide--clock] size-3" />
{{ asset.stats.formattedDate }}
</span>
</div>
<Button
v-if="interactive"
variant="secondary"
size="lg"
class="shrink-0 relative"
@click.stop="handleSelect"
>
{{ $t('g.use') }}
<StatusBadge
v-if="isNewlyImported"
severity="contrast"
class="absolute -top-0.5 -right-0.5"
/>
</Button>
</div>
</div>
</div>
@@ -121,33 +138,37 @@ import { useI18n } from 'vue-i18n'
import IconGroup from '@/components/button/IconGroup.vue'
import MoreButton from '@/components/button/MoreButton.vue'
import EditableText from '@/components/common/EditableText.vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
import Button from '@/components/ui/button/Button.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { assetService } from '@/platform/assets/services/assetService'
import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
const { asset, interactive } = defineProps<{
const { asset, interactive, focused } = defineProps<{
asset: AssetDisplayItem
interactive?: boolean
focused?: boolean
}>()
const emit = defineEmits<{
focus: [asset: AssetDisplayItem]
select: [asset: AssetDisplayItem]
deleted: [asset: AssetDisplayItem]
showInfo: [asset: AssetDisplayItem]
}>()
const { t } = useI18n()
const settingStore = useSettingStore()
const { closeDialog } = useDialogStore()
const { flags } = useFeatureFlags()
const toastStore = useToastStore()
const { isDownloadedThisSession, acknowledgeAsset } = useAssetDownloadStore()
const dropdownMenuButton = useTemplateRef<InstanceType<typeof MoreButton>>(
'dropdown-menu-button'
@@ -156,10 +177,9 @@ const dropdownMenuButton = useTemplateRef<InstanceType<typeof MoreButton>>(
const titleId = useId()
const descId = useId()
const isEditing = ref(false)
const newNameRef = ref<string>()
const displayName = computed(() => getAssetDisplayName(asset))
const displayName = computed(() => newNameRef.value ?? asset.name)
const isNewlyImported = computed(() => isDownloadedThisSession(asset.id))
const showAssetOptions = computed(
() =>
@@ -176,6 +196,11 @@ const { isLoading, error } = useImage({
alt: asset.name
})
function handleSelect() {
acknowledgeAsset(asset.id)
emit('select', asset)
}
function confirmDeletion() {
dropdownMenuButton.value?.hide()
const assetName = toValue(displayName)
@@ -225,32 +250,4 @@ function confirmDeletion() {
}
})
}
function startAssetRename() {
dropdownMenuButton.value?.hide()
isEditing.value = true
}
async function assetRename(newName?: string) {
isEditing.value = false
if (newName) {
// Optimistic update
newNameRef.value = newName
try {
const result = await assetService.updateAsset(asset.id, {
name: newName
})
// Update with the actual name once the server responds
newNameRef.value = result.name
} catch (err: unknown) {
console.error(err)
toastStore.add({
severity: 'error',
summary: t('assetBrowser.rename.failed'),
life: 10_000
})
newNameRef.value = undefined
}
}
}
</script>

View File

@@ -10,11 +10,15 @@ import {
createAssetWithoutBaseModel
} from '@/platform/assets/fixtures/ui-mock-assets'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { createI18n } from 'vue-i18n'
// Mock @/i18n directly since component imports { t } from '@/i18n'
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {}
}
})
// Mock components with minimal functionality for business logic testing
vi.mock('@/components/input/MultiSelect.vue', () => ({
@@ -66,9 +70,7 @@ function mountAssetFilterBar(props = {}) {
return mount(AssetFilterBar, {
props,
global: {
mocks: {
$t: (key: string) => key
}
plugins: [i18n]
}
})
}
@@ -86,10 +88,6 @@ function findBaseModelsFilter(wrapper: ReturnType<typeof mountAssetFilterBar>) {
return wrapper.findComponent('[data-component-id="asset-filter-base-models"]')
}
function findOwnershipFilter(wrapper: ReturnType<typeof mountAssetFilterBar>) {
return wrapper.findComponent('[data-component-id="asset-filter-ownership"]')
}
function findSortFilter(wrapper: ReturnType<typeof mountAssetFilterBar>) {
return wrapper.findComponent('[data-component-id="asset-filter-sort"]')
}
@@ -268,90 +266,5 @@ describe('AssetFilterBar', () => {
expect(fileFormatSelect.exists()).toBe(false)
expect(baseModelSelect.exists()).toBe(false)
})
it('hides ownership filter when no mutable assets', () => {
const assets = [
createAssetWithSpecificExtension('safetensors', true) // immutable
]
const wrapper = mountAssetFilterBar({ assets })
const ownershipSelect = findOwnershipFilter(wrapper)
expect(ownershipSelect.exists()).toBe(false)
})
it('shows ownership filter when mutable assets exist', () => {
const assets = [
createAssetWithSpecificExtension('safetensors', false) // mutable
]
const wrapper = mountAssetFilterBar({ assets })
const ownershipSelect = findOwnershipFilter(wrapper)
expect(ownershipSelect.exists()).toBe(true)
})
it('shows ownership filter when mixed assets exist', () => {
const assets = [
createAssetWithSpecificExtension('safetensors', true), // immutable
createAssetWithSpecificExtension('ckpt', false) // mutable
]
const wrapper = mountAssetFilterBar({ assets })
const ownershipSelect = findOwnershipFilter(wrapper)
expect(ownershipSelect.exists()).toBe(true)
})
it('shows ownership filter with allAssets when provided', () => {
const assets = [
createAssetWithSpecificExtension('safetensors', true) // immutable
]
const allAssets = [
createAssetWithSpecificExtension('safetensors', true), // immutable
createAssetWithSpecificExtension('ckpt', false) // mutable
]
const wrapper = mountAssetFilterBar({ assets, allAssets })
const ownershipSelect = findOwnershipFilter(wrapper)
expect(ownershipSelect.exists()).toBe(true)
})
})
describe('Ownership Filter', () => {
it('emits ownership filter changes', async () => {
const assets = [
createAssetWithSpecificExtension('safetensors', false) // mutable
]
const wrapper = mountAssetFilterBar({ assets })
const ownershipSelect = findOwnershipFilter(wrapper)
expect(ownershipSelect.exists()).toBe(true)
const ownershipSelectElement = ownershipSelect.find('select')
ownershipSelectElement.element.value = 'my-models'
await ownershipSelectElement.trigger('change')
await nextTick()
const emitted = wrapper.emitted('filterChange')
expect(emitted).toBeTruthy()
const filterState = emitted![emitted!.length - 1][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 = findSortFilter(wrapper)
const sortSelectElement = sortSelect.find('select')
sortSelectElement.element.value = 'recent'
await sortSelectElement.trigger('change')
await nextTick()
const emitted = wrapper.emitted('filterChange')
const filterState = emitted![0][0] as FilterState
expect(filterState.ownership).toBe('all')
})
})
})

View File

@@ -26,16 +26,6 @@
data-component-id="asset-filter-base-models"
@update:model-value="handleFilterChange"
/>
<SingleSelect
v-if="hasMutableAssets"
v-model="ownership"
:label="$t('assetBrowser.ownership')"
:options="ownershipOptions"
class="min-w-42"
data-component-id="asset-filter-ownership"
@update:model-value="handleFilterChange"
/>
</div>
<div class="flex items-center" data-component-id="asset-filter-bar-right">
@@ -57,56 +47,41 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import type { SelectOption } from '@/components/input/types'
import { t } from '@/i18n'
import type { OwnershipOption } from '@/platform/assets/composables/useAssetBrowser'
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
const SORT_OPTIONS = [
{ name: t('assetBrowser.sortRecent'), value: 'recent' },
{ name: t('assetBrowser.sortAZ'), value: 'name-asc' },
{ name: t('assetBrowser.sortZA'), value: 'name-desc' }
] as const
const { t } = useI18n()
type SortOption = (typeof SORT_OPTIONS)[number]['value']
type SortOption = 'recent' | 'name-asc' | 'name-desc'
const sortOptions = [...SORT_OPTIONS]
const ownershipOptions = [
{ name: t('assetBrowser.ownershipAll'), value: 'all' },
{ name: t('assetBrowser.ownershipMyModels'), value: 'my-models' },
{ name: t('assetBrowser.ownershipPublicModels'), value: 'public-models' }
]
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: string
ownership: OwnershipOption
sortBy: SortOption
}
const { assets = [], allAssets = [] } = defineProps<{
const { assets = [] } = defineProps<{
assets?: AssetItem[]
allAssets?: AssetItem[]
}>()
const fileFormats = ref<SelectOption[]>([])
const baseModels = ref<SelectOption[]>([])
const sortBy = ref<SortOption>('recent')
const ownership = ref<OwnershipOption>('all')
const { availableFileFormats, availableBaseModels } =
useAssetFilterOptions(assets)
const hasMutableAssets = computed(() => {
const assetsToCheck = allAssets.length ? allAssets : assets
return assetsToCheck.some((asset) => asset.is_immutable === false)
})
const emit = defineEmits<{
filterChange: [filters: FilterState]
}>()
@@ -115,8 +90,7 @@ function handleFilterChange() {
emit('filterChange', {
fileFormats: fileFormats.value.map((option: SelectOption) => option.value),
baseModels: baseModels.value.map((option: SelectOption) => option.value),
sortBy: sortBy.value,
ownership: ownership.value
sortBy: sortBy.value
})
}
</script>

View File

@@ -19,9 +19,11 @@
>
<i class="mb-4 icon-[lucide--search] size-10" />
<h3 class="mb-2 text-lg font-medium">
{{ $t('assetBrowser.noAssetsFound') }}
{{ emptyTitle ?? $t('assetBrowser.noAssetsFound') }}
</h3>
<p class="text-sm">{{ $t('assetBrowser.tryAdjustingFilters') }}</p>
<p class="text-sm">
{{ emptyMessage ?? $t('assetBrowser.tryAdjustingFilters') }}
</p>
</div>
<VirtualGrid
v-else
@@ -35,8 +37,11 @@
<AssetCard
:asset="item"
:interactive="true"
:focused="item.id === focusedAssetId"
@focus="$emit('assetFocus', $event)"
@select="$emit('assetSelect', $event)"
@deleted="$emit('assetDeleted', $event)"
@show-info="$emit('assetShowInfo', $event)"
/>
</template>
</VirtualGrid>
@@ -52,14 +57,19 @@ import VirtualGrid from '@/components/common/VirtualGrid.vue'
import AssetCard from '@/platform/assets/components/AssetCard.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
const { assets } = defineProps<{
const { assets, focusedAssetId, emptyTitle, emptyMessage } = defineProps<{
assets: AssetDisplayItem[]
loading?: boolean
focusedAssetId?: string | null
emptyTitle?: string
emptyMessage?: string
}>()
defineEmits<{
assetFocus: [asset: AssetDisplayItem]
assetSelect: [asset: AssetDisplayItem]
assetDeleted: [asset: AssetDisplayItem]
assetShowInfo: [asset: AssetDisplayItem]
}>()
const assetsWithKey = computed(() =>
@@ -73,7 +83,7 @@ const isLg = breakpoints.greaterOrEqual('lg')
const isMd = breakpoints.greaterOrEqual('md')
const maxColumns = computed(() => {
if (is2Xl.value) return 5
if (isXl.value) return 4
if (isXl.value) return 3
if (isLg.value) return 3
if (isMd.value) return 2
return 1

View File

@@ -0,0 +1,12 @@
<template>
<div class="flex flex-col gap-1 px-4 py-2 text-sm text-muted-foreground">
<span>{{ label }}</span>
<slot />
</div>
</template>
<script setup lang="ts">
defineProps<{
label: string
}>()
</script>

View File

@@ -0,0 +1,165 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import ModelInfoPanel from './ModelInfoPanel.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
describe('ModelInfoPanel', () => {
const createMockAsset = (
overrides: Partial<AssetDisplayItem> = {}
): AssetDisplayItem => ({
id: 'test-id',
name: 'test-model.safetensors',
asset_hash: 'hash123',
size: 1024,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
last_access_time: '2024-01-01T00:00:00Z',
description: 'A test model description',
badges: [],
stats: {},
...overrides
})
const mountPanel = (asset: AssetDisplayItem) => {
return mount(ModelInfoPanel, {
props: { asset },
global: {
plugins: [createTestingPinia({ stubActions: false }), i18n]
}
})
}
describe('Basic Info Section', () => {
it('renders basic info section', () => {
const wrapper = mountPanel(createMockAsset())
expect(wrapper.text()).toContain('assetBrowser.modelInfo.basicInfo')
})
it('displays asset filename', () => {
const asset = createMockAsset({ name: 'my-model.safetensors' })
const wrapper = mountPanel(asset)
expect(wrapper.text()).toContain('my-model.safetensors')
})
it('displays name from user_metadata when present', () => {
const asset = createMockAsset({
user_metadata: { name: 'My Custom Model' }
})
const wrapper = mountPanel(asset)
expect(wrapper.text()).toContain('My Custom Model')
})
it('falls back to asset name when user_metadata.name not present', () => {
const asset = createMockAsset({ name: 'fallback-model.safetensors' })
const wrapper = mountPanel(asset)
expect(wrapper.text()).toContain('fallback-model.safetensors')
})
it('renders source link when source_arn is present', () => {
const asset = createMockAsset({
user_metadata: { source_arn: 'civitai:model:123:version:456' }
})
const wrapper = mountPanel(asset)
const link = wrapper.find(
'a[href="https://civitai.com/models/123?modelVersionId=456"]'
)
expect(link.exists()).toBe(true)
expect(link.attributes('target')).toBe('_blank')
})
it('displays Civitai icon for Civitai source', () => {
const asset = createMockAsset({
user_metadata: { source_arn: 'civitai:model:123:version:456' }
})
const wrapper = mountPanel(asset)
expect(
wrapper.find('img[src="/assets/images/civitai.svg"]').exists()
).toBe(true)
})
it('does not render source field when source_arn is absent', () => {
const asset = createMockAsset()
const wrapper = mountPanel(asset)
const links = wrapper.findAll('a')
expect(links).toHaveLength(0)
})
})
describe('Model Tagging Section', () => {
it('renders model tagging section', () => {
const wrapper = mountPanel(createMockAsset())
expect(wrapper.text()).toContain('assetBrowser.modelInfo.modelTagging')
})
it('renders model type field', () => {
const wrapper = mountPanel(createMockAsset())
expect(wrapper.text()).toContain('assetBrowser.modelInfo.modelType')
})
it('renders base models field', () => {
const asset = createMockAsset({
user_metadata: { base_model: ['SDXL'] }
})
const wrapper = mountPanel(asset)
expect(wrapper.text()).toContain(
'assetBrowser.modelInfo.compatibleBaseModels'
)
})
it('renders additional tags field', () => {
const wrapper = mountPanel(createMockAsset())
expect(wrapper.text()).toContain('assetBrowser.modelInfo.additionalTags')
})
})
describe('Model Description Section', () => {
it('renders trigger phrases when present', () => {
const asset = createMockAsset({
user_metadata: { trained_words: ['trigger1', 'trigger2'] }
})
const wrapper = mountPanel(asset)
expect(wrapper.text()).toContain('trigger1')
expect(wrapper.text()).toContain('trigger2')
})
it('renders description section', () => {
const wrapper = mountPanel(createMockAsset())
expect(wrapper.text()).toContain(
'assetBrowser.modelInfo.modelDescription'
)
})
it('does not render trigger phrases field when empty', () => {
const asset = createMockAsset()
const wrapper = mountPanel(asset)
expect(wrapper.text()).not.toContain(
'assetBrowser.modelInfo.triggerPhrases'
)
})
})
describe('Accordion Structure', () => {
it('renders all three section labels', () => {
const wrapper = mountPanel(createMockAsset())
expect(wrapper.text()).toContain('assetBrowser.modelInfo.basicInfo')
expect(wrapper.text()).toContain('assetBrowser.modelInfo.modelTagging')
expect(wrapper.text()).toContain(
'assetBrowser.modelInfo.modelDescription'
)
})
})
})

View File

@@ -0,0 +1,296 @@
<template>
<div
data-component-id="ModelInfoPanel"
class="flex h-full flex-col scrollbar-custom"
>
<PropertiesAccordionItem :class="accordionClass">
<template #label>
<span class="text-xs uppercase font-inter">
{{ t('assetBrowser.modelInfo.basicInfo') }}
</span>
</template>
<ModelInfoField :label="t('assetBrowser.modelInfo.displayName')">
<EditableText
:model-value="displayName"
:is-editing="isEditingDisplayName"
:class="cn('break-all', !isImmutable && 'text-base-foreground')"
@dblclick="isEditingDisplayName = !isImmutable"
@edit="handleDisplayNameEdit"
@cancel="isEditingDisplayName = false"
/>
</ModelInfoField>
<ModelInfoField :label="t('assetBrowser.modelInfo.fileName')">
<span class="break-all">{{ asset.name }}</span>
</ModelInfoField>
<ModelInfoField
v-if="sourceUrl"
:label="t('assetBrowser.modelInfo.source')"
>
<a
:href="sourceUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 text-muted-foreground no-underline transition-colors hover:text-foreground"
>
<img
v-if="sourceName === 'Civitai'"
src="/assets/images/civitai.svg"
alt=""
class="size-4 shrink-0"
/>
{{ t('assetBrowser.modelInfo.viewOnSource', { source: sourceName }) }}
<i class="icon-[lucide--external-link] size-4 shrink-0" />
</a>
</ModelInfoField>
</PropertiesAccordionItem>
<PropertiesAccordionItem :class="accordionClass">
<template #label>
<span class="text-xs uppercase font-inter">
{{ t('assetBrowser.modelInfo.modelTagging') }}
</span>
</template>
<ModelInfoField :label="t('assetBrowser.modelInfo.modelType')">
<Select v-model="selectedModelType" :disabled="isImmutable">
<SelectTrigger class="w-full">
<SelectValue
:placeholder="t('assetBrowser.modelInfo.selectModelType')"
/>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in modelTypes"
:key="option.value"
:value="option.value"
>
{{ option.name }}
</SelectItem>
</SelectContent>
</Select>
</ModelInfoField>
<ModelInfoField :label="t('assetBrowser.modelInfo.compatibleBaseModels')">
<TagsInput
v-slot="{ isEmpty }"
v-model="baseModels"
:disabled="isImmutable"
>
<TagsInputItem
v-for="model in baseModels"
:key="model"
:value="model"
>
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput
:is-empty="isEmpty"
:placeholder="
isImmutable
? t('assetBrowser.modelInfo.baseModelUnknown')
: t('assetBrowser.modelInfo.addBaseModel')
"
/>
</TagsInput>
</ModelInfoField>
<ModelInfoField :label="t('assetBrowser.modelInfo.additionalTags')">
<TagsInput
v-slot="{ isEmpty }"
v-model="additionalTags"
:disabled="isImmutable"
>
<TagsInputItem v-for="tag in additionalTags" :key="tag" :value="tag">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput
:is-empty="isEmpty"
:placeholder="
isImmutable
? t('assetBrowser.modelInfo.noAdditionalTags')
: t('assetBrowser.modelInfo.addTag')
"
/>
</TagsInput>
</ModelInfoField>
</PropertiesAccordionItem>
<PropertiesAccordionItem :class="accordionClass">
<template #label>
<span class="text-xs uppercase font-inter">
{{ t('assetBrowser.modelInfo.modelDescription') }}
</span>
</template>
<ModelInfoField
v-if="triggerPhrases.length > 0"
:label="t('assetBrowser.modelInfo.triggerPhrases')"
>
<div class="flex flex-wrap gap-1">
<span
v-for="phrase in triggerPhrases"
:key="phrase"
class="rounded px-2 py-0.5 text-xs"
>
{{ phrase }}
</span>
</div>
</ModelInfoField>
<ModelInfoField
v-if="description"
:label="t('assetBrowser.modelInfo.description')"
>
<p class="text-sm whitespace-pre-wrap">{{ description }}</p>
</ModelInfoField>
<ModelInfoField :label="t('assetBrowser.modelInfo.description')">
<textarea
ref="descriptionTextarea"
v-model="userDescription"
:disabled="isImmutable"
:placeholder="
isImmutable
? t('assetBrowser.modelInfo.descriptionNotSet')
: t('assetBrowser.modelInfo.descriptionPlaceholder')
"
rows="3"
:class="
cn(
'w-full resize-y rounded-lg border border-transparent bg-transparent px-3 py-2 text-sm text-component-node-foreground outline-none transition-colors focus:bg-component-node-widget-background',
isImmutable && 'cursor-not-allowed'
)
"
@keydown.escape.stop="descriptionTextarea?.blur()"
/>
</ModelInfoField>
</PropertiesAccordionItem>
</div>
</template>
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import TagsInput from '@/components/ui/tags-input/TagsInput.vue'
import TagsInputInput from '@/components/ui/tags-input/TagsInputInput.vue'
import TagsInputItem from '@/components/ui/tags-input/TagsInputItem.vue'
import TagsInputItemDelete from '@/components/ui/tags-input/TagsInputItemDelete.vue'
import TagsInputItemText from '@/components/ui/tags-input/TagsInputItemText.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import type { AssetUserMetadata } from '@/platform/assets/schemas/assetSchema'
import {
getAssetAdditionalTags,
getAssetBaseModels,
getAssetDescription,
getAssetDisplayName,
getAssetModelType,
getAssetSourceUrl,
getAssetTriggerPhrases,
getAssetUserDescription,
getSourceName
} from '@/platform/assets/utils/assetMetadataUtils'
import { useAssetsStore } from '@/stores/assetsStore'
import { cn } from '@/utils/tailwindUtil'
import ModelInfoField from './ModelInfoField.vue'
const { t } = useI18n()
const descriptionTextarea = useTemplateRef<HTMLTextAreaElement>(
'descriptionTextarea'
)
const accordionClass = cn(
'bg-modal-panel-background border-t border-border-default'
)
const { asset, cacheKey } = defineProps<{
asset: AssetDisplayItem
cacheKey?: string
}>()
const assetsStore = useAssetsStore()
const { modelTypes } = useModelTypes()
const pendingUpdates = ref<AssetUserMetadata>({})
const isEditingDisplayName = ref(false)
const isImmutable = computed(() => asset.is_immutable ?? true)
const displayName = computed(
() => pendingUpdates.value.name ?? getAssetDisplayName(asset)
)
const sourceUrl = computed(() => getAssetSourceUrl(asset))
const sourceName = computed(() =>
sourceUrl.value ? getSourceName(sourceUrl.value) : ''
)
const description = computed(() => getAssetDescription(asset))
const triggerPhrases = computed(() => getAssetTriggerPhrases(asset))
watch(
() => asset.user_metadata,
() => {
pendingUpdates.value = {}
}
)
const debouncedFlushMetadata = useDebounceFn(() => {
if (isImmutable.value) return
assetsStore.updateAssetMetadata(
asset.id,
{ ...(asset.user_metadata ?? {}), ...pendingUpdates.value },
cacheKey
)
}, 500)
function queueMetadataUpdate(updates: AssetUserMetadata) {
pendingUpdates.value = { ...pendingUpdates.value, ...updates }
debouncedFlushMetadata()
}
function handleDisplayNameEdit(newName: string) {
isEditingDisplayName.value = false
if (newName && newName !== displayName.value) {
queueMetadataUpdate({ name: newName })
}
}
const debouncedSaveModelType = useDebounceFn((newModelType: string) => {
if (isImmutable.value) return
const currentModelType = getAssetModelType(asset)
if (currentModelType === newModelType) return
const newTags = asset.tags
.filter((tag) => tag !== currentModelType)
.concat(newModelType)
assetsStore.updateAssetTags(asset.id, newTags, cacheKey)
}, 500)
const baseModels = computed({
get: () => pendingUpdates.value.base_model ?? getAssetBaseModels(asset),
set: (value: string[]) => queueMetadataUpdate({ base_model: value })
})
const additionalTags = computed({
get: () =>
pendingUpdates.value.additional_tags ?? getAssetAdditionalTags(asset),
set: (value: string[]) => queueMetadataUpdate({ additional_tags: value })
})
const userDescription = computed({
get: () =>
pendingUpdates.value.user_description ?? getAssetUserDescription(asset),
set: (value: string) => queueMetadataUpdate({ user_description: value })
})
const selectedModelType = computed({
get: () => getAssetModelType(asset) ?? undefined,
set: (value: string | undefined) => {
if (value) debouncedSaveModelType(value)
}
})
</script>

View File

@@ -295,8 +295,7 @@ describe('useAssetBrowser', () => {
updateFilters({
sortBy: 'name-asc',
fileFormats: ['safetensors'],
baseModels: [],
ownership: 'all'
baseModels: []
})
await nextTick()
@@ -331,8 +330,7 @@ describe('useAssetBrowser', () => {
updateFilters({
sortBy: 'name-asc',
fileFormats: [],
baseModels: ['SDXL'],
ownership: 'all'
baseModels: ['SDXL']
})
await nextTick()
@@ -384,10 +382,9 @@ describe('useAssetBrowser', () => {
const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets))
updateFilters({
sortBy: 'name',
sortBy: 'name-asc',
fileFormats: [],
baseModels: [],
ownership: 'all'
baseModels: []
})
await nextTick()
@@ -411,8 +408,7 @@ describe('useAssetBrowser', () => {
updateFilters({
sortBy: 'recent',
fileFormats: [],
baseModels: [],
ownership: 'all'
baseModels: []
})
await nextTick()
@@ -444,8 +440,7 @@ describe('useAssetBrowser', () => {
updateFilters({
sortBy: 'name-asc',
fileFormats: [],
baseModels: [],
ownership: 'all'
baseModels: []
})
await nextTick()

View File

@@ -8,13 +8,14 @@ import { d, t } from '@/i18n'
import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getAssetBaseModel,
getAssetDescription
getAssetBaseModels,
getAssetDescription,
getAssetDisplayName
} from '@/platform/assets/utils/assetMetadataUtils'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
export type OwnershipOption = 'all' | 'my-models' | 'public-models'
type OwnershipOption = 'all' | 'my-models' | 'public-models'
type NavId = 'all' | 'imported' | (string & {})
@@ -48,8 +49,8 @@ function filterByBaseModels(models: string[]) {
return (asset: AssetItem) => {
if (models.length === 0) return true
const modelSet = new Set(models)
const baseModel = getAssetBaseModel(asset)
return baseModel ? modelSet.has(baseModel) : false
const assetBaseModels = getAssetBaseModels(asset)
return assetBaseModels.some((model) => modelSet.has(model))
}
}
@@ -95,8 +96,7 @@ export function useAssetBrowser(
const filters = ref<FilterState>({
sortBy: 'recent',
fileFormats: [],
baseModels: [],
ownership: 'all'
baseModels: []
})
const selectedOwnership = computed<OwnershipOption>(() => {
@@ -135,18 +135,17 @@ export function useAssetBrowser(
badges.push({ label: badgeLabel, type: 'type' })
}
// Base model badge from metadata
const baseModel = getAssetBaseModel(asset)
if (baseModel) {
badges.push({
label: baseModel,
type: 'base'
})
// Base model badges from metadata
const baseModels = getAssetBaseModels(asset)
for (const model of baseModels) {
badges.push({ label: model, type: 'base' })
}
// Create display stats from API data
const stats = {
formattedDate: d(new Date(asset.created_at), { dateStyle: 'short' }),
formattedDate: asset.created_at
? d(new Date(asset.created_at), { dateStyle: 'short' })
: undefined,
downloadCount: undefined, // Not available in API
stars: undefined // Not available in API
}
@@ -235,7 +234,13 @@ export function useAssetBrowser(
fuseOptions: {
keys: [
{ name: 'name', weight: 0.4 },
{ name: 'tags', weight: 0.3 }
{ name: 'tags', weight: 0.3 },
{ name: 'user_metadata.name', weight: 0.4 },
{ name: 'user_metadata.additional_tags', weight: 0.3 },
{ name: 'user_metadata.trained_words', weight: 0.3 },
{ name: 'user_metadata.user_description', weight: 0.3 },
{ name: 'metadata.name', weight: 0.4 },
{ name: 'metadata.trained_words', weight: 0.3 }
],
threshold: 0.4, // Higher threshold for typo tolerance (0.0 = exact, 1.0 = match all)
ignoreLocation: true, // Search anywhere in the string, not just at the beginning
@@ -264,16 +269,15 @@ export function useAssetBrowser(
sortedAssets.sort((a, b) => {
switch (filters.value.sortBy) {
case 'name-desc':
return b.name.localeCompare(a.name)
return getAssetDisplayName(b).localeCompare(getAssetDisplayName(a))
case 'recent':
return (
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
new Date(b.created_at ?? 0).getTime() -
new Date(a.created_at ?? 0).getTime()
)
case 'popular':
return a.name.localeCompare(b.name)
case 'name-asc':
default:
return a.name.localeCompare(b.name)
return getAssetDisplayName(a).localeCompare(getAssetDisplayName(b))
}
})

View File

@@ -4,6 +4,7 @@ import type { MaybeRefOrGetter } from 'vue'
import type { SelectOption } from '@/components/input/types'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getAssetBaseModels } from '@/platform/assets/utils/assetMetadataUtils'
/**
* Composable that extracts available filter options from asset data
@@ -37,12 +38,7 @@ export function useAssetFilterOptions(assets: MaybeRefOrGetter<AssetItem[]>) {
*/
const availableBaseModels = computed<SelectOption[]>(() => {
const assetList = toValue(assets)
const models = assetList
.map((asset) => asset.user_metadata?.base_model)
.filter(
(baseModel): baseModel is string =>
baseModel !== undefined && typeof baseModel === 'string'
)
const models = assetList.flatMap((asset) => getAssetBaseModels(asset))
const uniqueModels = uniqWith(models, (a, b) => a === b)

View File

@@ -46,9 +46,10 @@ const DISALLOWED_MODEL_TYPES = ['nlf'] as const
export const useModelTypes = createSharedComposable(() => {
const {
state: modelTypes,
isReady,
isLoading,
error,
execute: fetchModelTypes
execute
} = useAsyncState(
async (): Promise<ModelTypeOption[]> => {
const response = await api.getModelFolders()
@@ -74,6 +75,11 @@ export const useModelTypes = createSharedComposable(() => {
}
)
async function fetchModelTypes() {
if (isReady.value || isLoading.value) return
await execute()
}
return {
modelTypes,
isLoading,

View File

@@ -10,10 +10,11 @@ const zAsset = z.object({
tags: z.array(z.string()).optional().default([]),
preview_id: z.string().nullable().optional(),
preview_url: z.string().optional(),
created_at: z.string(),
created_at: z.string().optional(),
updated_at: z.string().optional(),
is_immutable: z.boolean().optional(),
last_access_time: z.string().optional(),
metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
user_metadata: z.record(z.unknown()).optional() // API allows arbitrary key-value pairs
})
@@ -90,6 +91,21 @@ export type AsyncUploadResponse = z.infer<typeof zAsyncUploadResponse>
export type ModelFolder = z.infer<typeof zModelFolder>
export type ModelFile = z.infer<typeof zModelFile>
/** Payload for updating an asset via PUT /assets/:id */
export type AssetUpdatePayload = Partial<
Pick<AssetItem, 'name' | 'tags' | 'user_metadata'>
>
/** User-editable metadata fields for model assets */
const zAssetUserMetadata = z.object({
name: z.string().optional(),
base_model: z.array(z.string()).optional(),
additional_tags: z.array(z.string()).optional(),
user_description: z.string().optional()
})
export type AssetUserMetadata = z.infer<typeof zAssetUserMetadata>
// Legacy interface for backward compatibility (now aligned with Zod schema)
export interface ModelFolderInfo {
name: string

View File

@@ -11,6 +11,7 @@ import type {
AssetItem,
AssetMetadata,
AssetResponse,
AssetUpdatePayload,
AsyncUploadResponse,
ModelFile,
ModelFolder
@@ -320,7 +321,7 @@ function createAssetService() {
*/
async function updateAsset(
id: string,
newData: Partial<AssetMetadata>
newData: AssetUpdatePayload
): Promise<AssetItem> {
const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`, {
method: 'PUT',

View File

@@ -2,8 +2,16 @@ import { describe, expect, it } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getAssetAdditionalTags,
getAssetBaseModel,
getAssetDescription
getAssetBaseModels,
getAssetDescription,
getAssetDisplayName,
getAssetModelType,
getAssetSourceUrl,
getAssetTriggerPhrases,
getAssetUserDescription,
getSourceName
} from '@/platform/assets/utils/assetMetadataUtils'
describe('assetMetadataUtils', () => {
@@ -20,20 +28,17 @@ describe('assetMetadataUtils', () => {
}
describe('getAssetDescription', () => {
it('should return string description when present', () => {
const asset = {
...mockAsset,
user_metadata: { description: 'A test model' }
}
expect(getAssetDescription(asset)).toBe('A test model')
})
it('should return null when description is not a string', () => {
const asset = {
...mockAsset,
user_metadata: { description: 123 }
}
expect(getAssetDescription(asset)).toBeNull()
it.for([
{
name: 'returns string description when present',
description: 'A test model',
expected: 'A test model'
},
{ name: 'returns null for non-string', description: 123, expected: null },
{ name: 'returns null for null', description: null, expected: null }
])('$name', ({ description, expected }) => {
const asset = { ...mockAsset, user_metadata: { description } }
expect(getAssetDescription(asset)).toBe(expected)
})
it('should return null when no metadata', () => {
@@ -42,24 +47,228 @@ describe('assetMetadataUtils', () => {
})
describe('getAssetBaseModel', () => {
it('should return string base_model when present', () => {
const asset = {
...mockAsset,
user_metadata: { base_model: 'SDXL' }
}
expect(getAssetBaseModel(asset)).toBe('SDXL')
})
it('should return null when base_model is not a string', () => {
const asset = {
...mockAsset,
user_metadata: { base_model: 123 }
}
expect(getAssetBaseModel(asset)).toBeNull()
it.for([
{
name: 'returns string base_model when present',
base_model: 'SDXL',
expected: 'SDXL'
},
{ name: 'returns null for non-string', base_model: 123, expected: null },
{ name: 'returns null for null', base_model: null, expected: null }
])('$name', ({ base_model, expected }) => {
const asset = { ...mockAsset, user_metadata: { base_model } }
expect(getAssetBaseModel(asset)).toBe(expected)
})
it('should return null when no metadata', () => {
expect(getAssetBaseModel(mockAsset)).toBeNull()
})
})
describe('getAssetDisplayName', () => {
it.for([
{
name: 'returns name from user_metadata when present',
user_metadata: { name: 'My Custom Name' },
expected: 'My Custom Name'
},
{
name: 'falls back to asset name for non-string',
user_metadata: { name: 123 },
expected: 'test-model'
},
{
name: 'falls back to asset name for undefined',
user_metadata: undefined,
expected: 'test-model'
}
])('$name', ({ user_metadata, expected }) => {
const asset = { ...mockAsset, user_metadata }
expect(getAssetDisplayName(asset)).toBe(expected)
})
})
describe('getAssetSourceUrl', () => {
it.for([
{
name: 'constructs URL from civitai format',
source_arn: 'civitai:model:123:version:456',
expected: 'https://civitai.com/models/123?modelVersionId=456'
},
{ name: 'returns null for non-string', source_arn: 123, expected: null },
{
name: 'returns null for unrecognized format',
source_arn: 'unknown:format',
expected: null
}
])('$name', ({ source_arn, expected }) => {
const asset = { ...mockAsset, user_metadata: { source_arn } }
expect(getAssetSourceUrl(asset)).toBe(expected)
})
it('should return null when no metadata', () => {
expect(getAssetSourceUrl(mockAsset)).toBeNull()
})
})
describe('getAssetTriggerPhrases', () => {
it.for([
{
name: 'returns array when array present',
trained_words: ['phrase1', 'phrase2'],
expected: ['phrase1', 'phrase2']
},
{
name: 'wraps single string in array',
trained_words: 'single phrase',
expected: ['single phrase']
},
{
name: 'filters non-string values from array',
trained_words: ['valid', 123, 'also valid', null],
expected: ['valid', 'also valid']
}
])('$name', ({ trained_words, expected }) => {
const asset = { ...mockAsset, user_metadata: { trained_words } }
expect(getAssetTriggerPhrases(asset)).toEqual(expected)
})
it('should return empty array when no metadata', () => {
expect(getAssetTriggerPhrases(mockAsset)).toEqual([])
})
})
describe('getAssetAdditionalTags', () => {
it.for([
{
name: 'returns array of tags when present',
additional_tags: ['tag1', 'tag2'],
expected: ['tag1', 'tag2']
},
{
name: 'filters non-string values from array',
additional_tags: ['valid', 123, 'also valid'],
expected: ['valid', 'also valid']
},
{
name: 'returns empty array for non-array',
additional_tags: 'not an array',
expected: []
}
])('$name', ({ additional_tags, expected }) => {
const asset = { ...mockAsset, user_metadata: { additional_tags } }
expect(getAssetAdditionalTags(asset)).toEqual(expected)
})
it('should return empty array when no metadata', () => {
expect(getAssetAdditionalTags(mockAsset)).toEqual([])
})
})
describe('getSourceName', () => {
it.for([
{
name: 'returns Civitai for civitai.com',
url: 'https://civitai.com/models/123',
expected: 'Civitai'
},
{
name: 'returns Hugging Face for huggingface.co',
url: 'https://huggingface.co/org/model',
expected: 'Hugging Face'
},
{
name: 'returns Source for unknown URLs',
url: 'https://example.com/model',
expected: 'Source'
}
])('$name', ({ url, expected }) => {
expect(getSourceName(url)).toBe(expected)
})
})
describe('getAssetBaseModels', () => {
it.for([
{
name: 'array of strings',
base_model: ['SDXL', 'SD1.5', 'Flux'],
expected: ['SDXL', 'SD1.5', 'Flux']
},
{
name: 'filters non-string entries',
base_model: ['SDXL', 123, 'SD1.5', null, undefined],
expected: ['SDXL', 'SD1.5']
},
{
name: 'single string wrapped in array',
base_model: 'SDXL',
expected: ['SDXL']
},
{
name: 'non-array/string returns empty',
base_model: 123,
expected: []
},
{ name: 'undefined returns empty', base_model: undefined, expected: [] }
])('$name', ({ base_model, expected }) => {
const asset = { ...mockAsset, user_metadata: { base_model } }
expect(getAssetBaseModels(asset)).toEqual(expected)
})
it('should return empty array when no metadata', () => {
expect(getAssetBaseModels(mockAsset)).toEqual([])
})
})
describe('getAssetModelType', () => {
it.for([
{
name: 'returns model type from tags',
tags: ['models', 'checkpoints'],
expected: 'checkpoints'
},
{
name: 'extracts last segment from path-style tags',
tags: ['models', 'models/loras'],
expected: 'loras'
},
{
name: 'returns null when only models tag',
tags: ['models'],
expected: null
},
{ name: 'returns null when tags empty', tags: [], expected: null }
])('$name', ({ tags, expected }) => {
const asset = { ...mockAsset, tags }
expect(getAssetModelType(asset)).toBe(expected)
})
})
describe('getAssetUserDescription', () => {
it.for([
{
name: 'returns description when present',
user_description: 'A custom user description',
expected: 'A custom user description'
},
{
name: 'returns empty for non-string',
user_description: 123,
expected: ''
},
{ name: 'returns empty for null', user_description: null, expected: '' },
{
name: 'returns empty for undefined',
user_description: undefined,
expected: ''
}
])('$name', ({ user_description, expected }) => {
const asset = { ...mockAsset, user_metadata: { user_description } }
expect(getAssetUserDescription(asset)).toBe(expected)
})
it('should return empty string when no metadata', () => {
expect(getAssetUserDescription(mockAsset)).toBe('')
})
})
})

View File

@@ -1,27 +1,151 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
/**
* Type-safe utilities for extracting metadata from assets
* Type-safe utilities for extracting metadata from assets.
* These utilities check user_metadata first, then metadata, then fallback.
*/
/**
* Helper to get a string property from user_metadata or metadata
*/
function getStringProperty(asset: AssetItem, key: string): string | undefined {
const userValue = asset.user_metadata?.[key]
if (typeof userValue === 'string') return userValue
const metaValue = asset.metadata?.[key]
if (typeof metaValue === 'string') return metaValue
return undefined
}
/**
* Safely extracts string description from asset metadata
* Checks user_metadata first, then metadata, then returns null
* @param asset - The asset to extract description from
* @returns The description string or null if not present/not a string
*/
export function getAssetDescription(asset: AssetItem): string | null {
return typeof asset.user_metadata?.description === 'string'
? asset.user_metadata.description
: null
return getStringProperty(asset, 'description') ?? null
}
/**
* Safely extracts string base_model from asset metadata
* Checks user_metadata first, then metadata, then returns null
* @param asset - The asset to extract base_model from
* @returns The base_model string or null if not present/not a string
*/
export function getAssetBaseModel(asset: AssetItem): string | null {
return typeof asset.user_metadata?.base_model === 'string'
? asset.user_metadata.base_model
: null
return getStringProperty(asset, 'base_model') ?? null
}
/**
* Extracts base models as an array from asset metadata
* Checks user_metadata first, then metadata, then returns empty array
* @param asset - The asset to extract base models from
* @returns Array of base model strings
*/
export function getAssetBaseModels(asset: AssetItem): string[] {
const baseModel =
asset.user_metadata?.base_model ?? asset.metadata?.base_model
if (Array.isArray(baseModel)) {
return baseModel.filter((m): m is string => typeof m === 'string')
}
if (typeof baseModel === 'string' && baseModel) {
return [baseModel]
}
return []
}
/**
* Gets the display name for an asset
* Checks user_metadata.name first, then metadata.name, then asset.name
* @param asset - The asset to get display name from
* @returns The display name
*/
export function getAssetDisplayName(asset: AssetItem): string {
return getStringProperty(asset, 'name') ?? asset.name
}
/**
* Constructs source URL from asset's source_arn
* @param asset - The asset to extract source URL from
* @returns The source URL or null if not present/parseable
*/
export function getAssetSourceUrl(asset: AssetItem): string | null {
// Note: Reversed priority for backwards compatibility
const sourceArn =
asset.metadata?.source_arn ?? asset.user_metadata?.source_arn
if (typeof sourceArn !== 'string') return null
const civitaiMatch = sourceArn.match(
/^civitai:model:(\d+):version:(\d+)(?::file:\d+)?$/
)
if (civitaiMatch) {
const [, modelId, versionId] = civitaiMatch
return `https://civitai.com/models/${modelId}?modelVersionId=${versionId}`
}
return null
}
/**
* Extracts trigger phrases from asset metadata
* Checks user_metadata first, then metadata, then returns empty array
* @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')
}
if (typeof phrases === 'string') return [phrases]
return []
}
/**
* Extracts additional tags from asset user_metadata
* @param asset - The asset to extract tags from
* @returns Array of user-defined tags
*/
export function getAssetAdditionalTags(asset: AssetItem): string[] {
const tags = asset.user_metadata?.additional_tags
if (Array.isArray(tags)) {
return tags.filter((t): t is string => typeof t === 'string')
}
return []
}
/**
* Determines the source name from a URL
* @param url - The source URL
* @returns Human-readable source name
*/
export function getSourceName(url: string): string {
if (url.includes('civitai.com')) return 'Civitai'
if (url.includes('huggingface.co')) return 'Hugging Face'
return 'Source'
}
/**
* Extracts the model type from asset tags
* @param asset - The asset to extract model type from
* @returns The model type string or null if not present
*/
export function getAssetModelType(asset: AssetItem): string | null {
const typeTag = asset.tags?.find((tag) => tag && tag !== 'models')
if (!typeTag) return null
return typeTag.includes('/') ? (typeTag.split('/').pop() ?? null) : typeTag
}
/**
* Extracts user description from asset user_metadata
* @param asset - The asset to extract user description from
* @returns The user description string or empty string if not present
*/
export function getAssetUserDescription(asset: AssetItem): string {
return typeof asset.user_metadata?.user_description === 'string'
? asset.user_metadata.user_description
: ''
}

View File

@@ -45,7 +45,7 @@ const timeOptions = {
second: 'numeric'
} as const
function formatTime(time: string) {
function formatTime(time?: string) {
if (!time) return ''
const date = new Date(time)
return `${d(date, dateOptions)} | ${d(date, timeOptions)}`

View File

@@ -15,6 +15,7 @@ vi.mock('@/stores/assetsStore', () => ({
getAssets: () => [],
isModelLoading: () => false,
getError: () => undefined,
hasAssetKey: () => false,
updateModelsForNodeType: mockUpdateModelsForNodeType
})
}))

View File

@@ -11,6 +11,7 @@ vi.mock('@/platform/distribution/types', () => ({
const mockAssetsByKey = new Map<string, AssetItem[]>()
const mockLoadingByKey = new Map<string, boolean>()
const mockErrorByKey = new Map<string, Error | undefined>()
const mockInitializedKeys = new Set<string>()
const mockUpdateModelsForNodeType = vi.fn()
const mockGetCategoryForNodeType = vi.fn()
@@ -19,6 +20,7 @@ vi.mock('@/stores/assetsStore', () => ({
getAssets: (key: string) => mockAssetsByKey.get(key) ?? [],
isModelLoading: (key: string) => mockLoadingByKey.get(key) ?? false,
getError: (key: string) => mockErrorByKey.get(key),
hasAssetKey: (key: string) => mockInitializedKeys.has(key),
updateModelsForNodeType: mockUpdateModelsForNodeType
})
}))
@@ -35,6 +37,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
mockAssetsByKey.clear()
mockLoadingByKey.clear()
mockErrorByKey.clear()
mockInitializedKeys.clear()
mockGetCategoryForNodeType.mockReturnValue(undefined)
mockUpdateModelsForNodeType.mockImplementation(
@@ -76,6 +79,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockInitializedKeys.add(_nodeType)
mockAssetsByKey.set(_nodeType, mockAssets)
mockLoadingByKey.set(_nodeType, false)
return mockAssets
@@ -108,6 +112,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockInitializedKeys.add(_nodeType)
mockErrorByKey.set(_nodeType, mockError)
mockAssetsByKey.set(_nodeType, [])
mockLoadingByKey.set(_nodeType, false)
@@ -130,6 +135,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockInitializedKeys.add(_nodeType)
mockAssetsByKey.set(_nodeType, [])
mockLoadingByKey.set(_nodeType, false)
return []
@@ -154,6 +160,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockInitializedKeys.add(_nodeType)
mockAssetsByKey.set(_nodeType, mockAssets)
mockLoadingByKey.set(_nodeType, false)
return mockAssets
@@ -182,6 +189,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
mockGetCategoryForNodeType.mockReturnValue('loras')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockInitializedKeys.add(_nodeType)
mockAssetsByKey.set(_nodeType, mockAssets)
mockLoadingByKey.set(_nodeType, false)
return mockAssets
@@ -209,6 +217,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockInitializedKeys.add(_nodeType)
mockAssetsByKey.set(_nodeType, mockAssets)
mockLoadingByKey.set(_nodeType, false)
return mockAssets

View File

@@ -48,7 +48,7 @@ export function useAssetWidgetData(
})
const dropdownItems = computed<DropdownItem[]>(() => {
return assets.value.map((asset) => ({
return (assets.value ?? []).map((asset) => ({
id: asset.id,
name:
(asset.user_metadata?.filename as string | undefined) ?? asset.name,
@@ -65,10 +65,10 @@ export function useAssetWidgetData(
return
}
const existingAssets = assetsStore.getAssets(currentNodeType) ?? []
const hasData = existingAssets.length > 0
const isLoading = assetsStore.isModelLoading(currentNodeType)
const hasBeenInitialized = assetsStore.hasAssetKey(currentNodeType)
if (!hasData) {
if (!isLoading && !hasBeenInitialized) {
await assetsStore.updateModelsForNodeType(currentNodeType)
}
},

View File

@@ -319,8 +319,8 @@ describe('assetsStore - Refactored (Option A)', () => {
// Verify sorting (newest first - lower index = newer)
for (let i = 1; i < store.historyAssets.length; i++) {
const prevDate = new Date(store.historyAssets[i - 1].created_at)
const currDate = new Date(store.historyAssets[i].created_at)
const prevDate = new Date(store.historyAssets[i - 1].created_at ?? 0)
const currDate = new Date(store.historyAssets[i].created_at ?? 0)
expect(prevDate.getTime()).toBeGreaterThanOrEqual(currDate.getTime())
}
})
@@ -435,8 +435,8 @@ describe('assetsStore - Refactored (Option A)', () => {
// Should still maintain sorting
for (let i = 1; i < store.historyAssets.length; i++) {
const prevDate = new Date(store.historyAssets[i - 1].created_at)
const currDate = new Date(store.historyAssets[i].created_at)
const prevDate = new Date(store.historyAssets[i - 1].created_at ?? 0)
const currDate = new Date(store.historyAssets[i].created_at ?? 0)
expect(prevDate.getTime()).toBeGreaterThanOrEqual(currDate.getTime())
}
})

View File

@@ -78,7 +78,8 @@ function mapHistoryToAssets(historyItems: JobListItem[]): AssetItem[] {
return assetItems.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
new Date(b.created_at ?? 0).getTime() -
new Date(a.created_at ?? 0).getTime()
)
}
@@ -145,9 +146,9 @@ export const useAssetsStore = defineStore('assets', () => {
loadedIds.add(asset.id)
// Find insertion index to maintain sorted order (newest first)
const assetTime = new Date(asset.created_at).getTime()
const assetTime = new Date(asset.created_at ?? 0).getTime()
const insertIndex = allHistoryItems.value.findIndex(
(item) => new Date(item.created_at).getTime() < assetTime
(item) => new Date(item.created_at ?? 0).getTime() < assetTime
)
if (insertIndex === -1) {
@@ -321,6 +322,10 @@ export const useAssetsStore = defineStore('assets', () => {
return modelStateByKey.value.get(key)?.hasMore ?? false
}
function hasAssetKey(key: string): boolean {
return modelStateByKey.value.has(key)
}
/**
* Internal helper to fetch and cache assets with a given key and fetcher.
* Loads first batch immediately, then progressively loads remaining batches.
@@ -419,13 +424,75 @@ export const useAssetsStore = defineStore('assets', () => {
)
}
/**
* Optimistically update an asset in the cache
* @param assetId The asset ID to update
* @param updates Partial asset data to merge
* @param cacheKey Optional cache key to target (nodeType or 'tag:xxx')
*/
function updateAssetInCache(
assetId: string,
updates: Partial<AssetItem>,
cacheKey?: string
) {
const keysToCheck = cacheKey
? [cacheKey]
: Array.from(modelStateByKey.value.keys())
for (const key of keysToCheck) {
const state = modelStateByKey.value.get(key)
if (!state?.assets) continue
const existingAsset = state.assets.get(assetId)
if (existingAsset) {
const updatedAsset = { ...existingAsset, ...updates }
state.assets.set(assetId, updatedAsset)
assetsArrayCache.delete(key)
if (cacheKey) return
}
}
}
/**
* Update asset metadata with optimistic cache update
* @param assetId The asset ID to update
* @param userMetadata The user_metadata to save
* @param cacheKey Optional cache key to target for optimistic update
*/
async function updateAssetMetadata(
assetId: string,
userMetadata: Record<string, unknown>,
cacheKey?: string
) {
updateAssetInCache(assetId, { user_metadata: userMetadata }, cacheKey)
await assetService.updateAsset(assetId, { user_metadata: userMetadata })
}
/**
* Update asset tags with optimistic cache update
* @param assetId The asset ID to update
* @param tags The tags array to save
* @param cacheKey Optional cache key to target for optimistic update
*/
async function updateAssetTags(
assetId: string,
tags: string[],
cacheKey?: string
) {
updateAssetInCache(assetId, { tags }, cacheKey)
await assetService.updateAsset(assetId, { tags })
}
return {
getAssets,
isLoading,
getError,
hasMore,
hasAssetKey,
updateModelsForNodeType,
updateModelsForTag
updateModelsForTag,
updateAssetMetadata,
updateAssetTags
}
}
@@ -435,8 +502,11 @@ export const useAssetsStore = defineStore('assets', () => {
isLoading: () => false,
getError: () => undefined,
hasMore: () => false,
hasAssetKey: () => false,
updateModelsForNodeType: async () => {},
updateModelsForTag: async () => {}
updateModelsForTag: async () => {},
updateAssetMetadata: async () => {},
updateAssetTags: async () => {}
}
}
@@ -445,8 +515,11 @@ export const useAssetsStore = defineStore('assets', () => {
isLoading: isModelLoading,
getError,
hasMore,
hasAssetKey,
updateModelsForNodeType,
updateModelsForTag
updateModelsForTag,
updateAssetMetadata,
updateAssetTags
} = getModelState()
// Watch for completed downloads and refresh model caches
@@ -511,9 +584,12 @@ export const useAssetsStore = defineStore('assets', () => {
isModelLoading,
getError,
hasMore,
hasAssetKey,
// Model assets - actions
updateModelsForNodeType,
updateModelsForTag
updateModelsForTag,
updateAssetMetadata,
updateAssetTags
}
})